mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-20 21:57:14 +08:00
Compare commits
138 Commits
feat-cow-d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76d6186bd3 | ||
|
|
a78fb711ad | ||
|
|
b755d18e63 | ||
|
|
3cdc13d69d | ||
|
|
ea0c90f4ea | ||
|
|
f9e7c07af8 | ||
|
|
c4e5d11da9 | ||
|
|
177f494207 | ||
|
|
0c76d851cb | ||
|
|
0783d64f1f | ||
|
|
064740aaad | ||
|
|
8071ea496a | ||
|
|
83209a8b2c | ||
|
|
3db595124c | ||
|
|
6e10b8e3d2 | ||
|
|
2635c12a7f | ||
|
|
64e8f56a7b | ||
|
|
67be468ff1 | ||
|
|
209f1f0a6f | ||
|
|
f7a0184ea2 | ||
|
|
7d8751f04b | ||
|
|
3ee7b164d4 | ||
|
|
d7abbd3baa | ||
|
|
0db8a24441 | ||
|
|
2f86e9b608 | ||
|
|
9d8c6557ae | ||
|
|
257eb5d598 | ||
|
|
9f63453e67 | ||
|
|
5858bda854 | ||
|
|
c784c89fe3 | ||
|
|
186dae059e | ||
|
|
ded3d2c929 | ||
|
|
b55c592714 | ||
|
|
1d226979d1 | ||
|
|
6fad628551 | ||
|
|
8b426ed71d | ||
|
|
c7060c147d | ||
|
|
db49532211 | ||
|
|
1a52de241d | ||
|
|
b35501e6ad | ||
|
|
7f8f690497 | ||
|
|
356b02ac79 | ||
|
|
5d55ec0f8c | ||
|
|
d5fdd644cf | ||
|
|
eeb4b7981e | ||
|
|
5f1c98881d | ||
|
|
94d0f56689 | ||
|
|
4d690341a7 | ||
|
|
d8c419227c | ||
|
|
8c7cda89dc | ||
|
|
42a5cf9538 | ||
|
|
996406eb2a | ||
|
|
b98fbae6f6 | ||
|
|
4d87703e31 | ||
|
|
9ef64b7858 | ||
|
|
bf0c26d3c4 | ||
|
|
65970c564c | ||
|
|
ed36ca99c0 | ||
|
|
ce09efe640 | ||
|
|
adaf9a7813 | ||
|
|
06f9492518 | ||
|
|
530042675e | ||
|
|
ca404aeb24 | ||
|
|
a6c975f92c | ||
|
|
dd3cadbd81 | ||
|
|
efbabfcace | ||
|
|
09c71ef1d9 | ||
|
|
0bb8208f36 | ||
|
|
56571c77ca | ||
|
|
6c353d389b | ||
|
|
583217d396 | ||
|
|
93162d2f10 | ||
|
|
2e74295807 | ||
|
|
93bf8844de | ||
|
|
fcc520df47 | ||
|
|
a94f4e3c18 | ||
|
|
de9e7f0e84 | ||
|
|
ed5b2d6ce6 | ||
|
|
2cd3fa7981 | ||
|
|
f01cc3a0b4 | ||
|
|
51bf09208d | ||
|
|
bf0831a664 | ||
|
|
96b1fccf76 | ||
|
|
4b57971d33 | ||
|
|
d531e14fbf | ||
|
|
8df38a23d2 | ||
|
|
38105e6539 | ||
|
|
14c6577d51 | ||
|
|
f051a58db5 | ||
|
|
825d990312 | ||
|
|
cb31013584 | ||
|
|
dd74d1dabe | ||
|
|
75f3952ac6 | ||
|
|
37423fbb31 | ||
|
|
00c3436d48 | ||
|
|
377b4e5cb8 | ||
|
|
a427586b89 | ||
|
|
a951494489 | ||
|
|
a871c0437d | ||
|
|
013960cd5a | ||
|
|
60aebf41a8 | ||
|
|
2cf521e57e | ||
|
|
dad3a84efb | ||
|
|
ae864c7ff9 | ||
|
|
3b33114a40 | ||
|
|
e0f49ac619 | ||
|
|
01ec49afd2 | ||
|
|
b44154fe02 | ||
|
|
b8dad38622 | ||
|
|
80fea77c86 | ||
|
|
e5f3eb48d4 | ||
|
|
ca876b0c65 | ||
|
|
0a762b8c08 | ||
|
|
fd90a89b45 | ||
|
|
f82eb39d23 | ||
|
|
2786148153 | ||
|
|
2959cfea32 | ||
|
|
e536232963 | ||
|
|
778d78cebe | ||
|
|
538281da51 | ||
|
|
12cd626949 | ||
|
|
ff64a7930e | ||
|
|
5d726fe340 | ||
|
|
e1834124d4 | ||
|
|
f49e965736 | ||
|
|
936eaf5939 | ||
|
|
7047b30e27 | ||
|
|
5c67e970d1 | ||
|
|
8023c4e8b7 | ||
|
|
641b84519c | ||
|
|
0c8cb974e2 | ||
|
|
915edbe145 | ||
|
|
0c20c5c159 | ||
|
|
9ea0017778 | ||
|
|
f1cdc2d2cc | ||
|
|
5db2998e3d | ||
|
|
0bc0f2b930 | ||
|
|
84d6848e67 |
122
.github/scripts/register-releases.mjs
vendored
Normal file
122
.github/scripts/register-releases.mjs
vendored
Normal file
@@ -0,0 +1,122 @@
|
||||
// Build the D1 upsert SQL for a desktop release from the files in a directory.
|
||||
//
|
||||
// Each mac release has TWO artifacts that map to a SINGLE D1 row:
|
||||
// - <name>-<arch>.dmg -> manual download (filename / size / sha512)
|
||||
// - <name>-<arch>.zip -> auto-update (update_filename / update_size /
|
||||
// update_sha512)
|
||||
// electron-updater's MacUpdater can only consume a zip, never a dmg, so the
|
||||
// feed serves the zip while the website serves the dmg. Windows has only the
|
||||
// .exe (stored in the main columns; it's both the download and the update).
|
||||
//
|
||||
// We emit ONE `INSERT OR REPLACE` per (version, platform) carrying BOTH halves,
|
||||
// because two replaces on the same primary key would drop whichever came first.
|
||||
//
|
||||
// Usage:
|
||||
// node register-releases.mjs --dir dist --version 1.2.0 \
|
||||
// --sql out.sql [--latest]
|
||||
//
|
||||
// --latest mark these rows is_latest=1 AND clear the previous latest for
|
||||
// each platform (used by the publish/promote workflow). Without it
|
||||
// rows are written unpublished (is_latest=0) — the build stage.
|
||||
//
|
||||
// sha512 is base64 (the exact format electron-updater validates).
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
|
||||
function arg(name, fallback = undefined) {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
if (i === -1) return fallback
|
||||
const next = process.argv[i + 1]
|
||||
// Boolean flag (no value or next token is another flag).
|
||||
if (next === undefined || next.startsWith('--')) return true
|
||||
return next
|
||||
}
|
||||
|
||||
const dir = arg('dir', 'dist')
|
||||
const version = arg('version')
|
||||
const sqlPath = arg('sql', 'd1.sql')
|
||||
const makeLatest = arg('latest', false) === true
|
||||
|
||||
if (!version) {
|
||||
console.error('register-releases: --version is required')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sha512 = (f) =>
|
||||
execSync(`openssl dgst -sha512 -binary "${f}" | openssl base64 -A`, {
|
||||
shell: '/bin/bash',
|
||||
})
|
||||
.toString()
|
||||
.trim()
|
||||
|
||||
// SQL-escape single quotes (base64/keys shouldn't contain them, but be safe).
|
||||
const q = (s) => String(s).replace(/'/g, "''")
|
||||
|
||||
// platform -> { main: {key,size,sha}, upd: {key,size,sha} }
|
||||
const rows = {}
|
||||
|
||||
for (const base of fs.readdirSync(dir)) {
|
||||
const f = `${dir}/${base}`
|
||||
if (fs.statSync(f).isDirectory()) continue
|
||||
|
||||
let platform
|
||||
let slot
|
||||
if (/arm64\.dmg$/.test(base)) {
|
||||
platform = 'mac-arm64'
|
||||
slot = 'main'
|
||||
} else if (/x64\.dmg$/.test(base)) {
|
||||
platform = 'mac-x64'
|
||||
slot = 'main'
|
||||
} else if (/arm64\.zip$/.test(base)) {
|
||||
platform = 'mac-arm64'
|
||||
slot = 'upd'
|
||||
} else if (/x64\.zip$/.test(base)) {
|
||||
platform = 'mac-x64'
|
||||
slot = 'upd'
|
||||
} else if (/win7.*\.exe$/i.test(base)) {
|
||||
// Legacy Win7/8 build (Electron 22). Its artifactName carries a "win7"
|
||||
// segment so it never collides with the standard win exe in the same
|
||||
// v<version>/ folder — just like arm64/x64 distinguish the two mac builds.
|
||||
platform = 'win-legacy'
|
||||
slot = 'main'
|
||||
} else if (/\.exe$/.test(base)) {
|
||||
platform = 'win'
|
||||
slot = 'main'
|
||||
} else {
|
||||
console.log('Skipping unrecognized artifact:', base)
|
||||
continue
|
||||
}
|
||||
|
||||
rows[platform] ||= {}
|
||||
rows[platform][slot] = {
|
||||
key: `v${version}/${base}`,
|
||||
size: fs.statSync(f).size,
|
||||
sha: sha512(f),
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(rows).length === 0) {
|
||||
console.error('register-releases: no recognized artifacts in', dir)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const isLatest = makeLatest ? 1 : 0
|
||||
const sql = []
|
||||
for (const [platform, r] of Object.entries(rows)) {
|
||||
const m = r.main || { key: '', size: 0, sha: '' }
|
||||
const u = r.upd || { key: '', size: 0, sha: '' }
|
||||
if (makeLatest) {
|
||||
// Clear the previous latest for this platform before promoting the new row.
|
||||
sql.push(`UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';`)
|
||||
}
|
||||
sql.push(
|
||||
`INSERT OR REPLACE INTO releases ` +
|
||||
`(version, platform, filename, size, sha512, update_filename, update_size, update_sha512, is_latest) ` +
|
||||
`VALUES ('${version}', '${platform}', '${q(m.key)}', ${m.size}, '${q(m.sha)}', ` +
|
||||
`'${q(u.key)}', ${u.size}, '${q(u.sha)}', ${isLatest});`
|
||||
)
|
||||
}
|
||||
|
||||
fs.writeFileSync(sqlPath, sql.join('\n') + '\n')
|
||||
console.log(`register-releases: wrote ${sql.length} statement(s) to ${sqlPath}`)
|
||||
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
|
||||
192
.github/workflows/release.yml
vendored
192
.github/workflows/release.yml
vendored
@@ -1,14 +1,22 @@
|
||||
name: Release Desktop
|
||||
|
||||
# Tag-driven release: push a tag like `v1.2.0` to build and publish the
|
||||
# desktop client for macOS (arm64 + x64) and Windows (x64). The tag is the
|
||||
# single source of truth for the version — it's written into package.json at
|
||||
# build time, so the maintainer never edits the version by hand.
|
||||
# STAGE 1 of the decoupled release pipeline: BUILD ONLY.
|
||||
# Builds the desktop client for macOS (arm64 + x64) and Windows (x64), mirrors
|
||||
# the installers to R2, and registers them in D1 as UNPUBLISHED (is_latest=0)
|
||||
# so the website keeps serving the previous release. It does NOT notarize
|
||||
# (Apple's notary service stalls this large bundle for hours) and does NOT
|
||||
# create a GitHub Release.
|
||||
#
|
||||
# Full flow:
|
||||
# 1. (this workflow) build + upload to R2 + D1 as unpublished.
|
||||
# 2. (local) download the mac dmgs, run desktop/build/notarize-dmg.sh to
|
||||
# notarize + staple + re-upload the stapled dmgs to R2.
|
||||
# 3. (Publish Desktop workflow) flip D1 is_latest=1 and attach GitHub
|
||||
# Release assets — makes the version live on the site.
|
||||
#
|
||||
# Manual only: run stage 1 via workflow_dispatch. Tag pushes do NOT trigger a
|
||||
# build, so cutting a release tag never rebuilds installers or overwrites R2.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
# Manual trigger for testing the full pipeline without cutting a real tag.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -97,6 +105,39 @@ jobs:
|
||||
shell: bash
|
||||
run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
# Compile renderer + main in its OWN step, alone, so the npm.cmd batch
|
||||
# wrapper (see the note on the build step below) can't take out anything
|
||||
# after it.
|
||||
- name: Compile (vite + tsc)
|
||||
working-directory: desktop
|
||||
shell: bash
|
||||
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)
|
||||
working-directory: desktop
|
||||
shell: bash
|
||||
@@ -108,41 +149,77 @@ jobs:
|
||||
# is the correct state for unsigned builds.
|
||||
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||
# Windows code signing via the signing CLI. Credentials are
|
||||
# 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: |
|
||||
npm run build
|
||||
|
||||
# Only export signing vars when provided. Empty strings are NOT the
|
||||
# same as unset to electron-builder: an empty CSC_LINK is treated as
|
||||
# a (broken) certificate path and aborts the build. Leaving them
|
||||
# unset makes electron-builder fall back to an unsigned build.
|
||||
# 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
|
||||
# Windows build (electron-builder would try to load it and fail), and
|
||||
# vice versa. electron-builder reads a single CSC_LINK/CSC_KEY_PASSWORD
|
||||
# pair, so we set it per-platform. An empty CSC_LINK is treated by
|
||||
# electron-builder as a broken cert path, so we leave it entirely unset
|
||||
# for an unsigned build.
|
||||
#
|
||||
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
|
||||
# (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
|
||||
mac)
|
||||
if [ -n "$MAC_CSC_LINK" ]; then
|
||||
export CSC_LINK="$MAC_CSC_LINK"
|
||||
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
||||
fi
|
||||
if [ -z "$WIN_CSC_LINK" ]; then
|
||||
unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Publish to the GitHub Release on tag pushes; otherwise build only.
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
PUBLISH=always
|
||||
else
|
||||
PUBLISH=never
|
||||
fi
|
||||
npx electron-builder ${{ matrix.eb_flags }} --publish "$PUBLISH"
|
||||
# Never let electron-builder publish: our publish target is a generic
|
||||
# (read-only) feed served from R2/D1, which it can't upload to. We mirror
|
||||
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
||||
# `--publish never` still emits the latest*.yml files.
|
||||
#
|
||||
# CONFIG PER PLATFORM: each platform loads its OWN dynamic config.
|
||||
# mac -> electron-builder.js (injects mac.binaries for signing)
|
||||
# win -> electron-builder.win.js (wires the sign hook; electron-builder
|
||||
# signs the app, backend and installer)
|
||||
# HISTORY: passing --config on Windows previously broke the build (no
|
||||
# installer, job still green). That happened because the MAC config
|
||||
# (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
|
||||
# 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.
|
||||
case "${{ matrix.platform }}" in
|
||||
mac) config_arg="--config electron-builder.js" ;;
|
||||
win) config_arg="--config electron-builder.win.js" ;;
|
||||
*) config_arg="" ;;
|
||||
esac
|
||||
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
||||
|
||||
# Upload artifacts regardless of outcome, so a failed run still surfaces
|
||||
# the built installers (and, on success, the notarized+stapled dmg).
|
||||
- name: Upload artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
# One bundle per platform/arch so the publish job can collect them all.
|
||||
name: cowagent-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: |
|
||||
desktop/release/*.dmg
|
||||
desktop/release/*.zip
|
||||
desktop/release/*.exe
|
||||
desktop/release/*.yml
|
||||
desktop/release/*.blockmap
|
||||
@@ -201,10 +278,13 @@ jobs:
|
||||
id: stage
|
||||
run: |
|
||||
mkdir -p dist
|
||||
# Flatten installers from every per-platform artifact dir; only the
|
||||
# user-facing installers go to R2 (updater .yml/.blockmap stay on the
|
||||
# GitHub Release, which electron-updater reads directly).
|
||||
find artifacts -type f \( -name '*.dmg' -o -name '*.exe' \) -exec cp {} dist/ \;
|
||||
# Flatten installers + their .blockmap (used by electron-updater for
|
||||
# differential downloads) from every per-platform artifact dir. The
|
||||
# .yml feed is generated dynamically by the /update Function from D1,
|
||||
# so the yml files themselves don't need to go to R2.
|
||||
# .zip is the mac auto-update artifact (electron-updater's MacUpdater
|
||||
# can ONLY consume zip, not dmg — the dmg is for manual downloads).
|
||||
find artifacts -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' \) -exec cp {} dist/ \;
|
||||
echo "Staged files:"; ls -la dist
|
||||
# When the whole matrix failed there's nothing to publish; flag it so
|
||||
# the R2/D1 steps skip instead of writing an empty/partial release.
|
||||
@@ -239,36 +319,18 @@ jobs:
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
VER: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
# Map each installer filename to a platform id. dmg arch is in the
|
||||
# name (…-arm64.dmg / …-x64.dmg); .exe is the Windows installer.
|
||||
sql_file="$(mktemp)"
|
||||
# This build job ALWAYS registers rows as unpublished (is_latest=0), so
|
||||
# /download/<p>/latest keeps serving the previous release and the new
|
||||
# version stays invisible on the site. macOS dmgs still need to be
|
||||
# notarized+stapled locally (build/notarize-dmg.sh) before they're
|
||||
# safe to ship. Promotion to latest happens later, only after
|
||||
# notarization, via the separate "Publish Desktop" workflow.
|
||||
echo "==> Registering $VER as unpublished (is_latest=0)."
|
||||
|
||||
# Pre-releases (e.g. 1.0.0-test / -beta / -rc.1 / -alpha / -dev) are
|
||||
# recorded but NEVER marked latest, so /download/<p>/latest keeps
|
||||
# serving the last stable build. They also must not clear an existing
|
||||
# stable's latest flag. Only a final version (no pre-release suffix)
|
||||
# becomes the new latest and clears the previous one per platform.
|
||||
case "$VER" in
|
||||
*-*) is_latest=0; echo "==> $VER is a pre-release; not marking latest." ;;
|
||||
*) is_latest=1; echo "==> $VER is a stable release; marking latest." ;;
|
||||
esac
|
||||
|
||||
for f in dist/*; do
|
||||
base="$(basename "$f")"
|
||||
size="$(stat -c%s "$f")"
|
||||
case "$base" in
|
||||
*arm64.dmg) platform="mac-arm64" ;;
|
||||
*x64.dmg) platform="mac-x64" ;;
|
||||
*.exe) platform="win" ;;
|
||||
*) echo "Skipping unrecognized artifact: $base"; continue ;;
|
||||
esac
|
||||
key="v${VER}/${base}"
|
||||
# Stable only: clear the previous latest for THIS platform first, so
|
||||
# a partial backfill never wipes other platforms' latest flag.
|
||||
if [ "$is_latest" = "1" ]; then
|
||||
echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file"
|
||||
fi
|
||||
echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, ${is_latest});" >> "$sql_file"
|
||||
done
|
||||
echo "==> D1 statements:"; cat "$sql_file"
|
||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file"
|
||||
# Build one upsert per (version, platform) carrying both the dmg
|
||||
# (manual download) and the mac zip (auto-update) columns. See
|
||||
# .github/scripts/register-releases.mjs for the mapping. No --latest
|
||||
# here: rows stay unpublished until the publish workflow promotes them.
|
||||
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
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -54,7 +54,11 @@ desktop/build/*
|
||||
!desktop/build/cowagent-backend.spec
|
||||
!desktop/build/requirements-desktop.txt
|
||||
!desktop/build/build-backend.sh
|
||||
!desktop/build/entitlements.mac.plist
|
||||
!desktop/build/notarize-dmg.sh
|
||||
|
||||
# Icon authoring scratch dir: intermediate assets used to produce the final
|
||||
# icons. Only the finished icons under desktop/resources/ should be committed.
|
||||
desktop/resources/.icon-work/
|
||||
|
||||
.wrangler/
|
||||
|
||||
17
README.md
17
README.md
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
[English] | [<a href="docs/zh/README.md">中文</a>] | [<a href="docs/ja/README.md">日本語</a>]
|
||||
[English] | [<a href="docs/zh/README.md">中文</a>] | [<a href="docs/zh/README-Hant.md">繁體中文</a>] | [<a href="docs/ja/README.md">日本語</a>]
|
||||
</p>
|
||||
|
||||
**CowAgent** is an open-source super AI assistant that proactively plans tasks, controls your computer and external services, creates and runs Skills, builds a personal knowledge base and long-term memory, and grows alongside you through self-evolution — a reference implementation of Agent Harness engineering.
|
||||
@@ -24,6 +24,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
|
||||
<a href="https://docs.cowagent.ai/intro/index">📖 Docs</a> ·
|
||||
<a href="https://docs.cowagent.ai/guide/quick-start">🚀 Quick Start</a> ·
|
||||
<a href="https://skills.cowagent.ai/">🧩 Skill Hub</a> ·
|
||||
<a href="https://cowagent.ai/download/">💻 Download</a> ·
|
||||
<a href="https://link-ai.tech/cowagent/create">☁️ Try Online</a>
|
||||
</p>
|
||||
|
||||
@@ -95,6 +96,8 @@ cow skill install <name> # install a skill
|
||||
cow install-browser # install browser automation
|
||||
```
|
||||
|
||||
> 💻 Desktop client: download the **[CowAgent Desktop client](https://cowagent.ai/download/)** (macOS / Windows) — the backend is bundled, ready to use out of the box.
|
||||
|
||||
<br/>
|
||||
|
||||
## 🤖 Models
|
||||
@@ -103,14 +106,14 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
||||
|
||||
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 / fable-5 | ✅ | ✅ | | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.6 series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k3 | ✅ | ✅ | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
||||
@@ -199,6 +202,10 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
|
||||
|
||||
## 🏷 Changelog
|
||||
|
||||
> **2026.07.20:** [v2.1.4](https://github.com/zhayujie/CowAgent/releases/tag/2.1.4) — Desktop experience improvements, MCP OAuth authorization, Lark channel enhancements, scheduler improvements and data backup, new models.
|
||||
|
||||
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [Desktop client](https://cowagent.ai/download/) for macOS / Windows, knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models.
|
||||
|
||||
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements.
|
||||
|
||||
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — Self-Evolution, Web console upgrades (message management, parallel sessions), cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus), Python 3.13 support.
|
||||
|
||||
@@ -183,9 +183,15 @@ class ChatService:
|
||||
|
||||
# Register a cancel token so /cancel can abort this in-flight run.
|
||||
# IM channels key on session_id (no per-turn request_id here).
|
||||
from agent.protocol import get_cancel_registry
|
||||
from agent.protocol import get_cancel_registry, get_steer_registry
|
||||
registry = get_cancel_registry()
|
||||
steer_registry = get_steer_registry()
|
||||
cancel_event = registry.register(session_id, session_id=session_id) if session_id else None
|
||||
steer_inbox = (
|
||||
steer_registry.register(session_id)
|
||||
if session_id
|
||||
else None
|
||||
)
|
||||
|
||||
executor = AgentStreamExecutor(
|
||||
agent=agent,
|
||||
@@ -197,6 +203,7 @@ class ChatService:
|
||||
messages=messages_copy,
|
||||
max_context_turns=max_context_turns,
|
||||
cancel_event=cancel_event,
|
||||
steer_inbox=steer_inbox,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -217,6 +224,8 @@ class ChatService:
|
||||
registry.unregister(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
if session_id and steer_inbox is not None:
|
||||
steer_registry.unregister(session_id, steer_inbox)
|
||||
|
||||
# Sync executor messages back to agent (thread-safe).
|
||||
# The executor may have trimmed context, making its list shorter than
|
||||
|
||||
@@ -424,6 +424,11 @@ def run_evolution_for_session(
|
||||
enable_skills=True,
|
||||
runtime_info=getattr(agent, "runtime_info", None),
|
||||
)
|
||||
# Mark this as a restricted review agent so runtime MCP reconciliation
|
||||
# (ToolManager.sync_mcp_into_agent) will NOT silently re-inject MCP tools
|
||||
# that _select_tools()/_guard_tools() intentionally withheld. Without this
|
||||
# flag the review boundary would be re-opened on the first LLM turn.
|
||||
review_agent._evolution_restricted = True
|
||||
# Reuse the live model so it follows the user's configured model.
|
||||
review_agent.model = agent.model
|
||||
# Inject the evolution task brief AFTER the full system prompt: the agent
|
||||
|
||||
@@ -17,6 +17,7 @@ import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional, Iterable
|
||||
from urllib.parse import quote
|
||||
|
||||
from common.log import logger
|
||||
from config import conf
|
||||
@@ -32,6 +33,10 @@ class KnowledgeService:
|
||||
|
||||
PROTECTED_FILES = {"index.md", "log.md"}
|
||||
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
|
||||
IMPORT_EXTENSIONS = {".md", ".txt"}
|
||||
MAX_IMPORT_FILES = 100
|
||||
MAX_IMPORT_FILE_SIZE = 10 * 1024 * 1024
|
||||
MAX_IMPORT_TOTAL_SIZE = 200 * 1024 * 1024
|
||||
|
||||
def __init__(self, workspace_root: str, memory_manager=None):
|
||||
self.workspace_root = os.path.abspath(workspace_root)
|
||||
@@ -75,7 +80,14 @@ class KnowledgeService:
|
||||
|
||||
def _manager(self):
|
||||
if self._memory_manager is None:
|
||||
self._memory_manager = MemoryManager(MemoryConfig(workspace_root=self.workspace_root))
|
||||
# Reuse the shared embedding provider selection so knowledge index
|
||||
# sync gets vectors too, instead of degrading to keyword-only.
|
||||
from agent.memory.embedding import create_default_embedding_provider
|
||||
embedding_provider = create_default_embedding_provider()
|
||||
self._memory_manager = MemoryManager(
|
||||
MemoryConfig(workspace_root=self.workspace_root),
|
||||
embedding_provider=embedding_provider,
|
||||
)
|
||||
return self._memory_manager
|
||||
|
||||
@staticmethod
|
||||
@@ -100,9 +112,9 @@ class KnowledgeService:
|
||||
raise error[0]
|
||||
return result[0] if result else None
|
||||
|
||||
def _sync_index(self, old_paths: Iterable[str]):
|
||||
def _sync_index(self, old_paths: Iterable[str], force: bool = False):
|
||||
old_paths = sorted(set(old_paths))
|
||||
if not old_paths:
|
||||
if not old_paths and not force:
|
||||
return
|
||||
manager = self._manager()
|
||||
for rel_path in old_paths:
|
||||
@@ -110,6 +122,195 @@ class KnowledgeService:
|
||||
manager.mark_dirty()
|
||||
self._run_sync(manager.sync())
|
||||
|
||||
@staticmethod
|
||||
def _extract_title(md_path: Path, fallback: str) -> str:
|
||||
"""Read a markdown file's H1 title, falling back to the file stem."""
|
||||
try:
|
||||
with open(md_path, "r", encoding="utf-8") as f:
|
||||
for _ in range(20):
|
||||
line = f.readline()
|
||||
if not line:
|
||||
break
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("# "):
|
||||
return stripped[2:].strip() or fallback
|
||||
except Exception:
|
||||
pass
|
||||
return fallback
|
||||
|
||||
def rebuild_index_md(self) -> bool:
|
||||
"""Regenerate knowledge/index.md from the actual directory tree.
|
||||
|
||||
Keeps the index in sync with real files so it never drifts or loses
|
||||
documents. Returns True when the file was (re)written.
|
||||
"""
|
||||
root = Path(self.knowledge_dir)
|
||||
if not root.is_dir():
|
||||
return False
|
||||
|
||||
def collect(dir_path: Path) -> list:
|
||||
# Return sorted (rel_path, title) tuples for *.md under dir_path,
|
||||
# excluding protected files at the knowledge root and dot files.
|
||||
entries = []
|
||||
for md in sorted(dir_path.rglob("*.md")):
|
||||
rel = md.relative_to(root).as_posix()
|
||||
if any(part.startswith(".") for part in md.relative_to(root).parts):
|
||||
continue
|
||||
if rel in self.PROTECTED_FILES:
|
||||
continue
|
||||
entries.append((rel, self._extract_title(md, md.stem)))
|
||||
return entries
|
||||
|
||||
all_entries = collect(root)
|
||||
|
||||
def link(rel: str) -> str:
|
||||
# Encode each path segment so spaces / special chars stay valid in
|
||||
# markdown links, while keeping the slashes between segments.
|
||||
encoded = "/".join(quote(part) for part in rel.split("/"))
|
||||
return f"./{encoded}"
|
||||
|
||||
lines = ["# 知识库目录", ""]
|
||||
# Root-level documents first (no category dir).
|
||||
root_docs = [(rel, title) for rel, title in all_entries if "/" not in rel]
|
||||
for rel, title in root_docs:
|
||||
lines.append(f"- [{title}]({link(rel)})")
|
||||
if root_docs:
|
||||
lines.append("")
|
||||
|
||||
# Group remaining documents by their top-level category.
|
||||
categories = {}
|
||||
for rel, title in all_entries:
|
||||
if "/" not in rel:
|
||||
continue
|
||||
category = rel.split("/", 1)[0]
|
||||
categories.setdefault(category, []).append((rel, title))
|
||||
|
||||
for category in sorted(categories.keys()):
|
||||
lines.append(f"## {category}")
|
||||
for rel, title in categories[category]:
|
||||
lines.append(f"- [{title}]({link(rel)})")
|
||||
lines.append("")
|
||||
|
||||
content = "\n".join(lines).rstrip() + "\n"
|
||||
index_path = root / "index.md"
|
||||
try:
|
||||
index_path.write_text(content, encoding="utf-8")
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(f"[KnowledgeService] Failed to rebuild index.md: {exc}")
|
||||
return False
|
||||
|
||||
def _sanitize_document_name(self, filename: str) -> str:
|
||||
name = os.path.basename((filename or "").replace("\\", "/")).strip()
|
||||
if not name:
|
||||
raise ValueError("filename is required")
|
||||
stem, ext = os.path.splitext(name)
|
||||
if ext.lower() not in self.IMPORT_EXTENSIONS:
|
||||
raise ValueError(f"unsupported file type: {ext or name}")
|
||||
if not stem or stem in (".", "..") or self.INVALID_NAME_RE.search(stem):
|
||||
raise ValueError("invalid filename")
|
||||
safe_name = f"{stem}.md"
|
||||
self._ensure_not_protected(safe_name)
|
||||
return safe_name
|
||||
|
||||
@staticmethod
|
||||
def _decode_document_content(content) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if not isinstance(content, (bytes, bytearray)):
|
||||
raise ValueError("document content is required")
|
||||
return bytes(content).decode("utf-8-sig", errors="replace")
|
||||
|
||||
def _resolve_import_destination(self, target_category: str, filename: str,
|
||||
conflict_strategy: str) -> tuple:
|
||||
target_rel, target_full = self._resolve_path(target_category, kind="category")
|
||||
if not target_full.is_dir():
|
||||
raise FileNotFoundError(f"category not found: {target_rel}")
|
||||
|
||||
safe_name = self._sanitize_document_name(filename)
|
||||
destination = target_full / safe_name
|
||||
rel_path = f"{target_rel}/{safe_name}"
|
||||
|
||||
if destination.exists():
|
||||
if conflict_strategy == "skip":
|
||||
return rel_path, destination, "skip"
|
||||
if conflict_strategy == "rename":
|
||||
stem = destination.stem
|
||||
suffix = destination.suffix
|
||||
for index in range(1, 1000):
|
||||
candidate = target_full / f"{stem}-{index}{suffix}"
|
||||
if not candidate.exists():
|
||||
candidate_rel = f"{target_rel}/{candidate.name}"
|
||||
return candidate_rel, candidate, "write"
|
||||
raise FileExistsError(f"target already exists: {rel_path}")
|
||||
if conflict_strategy != "overwrite":
|
||||
raise ValueError("invalid conflict strategy")
|
||||
return rel_path, destination, "write"
|
||||
|
||||
def create_document(self, path: str, content: str = "", overwrite: bool = False) -> dict:
|
||||
rel_path, full_path = self._resolve_path(path, kind="document")
|
||||
self._ensure_not_protected(rel_path)
|
||||
if len((content or "").encode("utf-8")) > self.MAX_IMPORT_FILE_SIZE:
|
||||
raise ValueError("file too large")
|
||||
if full_path.exists() and not overwrite:
|
||||
raise FileExistsError(f"target already exists: {rel_path}")
|
||||
old_paths = [rel_path] if full_path.exists() else []
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content or "", encoding="utf-8")
|
||||
# Keep index.md in sync before reindexing so it is indexed too.
|
||||
self.rebuild_index_md()
|
||||
self._sync_index(old_paths, force=True)
|
||||
return {"path": rel_path, "created": True, "overwritten": bool(old_paths)}
|
||||
|
||||
def import_documents(self, target_category: str, files: Iterable[dict],
|
||||
conflict_strategy: str = "skip") -> dict:
|
||||
if not isinstance(files, list):
|
||||
raise ValueError("files must be a list")
|
||||
if len(files) > self.MAX_IMPORT_FILES:
|
||||
raise ValueError(f"too many files: max {self.MAX_IMPORT_FILES}")
|
||||
results = []
|
||||
old_paths = []
|
||||
imported = skipped = failed = 0
|
||||
total_size = 0
|
||||
|
||||
for item in files:
|
||||
filename = item.get("filename") if isinstance(item, dict) else None
|
||||
try:
|
||||
content_bytes = item.get("content") if isinstance(item, dict) else None
|
||||
size = len(content_bytes.encode("utf-8")) if isinstance(content_bytes, str) else len(content_bytes or b"")
|
||||
total_size += size
|
||||
if total_size > self.MAX_IMPORT_TOTAL_SIZE:
|
||||
raise ValueError("import batch too large")
|
||||
if size > self.MAX_IMPORT_FILE_SIZE:
|
||||
raise ValueError("file too large")
|
||||
rel_path, destination, mode = self._resolve_import_destination(
|
||||
target_category, filename, conflict_strategy
|
||||
)
|
||||
if mode == "skip":
|
||||
skipped += 1
|
||||
results.append({"filename": filename, "path": rel_path, "status": "skipped",
|
||||
"reason": "target_exists"})
|
||||
continue
|
||||
|
||||
old_exists = destination.exists()
|
||||
content = self._decode_document_content(content_bytes)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(content, encoding="utf-8")
|
||||
if old_exists:
|
||||
old_paths.append(rel_path)
|
||||
imported += 1
|
||||
results.append({"filename": filename, "path": rel_path, "status": "imported",
|
||||
"overwritten": old_exists})
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
results.append({"filename": filename or "", "status": "failed", "reason": str(exc)})
|
||||
|
||||
if imported:
|
||||
# Keep index.md in sync before reindexing so it is indexed too.
|
||||
self.rebuild_index_md()
|
||||
self._sync_index(old_paths, force=True)
|
||||
return {"results": results, "imported": imported, "skipped": skipped, "failed": failed}
|
||||
|
||||
def create_category(self, path: str) -> dict:
|
||||
rel_path, full_path = self._resolve_path(path, kind="category")
|
||||
if full_path.exists():
|
||||
@@ -283,12 +484,16 @@ class KnowledgeService:
|
||||
if not is_root:
|
||||
stats["pages"] += 1
|
||||
stats["size"] += size
|
||||
title = name.replace(".md", "")
|
||||
# Prefer the H1 heading as a readable title for normal docs.
|
||||
# System files (index.md / log.md) keep their filename so the
|
||||
# tree never hides what they actually are.
|
||||
title = name[:-3]
|
||||
if name not in self.PROTECTED_FILES:
|
||||
try:
|
||||
with open(full, "r", encoding="utf-8") as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line.startswith("# "):
|
||||
title = first_line[2:].strip()
|
||||
title = first_line[2:].strip() or title
|
||||
except Exception:
|
||||
pass
|
||||
files.append({"name": name, "title": title, "size": size})
|
||||
@@ -416,6 +621,15 @@ class KnowledgeService:
|
||||
result = self.delete_documents(payload.get("paths") or [])
|
||||
elif action == "move_documents":
|
||||
result = self.move_documents(payload.get("paths") or [], payload.get("target_category"))
|
||||
elif action == "create_document":
|
||||
result = self.create_document(payload.get("path"), payload.get("content", ""),
|
||||
payload.get("overwrite", False))
|
||||
elif action == "import_documents":
|
||||
result = self.import_documents(
|
||||
payload.get("target_category"),
|
||||
payload.get("files") or [],
|
||||
payload.get("conflict_strategy", "skip"),
|
||||
)
|
||||
else:
|
||||
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
||||
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||
|
||||
@@ -7,7 +7,7 @@ conversation history persistence (SQLite).
|
||||
|
||||
from agent.memory.manager import MemoryManager
|
||||
from agent.memory.config import MemoryConfig, get_default_memory_config, set_global_memory_config
|
||||
from agent.memory.embedding import create_embedding_provider
|
||||
from agent.memory.embedding import create_embedding_provider, create_default_embedding_provider
|
||||
from agent.memory.conversation_store import ConversationStore, get_conversation_store
|
||||
from agent.memory.summarizer import ensure_daily_memory_file
|
||||
|
||||
@@ -17,6 +17,7 @@ __all__ = [
|
||||
'get_default_memory_config',
|
||||
'set_global_memory_config',
|
||||
'create_embedding_provider',
|
||||
'create_default_embedding_provider',
|
||||
'ConversationStore',
|
||||
'get_conversation_store',
|
||||
'ensure_daily_memory_file',
|
||||
|
||||
@@ -16,6 +16,7 @@ from agent.memory.embedding.provider import (
|
||||
OpenAIEmbeddingProvider,
|
||||
create_embedding_provider,
|
||||
)
|
||||
from agent.memory.embedding.factory import create_default_embedding_provider
|
||||
from agent.memory.embedding.rebuild import (
|
||||
RebuildResult,
|
||||
clear_index,
|
||||
@@ -33,6 +34,7 @@ __all__ = [
|
||||
"EmbeddingProvider",
|
||||
"OpenAIEmbeddingProvider",
|
||||
"create_embedding_provider",
|
||||
"create_default_embedding_provider",
|
||||
"RebuildResult",
|
||||
"clear_index",
|
||||
"rebuild_in_process",
|
||||
|
||||
209
agent/memory/embedding/factory.py
Normal file
209
agent/memory/embedding/factory.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Shared embedding provider factory.
|
||||
|
||||
Resolves the embedding provider purely from config.json, so every caller
|
||||
(agent initialization, knowledge base sync, index rebuild, ...) selects the
|
||||
same provider instead of silently degrading to keyword-only search.
|
||||
|
||||
Two paths:
|
||||
A. Default (no `embedding_provider` in config.json):
|
||||
Auto-init OpenAI -> LinkAI fallback.
|
||||
B. Explicit (`embedding_provider` is set):
|
||||
Initialize the requested vendor with unified dim (default per vendor).
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from common.log import logger
|
||||
|
||||
# Track whether the embedding model log has been printed in this process,
|
||||
# so we avoid spamming it once per session/caller.
|
||||
_embedding_logged: bool = False
|
||||
|
||||
|
||||
def create_default_embedding_provider():
|
||||
"""Build the embedding provider from config, or None for keyword-only mode."""
|
||||
from config import conf
|
||||
|
||||
explicit_provider = (conf().get("embedding_provider") or "").strip().lower()
|
||||
if not explicit_provider:
|
||||
return _init_legacy_provider()
|
||||
return _init_explicit_provider(explicit_provider)
|
||||
|
||||
|
||||
def _init_legacy_provider():
|
||||
"""Legacy auto-init path: OpenAI -> LinkAI."""
|
||||
from agent.memory.embedding.provider import create_embedding_provider
|
||||
from config import conf
|
||||
|
||||
embedding_provider = None
|
||||
embedding_model = None
|
||||
|
||||
openai_api_key = conf().get("open_ai_api_key", "")
|
||||
openai_api_base = conf().get("open_ai_api_base", "")
|
||||
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||
try:
|
||||
model = "text-embedding-3-small"
|
||||
embedding_provider = create_embedding_provider(
|
||||
provider="openai",
|
||||
model=model,
|
||||
api_key=openai_api_key,
|
||||
api_base=openai_api_base or "https://api.openai.com/v1",
|
||||
)
|
||||
embedding_model = f"openai/{model}"
|
||||
except Exception as e:
|
||||
logger.warning(f"[EmbeddingFactory] OpenAI embedding failed: {e}")
|
||||
|
||||
if embedding_provider is None:
|
||||
linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "")
|
||||
linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
|
||||
if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||
try:
|
||||
model = "text-embedding-3-small"
|
||||
embedding_provider = create_embedding_provider(
|
||||
provider="linkai",
|
||||
model=model,
|
||||
api_key=linkai_api_key,
|
||||
api_base=f"{linkai_api_base}/v1",
|
||||
)
|
||||
embedding_model = f"linkai/{model}"
|
||||
except Exception as e:
|
||||
logger.warning(f"[EmbeddingFactory] LinkAI embedding failed: {e}")
|
||||
|
||||
if embedding_provider is not None and embedding_model:
|
||||
_log_provider_once(f"{embedding_model} (dim={embedding_provider.dimensions})")
|
||||
|
||||
return embedding_provider
|
||||
|
||||
|
||||
def _init_explicit_provider(provider_key: str):
|
||||
"""Explicit-provider path: build the configured vendor."""
|
||||
from agent.memory.embedding.provider import EMBEDDING_VENDORS, create_embedding_provider
|
||||
from config import conf
|
||||
|
||||
# Custom providers ("custom:<id>") resolve credentials from custom_providers.
|
||||
resolved_provider_key = provider_key
|
||||
if provider_key.startswith("custom:"):
|
||||
resolved_provider_key = "custom"
|
||||
|
||||
meta = EMBEDDING_VENDORS.get(resolved_provider_key)
|
||||
if meta is None:
|
||||
logger.error(
|
||||
f"[EmbeddingFactory] Unknown embedding_provider '{provider_key}'. "
|
||||
f"Supported: {sorted(EMBEDDING_VENDORS.keys())}. "
|
||||
f"Memory will run in keyword-only mode."
|
||||
)
|
||||
return None
|
||||
|
||||
api_key = _resolve_api_key(provider_key)
|
||||
api_base = _resolve_api_base(provider_key, meta["default_base_url"])
|
||||
|
||||
if not api_key:
|
||||
logger.error(
|
||||
f"[EmbeddingFactory] embedding_provider='{provider_key}' is set but its "
|
||||
f"API key is missing. Memory will run in keyword-only mode."
|
||||
)
|
||||
return None
|
||||
|
||||
model = (conf().get("embedding_model") or "").strip()
|
||||
# Custom providers without a model fall back to the provider's default.
|
||||
if not model and resolved_provider_key == "custom":
|
||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||
_, custom_id = parse_custom_bot_type(provider_key)
|
||||
if custom_id:
|
||||
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||
if entry and entry.get("model"):
|
||||
model = entry["model"]
|
||||
if not model and resolved_provider_key != "custom":
|
||||
model = meta["default_model"]
|
||||
|
||||
try:
|
||||
cfg_dim = int(conf().get("embedding_dimensions") or 0)
|
||||
except (TypeError, ValueError):
|
||||
cfg_dim = 0
|
||||
dim = cfg_dim if cfg_dim > 0 else meta["default_dimensions"]
|
||||
|
||||
try:
|
||||
provider = create_embedding_provider(
|
||||
provider=resolved_provider_key,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
dimensions=dim,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[EmbeddingFactory] Failed to init embedding provider "
|
||||
f"'{provider_key}/{model}': {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
_log_provider_once(f"{provider_key}/{model} (dim={provider.dimensions})")
|
||||
return provider
|
||||
|
||||
|
||||
def _resolve_api_key(provider_key: str) -> str:
|
||||
"""Pick the API key for an explicit embedding provider from config."""
|
||||
from config import conf
|
||||
|
||||
if provider_key.startswith("custom:"):
|
||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||
_, custom_id = parse_custom_bot_type(provider_key)
|
||||
if custom_id:
|
||||
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||
if entry:
|
||||
return entry.get("api_key", "")
|
||||
return ""
|
||||
|
||||
key_map = {
|
||||
"openai": "open_ai_api_key",
|
||||
"linkai": "linkai_api_key",
|
||||
"dashscope": "dashscope_api_key",
|
||||
"doubao": "ark_api_key",
|
||||
"zhipu": "zhipu_ai_api_key",
|
||||
}
|
||||
field = key_map.get(provider_key)
|
||||
if not field:
|
||||
return ""
|
||||
value = conf().get(field, "") or ""
|
||||
if value in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_api_base(provider_key: str, default_base: str) -> str:
|
||||
"""Pick the API base for an explicit embedding provider from config."""
|
||||
from config import conf
|
||||
|
||||
if provider_key.startswith("custom:"):
|
||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||
_, custom_id = parse_custom_bot_type(provider_key)
|
||||
if custom_id:
|
||||
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||
if entry and entry.get("api_base"):
|
||||
return entry["api_base"]
|
||||
return default_base
|
||||
|
||||
base_map = {
|
||||
"openai": "open_ai_api_base",
|
||||
"linkai": "linkai_api_base",
|
||||
"doubao": "ark_base_url",
|
||||
"zhipu": "zhipu_ai_api_base",
|
||||
}
|
||||
field = base_map.get(provider_key)
|
||||
if not field:
|
||||
return default_base
|
||||
value = (conf().get(field) or "").strip()
|
||||
if not value:
|
||||
return default_base
|
||||
if provider_key == "linkai" and not value.rstrip("/").endswith("/v1"):
|
||||
return f"{value.rstrip('/')}/v1"
|
||||
return value
|
||||
|
||||
|
||||
def _log_provider_once(detail: str):
|
||||
global _embedding_logged
|
||||
if not _embedding_logged:
|
||||
logger.info(f"[EmbeddingFactory] Embedding model in use: {detail}")
|
||||
_embedding_logged = True
|
||||
@@ -163,10 +163,9 @@ def main() -> int:
|
||||
logger.info(f"[RebuildIndex] Workspace: {workspace_root}")
|
||||
logger.info(f"[RebuildIndex] Index db: {memory_config.get_db_path()}")
|
||||
|
||||
from bridge.agent_initializer import AgentInitializer
|
||||
from agent.memory.embedding import create_default_embedding_provider
|
||||
|
||||
initializer = AgentInitializer(bridge=None, agent_bridge=None)
|
||||
embedding_provider = initializer._init_embedding_provider(memory_config, session_id=None)
|
||||
embedding_provider = create_default_embedding_provider()
|
||||
if embedding_provider is None:
|
||||
logger.error(
|
||||
"[RebuildIndex] No embedding provider could be initialized. "
|
||||
|
||||
@@ -419,6 +419,17 @@ class MemoryFlushManager:
|
||||
lookback_days: How many days of daily files to read (default 1 for scheduled, 3 for manual)
|
||||
force: Skip input-hash dedup check (used by manual /memory dream trigger)
|
||||
"""
|
||||
# Config guard for scheduled runs. Manual trigger (force=True) always
|
||||
# runs since it is an explicit user action.
|
||||
if not force:
|
||||
try:
|
||||
from config import conf
|
||||
if not conf().get("deep_dream_enabled", True):
|
||||
logger.info("[DeepDream] deep_dream_enabled=false, skipping")
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not self.llm_model:
|
||||
logger.warning("[DeepDream] No LLM model available, skipping")
|
||||
return False
|
||||
|
||||
@@ -8,6 +8,13 @@ from .cancel import (
|
||||
CancelTokenRegistry,
|
||||
get_cancel_registry,
|
||||
)
|
||||
from .steer import (
|
||||
SteerInbox,
|
||||
SteerRegistry,
|
||||
SteerResult,
|
||||
SteerStatus,
|
||||
get_steer_registry,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Agent',
|
||||
@@ -25,4 +32,9 @@ __all__ = [
|
||||
'AgentCancelledError',
|
||||
'CancelTokenRegistry',
|
||||
'get_cancel_registry',
|
||||
'SteerInbox',
|
||||
'SteerRegistry',
|
||||
'SteerResult',
|
||||
'SteerStatus',
|
||||
'get_steer_registry',
|
||||
]
|
||||
|
||||
@@ -381,7 +381,7 @@ class Agent:
|
||||
return action
|
||||
|
||||
def run_stream(self, user_message: str, on_event=None, clear_history: bool = False,
|
||||
skill_filter=None, cancel_event=None) -> str:
|
||||
skill_filter=None, cancel_event=None, steer_inbox=None) -> str:
|
||||
"""
|
||||
Execute single agent task with streaming (based on tool-call)
|
||||
|
||||
@@ -391,6 +391,7 @@ class Agent:
|
||||
- Event callbacks
|
||||
- Persistent conversation history across calls
|
||||
- User-initiated cancellation via ``cancel_event``
|
||||
- Explicit active-turn guidance via ``steer_inbox``
|
||||
|
||||
Args:
|
||||
user_message: User message
|
||||
@@ -403,6 +404,8 @@ class Agent:
|
||||
"[Interrupted by user]" assistant note, and returns the
|
||||
partial response. ``messages`` stays in a valid state
|
||||
(tool_use/tool_result pairs preserved).
|
||||
steer_inbox: Optional SteerInbox drained at safe checkpoints. New
|
||||
instructions guide this run without entering the normal queue.
|
||||
|
||||
Returns:
|
||||
Final response text
|
||||
@@ -448,6 +451,7 @@ class Agent:
|
||||
messages=messages_copy, # Pass copied message history
|
||||
max_context_turns=max_context_turns,
|
||||
cancel_event=cancel_event,
|
||||
steer_inbox=steer_inbox,
|
||||
)
|
||||
|
||||
# Execute
|
||||
|
||||
@@ -99,6 +99,7 @@ class AgentStreamExecutor:
|
||||
messages: Optional[List[Dict]] = None,
|
||||
max_context_turns: int = 30,
|
||||
cancel_event=None,
|
||||
steer_inbox=None,
|
||||
):
|
||||
"""
|
||||
Initialize stream executor
|
||||
@@ -116,6 +117,8 @@ class AgentStreamExecutor:
|
||||
Checked at every safe point (turn boundary, before tool execution,
|
||||
during LLM streaming). When set, raises AgentCancelledError which
|
||||
run_stream catches to gracefully wind down.
|
||||
steer_inbox: Optional SteerInbox for explicit instructions sent to
|
||||
this active run. Drained only at message-safe checkpoints.
|
||||
"""
|
||||
self.agent = agent
|
||||
self.model = model
|
||||
@@ -126,6 +129,7 @@ class AgentStreamExecutor:
|
||||
self.on_event = on_event
|
||||
self.max_context_turns = max_context_turns
|
||||
self.cancel_event = cancel_event
|
||||
self.steer_inbox = steer_inbox
|
||||
|
||||
# Message history - use provided messages or create new list
|
||||
self.messages = messages if messages is not None else []
|
||||
@@ -145,6 +149,72 @@ class AgentStreamExecutor:
|
||||
if self.cancel_event is not None and self.cancel_event.is_set():
|
||||
raise AgentCancelledError("agent cancelled by user")
|
||||
|
||||
def _drain_steering(self) -> List[str]:
|
||||
if self.steer_inbox is None:
|
||||
return []
|
||||
return self.steer_inbox.drain()
|
||||
|
||||
@staticmethod
|
||||
def _steering_text(updates: List[str]) -> str:
|
||||
if len(updates) == 1:
|
||||
body = updates[0]
|
||||
else:
|
||||
body = "\n".join(f"{idx}. {text}" for idx, text in enumerate(updates, 1))
|
||||
return (
|
||||
"[Steering update for the active task]\n"
|
||||
"Use this new instruction for the current task before continuing.\n\n"
|
||||
f"{body}"
|
||||
)
|
||||
|
||||
def _append_steering(
|
||||
self,
|
||||
updates: List[str],
|
||||
pending_tool_calls: Optional[List[Dict]] = None,
|
||||
content_blocks: Optional[List[Dict]] = None,
|
||||
) -> None:
|
||||
"""Append guidance, closing any tool_use blocks that will be skipped."""
|
||||
if not updates:
|
||||
return
|
||||
blocks = content_blocks if content_blocks is not None else []
|
||||
for tool_call in pending_tool_calls or []:
|
||||
blocks.append({
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_call["id"],
|
||||
"content": "Skipped because the user redirected the active task.",
|
||||
"is_error": True,
|
||||
})
|
||||
blocks.append({"type": "text", "text": self._steering_text(updates)})
|
||||
if content_blocks is None:
|
||||
self.messages.append({"role": "user", "content": blocks})
|
||||
self._emit_event("agent_steered", {"count": len(updates)})
|
||||
logger.info(f"[Agent] Applied {len(updates)} steering update(s)")
|
||||
|
||||
def _close_or_apply_final_steering(self) -> bool:
|
||||
"""Return True only when the run can finish without losing a steer."""
|
||||
updates = self._drain_steering()
|
||||
if updates:
|
||||
self._append_steering(updates)
|
||||
return False
|
||||
if self.steer_inbox is None:
|
||||
return True
|
||||
if self.steer_inbox.close_if_empty():
|
||||
return True
|
||||
updates = self._drain_steering()
|
||||
if updates:
|
||||
self._append_steering(updates)
|
||||
return False
|
||||
|
||||
def _drain_and_close_steering(self) -> None:
|
||||
"""Preserve any final guidance before the max-step summary call."""
|
||||
if self.steer_inbox is None:
|
||||
return
|
||||
while True:
|
||||
updates = self._drain_steering()
|
||||
if updates:
|
||||
self._append_steering(updates)
|
||||
if self.steer_inbox.close_if_empty():
|
||||
return
|
||||
|
||||
def _handle_cancelled(self, partial_response: str) -> None:
|
||||
"""Wind down ``self.messages`` after a user-initiated cancel.
|
||||
|
||||
@@ -379,6 +449,12 @@ class AgentStreamExecutor:
|
||||
|
||||
self._emit_event("agent_start")
|
||||
|
||||
# Reset the run-scoped MCP tool-retrieval accumulator. On-demand tool
|
||||
# retrieval only grows this set within a run, so a tool that already
|
||||
# produced a tool_use never disappears from the schema mid-run (which
|
||||
# would make Claude/MiniMax raise a message-format error).
|
||||
self._retrieved_mcp_names = set()
|
||||
|
||||
final_response = ""
|
||||
turn = 0
|
||||
|
||||
@@ -389,6 +465,10 @@ class AgentStreamExecutor:
|
||||
# between turns short-circuits cleanly.
|
||||
self._check_cancelled()
|
||||
|
||||
steering_updates = self._drain_steering()
|
||||
if steering_updates:
|
||||
self._append_steering(steering_updates)
|
||||
|
||||
turn += 1
|
||||
logger.info(f"[Agent] Turn {turn}")
|
||||
self._emit_event("turn_start", {"turn": turn})
|
||||
@@ -397,6 +477,24 @@ class AgentStreamExecutor:
|
||||
assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=True)
|
||||
final_response = assistant_msg
|
||||
|
||||
# A steer that arrived while the model was streaming takes
|
||||
# precedence over its proposed continuation. Tool calls have
|
||||
# already been written to history, so close every one with a
|
||||
# synthetic result before asking the model to reconsider.
|
||||
steering_updates = self._drain_steering()
|
||||
if steering_updates:
|
||||
self._append_steering(
|
||||
steering_updates,
|
||||
pending_tool_calls=tool_calls,
|
||||
)
|
||||
self._emit_event("turn_end", {
|
||||
"turn": turn,
|
||||
"has_tool_calls": bool(tool_calls),
|
||||
"tool_count": len(tool_calls),
|
||||
"steered": True,
|
||||
})
|
||||
continue
|
||||
|
||||
# No tool calls, end loop
|
||||
if not tool_calls:
|
||||
# 检查是否返回了空响应
|
||||
@@ -461,6 +559,22 @@ class AgentStreamExecutor:
|
||||
# If the explicit-response retry produced tool_calls, skip the break
|
||||
# and continue down to the tool execution branch in this same iteration.
|
||||
if not tool_calls:
|
||||
steering_updates = self._drain_steering()
|
||||
if steering_updates:
|
||||
self._append_steering(steering_updates)
|
||||
self._emit_event("turn_end", {
|
||||
"turn": turn,
|
||||
"has_tool_calls": False,
|
||||
"steered": True,
|
||||
})
|
||||
continue
|
||||
if not self._close_or_apply_final_steering():
|
||||
self._emit_event("turn_end", {
|
||||
"turn": turn,
|
||||
"has_tool_calls": False,
|
||||
"steered": True,
|
||||
})
|
||||
continue
|
||||
logger.debug(f"✅ Done (no tool calls)")
|
||||
self._emit_event("turn_end", {
|
||||
"turn": turn,
|
||||
@@ -493,9 +607,17 @@ class AgentStreamExecutor:
|
||||
tool_result_blocks = []
|
||||
|
||||
try:
|
||||
for tool_call in tool_calls:
|
||||
for tool_index, tool_call in enumerate(tool_calls):
|
||||
# Honour cancel between tool invocations within the same turn
|
||||
self._check_cancelled()
|
||||
steering_updates = self._drain_steering()
|
||||
if steering_updates:
|
||||
self._append_steering(
|
||||
steering_updates,
|
||||
pending_tool_calls=tool_calls[tool_index:],
|
||||
content_blocks=tool_result_blocks,
|
||||
)
|
||||
break
|
||||
result = self._execute_tool(tool_call)
|
||||
tool_results.append(result)
|
||||
|
||||
@@ -635,6 +757,7 @@ class AgentStreamExecutor:
|
||||
|
||||
if turn >= self.max_turns:
|
||||
logger.warning(f"⚠️ Reached max decision step limit: {self.max_turns}")
|
||||
self._drain_and_close_steering()
|
||||
|
||||
# Force model to summarize without tool calls
|
||||
logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...")
|
||||
@@ -693,6 +816,8 @@ class AgentStreamExecutor:
|
||||
raise
|
||||
|
||||
finally:
|
||||
if self.steer_inbox is not None:
|
||||
self.steer_inbox.close()
|
||||
final_response = final_response.strip() if final_response else final_response
|
||||
if cancelled:
|
||||
# Emit before agent_end so channels can mark UI as cancelled
|
||||
@@ -702,6 +827,70 @@ class AgentStreamExecutor:
|
||||
|
||||
return final_response
|
||||
|
||||
def _select_tools_for_injection(self) -> list:
|
||||
"""Decide which tools to inject into the current LLM turn.
|
||||
|
||||
Built-in tools are ALWAYS injected in full (skills and core flows hard
|
||||
depend on them). MCP tools are also injected in full UNLESS on-demand
|
||||
retrieval is enabled AND the MCP tool count exceeds the configured
|
||||
threshold — then only the most relevant MCP tools are injected, unioned
|
||||
with those already selected earlier in this run (only-grows, so a tool
|
||||
that already produced a tool_use never vanishes from the schema).
|
||||
|
||||
Degrades safely: disabled feature, no embedding provider, embedding
|
||||
failure, count below threshold, or any error → inject all tools. Tools
|
||||
are never silently dropped.
|
||||
"""
|
||||
all_tools = list(self.tools.values())
|
||||
try:
|
||||
from config import conf
|
||||
if not conf().get("mcp_tool_retrieval_enabled", False):
|
||||
return all_tools
|
||||
|
||||
from agent.tools.mcp.mcp_tool import McpTool
|
||||
mcp_tools = [t for t in all_tools if isinstance(t, McpTool)]
|
||||
builtin_tools = [t for t in all_tools if not isinstance(t, McpTool)]
|
||||
|
||||
threshold = int(conf().get("mcp_tool_retrieval_threshold", 20) or 20)
|
||||
if len(mcp_tools) <= threshold:
|
||||
return all_tools
|
||||
|
||||
top_k = int(conf().get("mcp_tool_retrieval_top_k", 10) or 10)
|
||||
|
||||
from agent.tools import ToolManager
|
||||
from agent.tools.mcp.tool_retrieval import (
|
||||
build_retrieval_query,
|
||||
select_mcp_tools,
|
||||
)
|
||||
|
||||
tm = ToolManager()
|
||||
tool_vectors = tm.get_mcp_tool_vectors()
|
||||
query = build_retrieval_query(self.messages)
|
||||
query_vector = tm.embed_query(query)
|
||||
|
||||
selected = select_mcp_tools(
|
||||
query_vector,
|
||||
tool_vectors,
|
||||
top_k,
|
||||
getattr(self, "_retrieved_mcp_names", set()),
|
||||
)
|
||||
if selected is None:
|
||||
# No provider / empty index / error → full injection.
|
||||
return all_tools
|
||||
|
||||
# Persist the accumulated selection for subsequent turns.
|
||||
self._retrieved_mcp_names = selected
|
||||
|
||||
selected_mcp = [t for t in mcp_tools if t.name in selected]
|
||||
logger.info(
|
||||
f"[ToolRetrieval] Injecting {len(builtin_tools)} built-in + "
|
||||
f"{len(selected_mcp)}/{len(mcp_tools)} MCP tool(s) (top_k={top_k})"
|
||||
)
|
||||
return builtin_tools + selected_mcp
|
||||
except Exception as e:
|
||||
logger.debug(f"[ToolRetrieval] full injection (retrieval skipped): {e}")
|
||||
return all_tools
|
||||
|
||||
def _call_llm_stream(self, retry_on_empty=True, retry_count=0, max_retries=3,
|
||||
_overflow_retry: bool = False) -> Tuple[str, List[Dict]]:
|
||||
"""
|
||||
@@ -742,7 +931,7 @@ class AgentStreamExecutor:
|
||||
tools_schema = None
|
||||
if self.tools:
|
||||
tools_schema = []
|
||||
for tool in self.tools.values():
|
||||
for tool in self._select_tools_for_injection():
|
||||
input_schema = tool.params
|
||||
try:
|
||||
dynamic = (tool.get_json_schema() or {}).get("parameters") or {}
|
||||
|
||||
127
agent/protocol/steer.py
Normal file
127
agent/protocol/steer.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Thread-safe active-run steering primitives.
|
||||
|
||||
Steering is deliberately separate from the normal per-session message queue.
|
||||
An instruction is accepted only while exactly one run for the scoped session
|
||||
is active; idle sessions never start a new run as a side effect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Deque, Dict, List, Optional, Set
|
||||
|
||||
|
||||
class SteerStatus(str, Enum):
|
||||
ACCEPTED = "accepted"
|
||||
INACTIVE = "inactive"
|
||||
AMBIGUOUS = "ambiguous"
|
||||
INVALID = "invalid"
|
||||
FULL = "full"
|
||||
CLOSING = "closing"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SteerResult:
|
||||
status: SteerStatus
|
||||
|
||||
@property
|
||||
def accepted(self) -> bool:
|
||||
return self.status == SteerStatus.ACCEPTED
|
||||
|
||||
|
||||
class SteerInbox:
|
||||
"""Bounded inbox owned by one active agent run."""
|
||||
|
||||
def __init__(self, max_pending: int = 16, max_chars: int = 8000):
|
||||
self.max_pending = max(1, int(max_pending))
|
||||
self.max_chars = max(1, int(max_chars))
|
||||
self._lock = threading.Lock()
|
||||
self._pending: Deque[str] = deque()
|
||||
self._accepting = True
|
||||
|
||||
def submit(self, instruction: str) -> SteerResult:
|
||||
text = (instruction or "").strip()
|
||||
if not text or len(text) > self.max_chars:
|
||||
return SteerResult(SteerStatus.INVALID)
|
||||
with self._lock:
|
||||
if not self._accepting:
|
||||
return SteerResult(SteerStatus.CLOSING)
|
||||
if len(self._pending) >= self.max_pending:
|
||||
return SteerResult(SteerStatus.FULL)
|
||||
self._pending.append(text)
|
||||
return SteerResult(SteerStatus.ACCEPTED)
|
||||
|
||||
def drain(self) -> List[str]:
|
||||
with self._lock:
|
||||
items = list(self._pending)
|
||||
self._pending.clear()
|
||||
return items
|
||||
|
||||
def close_if_empty(self) -> bool:
|
||||
"""Atomically stop accepting when no instruction is pending.
|
||||
|
||||
This closes the race between a final empty drain and an agent run
|
||||
returning: after this method succeeds, submitters receive CLOSING.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._pending:
|
||||
return False
|
||||
self._accepting = False
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._accepting = False
|
||||
|
||||
|
||||
class SteerRegistry:
|
||||
"""Map a scoped agent/session key to its active run inboxes."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
self._by_session: Dict[str, Set[SteerInbox]] = {}
|
||||
|
||||
def register(self, session_id: str, inbox: Optional[SteerInbox] = None) -> SteerInbox:
|
||||
inbox = inbox or SteerInbox()
|
||||
if not session_id:
|
||||
return inbox
|
||||
with self._lock:
|
||||
self._by_session.setdefault(session_id, set()).add(inbox)
|
||||
return inbox
|
||||
|
||||
def unregister(self, session_id: str, inbox: Optional[SteerInbox]) -> None:
|
||||
if not session_id or inbox is None:
|
||||
return
|
||||
inbox.close()
|
||||
with self._lock:
|
||||
bucket = self._by_session.get(session_id)
|
||||
if bucket is None:
|
||||
return
|
||||
bucket.discard(inbox)
|
||||
if not bucket:
|
||||
self._by_session.pop(session_id, None)
|
||||
|
||||
def submit(self, session_id: str, instruction: str) -> SteerResult:
|
||||
if not (instruction or "").strip():
|
||||
return SteerResult(SteerStatus.INVALID)
|
||||
with self._lock:
|
||||
inboxes = list(self._by_session.get(session_id, ()))
|
||||
if not inboxes:
|
||||
return SteerResult(SteerStatus.INACTIVE)
|
||||
if len(inboxes) != 1:
|
||||
return SteerResult(SteerStatus.AMBIGUOUS)
|
||||
return inboxes[0].submit(instruction)
|
||||
|
||||
def active_count(self, session_id: str) -> int:
|
||||
with self._lock:
|
||||
return len(self._by_session.get(session_id, ()))
|
||||
|
||||
|
||||
_registry = SteerRegistry()
|
||||
|
||||
|
||||
def get_steer_registry() -> SteerRegistry:
|
||||
return _registry
|
||||
@@ -90,20 +90,14 @@ FileSave = _optional_tools.get('FileSave')
|
||||
Terminal = _optional_tools.get('Terminal')
|
||||
|
||||
|
||||
# BrowserTool (requires playwright)
|
||||
# BrowserTool: playwright is soft-imported inside browser_service, so this
|
||||
# import always succeeds even without playwright. Readiness (playwright pkg /
|
||||
# system Chrome / downloaded Chromium) is checked at call time in BrowserTool.
|
||||
def _import_browser_tool():
|
||||
from common.log import logger
|
||||
try:
|
||||
from agent.tools.browser.browser_tool import BrowserTool
|
||||
return BrowserTool
|
||||
except ImportError as e:
|
||||
logger.info(
|
||||
f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
|
||||
f" To enable browser tool, run:\n"
|
||||
f" pip install playwright\n"
|
||||
f" playwright install chromium"
|
||||
)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
||||
return None
|
||||
|
||||
@@ -202,8 +202,12 @@ SAFETY:
|
||||
total_bytes = len(output.encode('utf-8'))
|
||||
|
||||
if total_bytes > DEFAULT_MAX_BYTES:
|
||||
# Save full output to temp file
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-') as f:
|
||||
# Save full output to temp file. encoding='utf-8' is required:
|
||||
# the default text-mode encoding is the platform locale (e.g.
|
||||
# cp936/GBK on Chinese Windows), which raises UnicodeEncodeError
|
||||
# for output containing emoji or other non-locale characters and
|
||||
# would discard an otherwise successful command result.
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-', encoding='utf-8') as f:
|
||||
f.write(output)
|
||||
temp_file_path = f.name
|
||||
|
||||
|
||||
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(),
|
||||
}
|
||||
@@ -9,6 +9,7 @@ period of inactivity to free resources.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import uuid
|
||||
import queue
|
||||
import threading
|
||||
@@ -215,6 +216,14 @@ _SNAPSHOT_JS = """
|
||||
str(list(_INTERACTIVE_TAGS)),
|
||||
)
|
||||
|
||||
# Returning the snapshot as ONE JSON string instead of a nested object is a big
|
||||
# win in the frozen desktop build: Playwright serializes a nested return value
|
||||
# node-by-node over many driver<->python protocol round trips, and each round
|
||||
# trip carries fixed overhead that is dramatically amplified in the frozen
|
||||
# bundle (a ~300-node tree can take 20s+). JSON.stringify in-page collapses it
|
||||
# to a single string transfer; Python then json.loads it. Behaviour identical.
|
||||
_SNAPSHOT_JS_STR = "() => JSON.stringify((%s)())" % _SNAPSHOT_JS.strip()
|
||||
|
||||
|
||||
_BROWSER_DEAD_HINTS = (
|
||||
"has been closed",
|
||||
@@ -321,17 +330,32 @@ class BrowserService:
|
||||
self._context = None
|
||||
self._page = None
|
||||
|
||||
# When we drive a system Chrome/Edge, we spawn it ourselves with a
|
||||
# debugging port and attach over CDP (see chrome_launcher). This avoids
|
||||
# the macOS Automation prompt + multi-second stall that
|
||||
# chromium.launch(channel=...) incurs. Holds the child process owner.
|
||||
self._chrome_launcher = None
|
||||
# Path to the system browser executable when using system-chrome mode.
|
||||
self._system_exe: Optional[str] = None
|
||||
|
||||
# Launch mode: one of "fresh" | "persistent" | "cdp".
|
||||
# - cdp: connect to an externally launched Chrome via CDP endpoint.
|
||||
# - persistent: launch with launch_persistent_context using a user_data_dir
|
||||
# so cookies / login state survive across runs (default).
|
||||
# - 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 ""
|
||||
persistent_flag = self._config.get("persistent", True)
|
||||
user_data_dir_cfg = self._config.get("user_data_dir")
|
||||
if user_data_dir_cfg is None:
|
||||
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 ""
|
||||
if self._cdp_endpoint:
|
||||
self._launch_mode = "cdp"
|
||||
@@ -343,6 +367,38 @@ class BrowserService:
|
||||
self._launch_mode = "fresh"
|
||||
self._user_data_dir = ""
|
||||
|
||||
# Resolve which browser engine to drive (system Chrome vs downloaded
|
||||
# Chromium). Deferred detection failures are surfaced at launch time.
|
||||
#
|
||||
# For a system Chrome/Edge we DON'T use chromium.launch(channel=...):
|
||||
# that "takes over" another app and triggers the macOS Automation
|
||||
# prompt + a long stall. Instead we spawn the browser ourselves with a
|
||||
# debugging port and attach over CDP (self._launch_mode = "system-cdp").
|
||||
# `self._system_exe` is the browser executable; the persistent
|
||||
# user_data_dir keeps login state across sessions.
|
||||
if self._launch_mode != "cdp":
|
||||
try:
|
||||
from agent.tools.browser.browser_env import resolve_engine
|
||||
engine = resolve_engine(self._config)
|
||||
if engine["mode"] == "system-chrome":
|
||||
self._channel = engine["channel"]
|
||||
self._system_exe = engine.get("path")
|
||||
# Only switch to spawn+CDP when we actually know the exe
|
||||
# path (macOS/Windows/Linux detection returns it). Persist
|
||||
# login state in a dedicated profile dir.
|
||||
if self._system_exe:
|
||||
self._launch_mode = "system-cdp"
|
||||
if not self._user_data_dir:
|
||||
self._user_data_dir = expand_path(_DEFAULT_USER_DATA_DIR)
|
||||
logger.info(f"[Browser] Engine resolved: {engine['reason']} "
|
||||
f"(spawn+CDP={bool(self._system_exe)})")
|
||||
elif engine["mode"] == "playwright-chromium":
|
||||
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_cfg = self._config.get("idle_timeout")
|
||||
self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT
|
||||
@@ -428,11 +484,30 @@ class BrowserService:
|
||||
|
||||
def _launch_browser(self):
|
||||
"""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:
|
||||
headless_cfg = self._config.get("headless")
|
||||
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
||||
|
||||
launch_args = ["--disable-dev-shm-usage"]
|
||||
launch_args = [
|
||||
"--disable-dev-shm-usage",
|
||||
# Trim first-launch overhead: skip the first-run wizard, the default
|
||||
# browser prompt, and Chrome's background/component network chatter.
|
||||
# These have no effect on page interaction but noticeably speed up
|
||||
# cold starts and each navigation.
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-features=Translate,OptimizationHints",
|
||||
]
|
||||
if self._headless:
|
||||
launch_args.append("--no-sandbox")
|
||||
|
||||
@@ -467,6 +542,8 @@ class BrowserService:
|
||||
|
||||
if self._launch_mode == "cdp":
|
||||
self._connect_cdp(viewport)
|
||||
elif self._launch_mode == "system-cdp":
|
||||
self._launch_system_cdp(launch_args, viewport)
|
||||
elif self._launch_mode == "persistent":
|
||||
self._launch_persistent(launch_args, viewport, user_agent)
|
||||
else:
|
||||
@@ -475,12 +552,20 @@ class BrowserService:
|
||||
logger.info("[Browser] Browser ready")
|
||||
|
||||
def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str):
|
||||
"""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(
|
||||
headless=self._headless,
|
||||
args=launch_args,
|
||||
)
|
||||
"""Classic launch: brand new Chromium with an empty context.
|
||||
|
||||
When `self._channel` is set (e.g. "chrome"/"msedge"), Playwright drives
|
||||
the user's installed system browser instead of its own Chromium.
|
||||
"""
|
||||
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(
|
||||
viewport=viewport,
|
||||
user_agent=user_agent,
|
||||
@@ -491,18 +576,25 @@ class BrowserService:
|
||||
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."""
|
||||
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||
engine_label = f"system:{self._channel}" if self._channel else "chromium"
|
||||
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})"
|
||||
)
|
||||
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:
|
||||
self._context = self._playwright.chromium.launch_persistent_context(
|
||||
user_data_dir=self._user_data_dir,
|
||||
headless=self._headless,
|
||||
args=launch_args,
|
||||
viewport=viewport,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
self._context = self._playwright.chromium.launch_persistent_context(**persistent_kwargs)
|
||||
except Exception as e:
|
||||
# Profile is locked when another Chromium instance already holds it.
|
||||
msg = str(e).lower()
|
||||
@@ -520,6 +612,41 @@ class BrowserService:
|
||||
self._page = pages[0] if pages else self._context.new_page()
|
||||
self._wire_close_listeners()
|
||||
|
||||
def _launch_system_cdp(self, launch_args: List[str], viewport: Dict[str, int]):
|
||||
"""Spawn the user's system Chrome/Edge with a debugging port, attach via CDP.
|
||||
|
||||
This is the default for system browsers. Unlike launch(channel=...), it
|
||||
does not "take over" the browser app, so it avoids the macOS Automation
|
||||
prompt / long stall. Login state persists in the isolated user_data_dir.
|
||||
"""
|
||||
from agent.tools.browser.chrome_launcher import ChromeLauncher
|
||||
|
||||
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||
logger.info(
|
||||
f"[Browser] Launching system:{self._channel} via spawn+CDP "
|
||||
f"(headless={self._headless}, profile={self._user_data_dir})"
|
||||
)
|
||||
self._chrome_launcher = ChromeLauncher(
|
||||
executable=self._system_exe,
|
||||
user_data_dir=self._user_data_dir,
|
||||
extra_args=launch_args,
|
||||
headless=self._headless,
|
||||
)
|
||||
endpoint = self._chrome_launcher.launch()
|
||||
|
||||
self._browser = self._playwright.chromium.connect_over_cdp(endpoint)
|
||||
# The spawned Chrome opens its own default context (backed by
|
||||
# user_data_dir); reuse it so cookies / logins persist.
|
||||
contexts = self._browser.contexts
|
||||
self._context = contexts[0] if contexts else self._browser.new_context(viewport=viewport)
|
||||
pages = self._context.pages
|
||||
self._page = pages[0] if pages else self._context.new_page()
|
||||
try:
|
||||
self._page.set_viewport_size(viewport)
|
||||
except Exception:
|
||||
pass
|
||||
self._wire_close_listeners()
|
||||
|
||||
def _connect_cdp(self, viewport: Dict[str, int]):
|
||||
"""Attach to an existing Chrome started with --remote-debugging-port."""
|
||||
endpoint = self._cdp_endpoint
|
||||
@@ -574,13 +701,27 @@ class BrowserService:
|
||||
self._cancel_idle_timer()
|
||||
|
||||
if self._launch_mode == "cdp":
|
||||
# For CDP, browser.close() only detaches the Playwright client;
|
||||
# the user's Chrome process and its tabs stay alive.
|
||||
# For external CDP, browser.close() only detaches the Playwright
|
||||
# client; the user's Chrome process and its tabs stay alive.
|
||||
try:
|
||||
if self._browser:
|
||||
self._browser.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] cdp disconnect error: {e}")
|
||||
elif self._launch_mode == "system-cdp":
|
||||
# We own the spawned Chrome: detach the CDP client, then kill the
|
||||
# process we started so it doesn't linger.
|
||||
try:
|
||||
if self._browser:
|
||||
self._browser.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] system-cdp disconnect error: {e}")
|
||||
try:
|
||||
if self._chrome_launcher:
|
||||
self._chrome_launcher.close()
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] chrome launcher close error: {e}")
|
||||
self._chrome_launcher = None
|
||||
else:
|
||||
for obj, label in [
|
||||
(self._context, "context"),
|
||||
@@ -687,11 +828,15 @@ class BrowserService:
|
||||
except Exception as e:
|
||||
return {"error": f"Navigation failed: {e}"}
|
||||
|
||||
# SPAs keep long-lived connections (websockets, polling, analytics) and
|
||||
# rarely reach true "networkidle", so waiting the full timeout is wasted
|
||||
# time. domcontentloaded already gives a usable DOM; give the page a
|
||||
# short grace period for initial render/XHR, then proceed.
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=8000)
|
||||
page.wait_for_load_state("networkidle", timeout=1500)
|
||||
except Exception:
|
||||
pass
|
||||
page.wait_for_timeout(500)
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
try:
|
||||
title = page.title()
|
||||
@@ -710,7 +855,11 @@ class BrowserService:
|
||||
def _do_snapshot(self, selector: Optional[str] = None) -> str:
|
||||
page = self._page
|
||||
try:
|
||||
result = page.evaluate(_SNAPSHOT_JS)
|
||||
# Return a single JSON string (not a nested object) to avoid
|
||||
# Playwright's per-node serialization round trips, which are slow
|
||||
# in the frozen build. See _SNAPSHOT_JS_STR.
|
||||
raw = page.evaluate(_SNAPSHOT_JS_STR)
|
||||
result = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception as e:
|
||||
return f"[Snapshot error: {e}]"
|
||||
|
||||
|
||||
@@ -185,6 +185,40 @@ class BrowserTool(BaseTool):
|
||||
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:
|
||||
action = args.get("action", "").strip().lower()
|
||||
if not action:
|
||||
@@ -195,6 +229,13 @@ class BrowserTool(BaseTool):
|
||||
valid = ", ".join(sorted(self._ACTION_MAP.keys()))
|
||||
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:
|
||||
return handler(self, args)
|
||||
except Exception as e:
|
||||
|
||||
174
agent/tools/browser/chrome_launcher.py
Normal file
174
agent/tools/browser/chrome_launcher.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Spawn a system Chrome/Edge with a DevTools debugging port for CDP control.
|
||||
|
||||
Why this exists: driving a system browser via Playwright's
|
||||
``chromium.launch(channel="chrome")`` makes the app *take over* another app's
|
||||
process, which on macOS triggers a TCC "Automation" permission prompt and a
|
||||
multi-second (sometimes 100s+) stall on first use. Launching Chrome ourselves
|
||||
with ``--remote-debugging-port`` and attaching via ``connect_over_cdp`` avoids
|
||||
that entirely — from the OS's view it's just a process listening on a local
|
||||
port — and matches how Codex / Claude Code drive the user's real browser.
|
||||
|
||||
The launched process uses an isolated ``--user-data-dir`` so it never fights
|
||||
the user's day-to-day browser profile, while still persisting login state
|
||||
across sessions inside that dir.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import socket
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from typing import Optional, List
|
||||
|
||||
from common.log import logger
|
||||
|
||||
|
||||
class ChromeLauncher:
|
||||
"""Own the lifecycle of a debugging-enabled Chrome/Edge child process."""
|
||||
|
||||
def __init__(self, executable: str, user_data_dir: str,
|
||||
extra_args: Optional[List[str]] = None,
|
||||
headless: bool = False):
|
||||
self._executable = executable
|
||||
self._user_data_dir = user_data_dir
|
||||
self._extra_args = extra_args or []
|
||||
self._headless = headless
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._port: Optional[int] = None
|
||||
|
||||
@property
|
||||
def endpoint(self) -> str:
|
||||
"""CDP HTTP endpoint (only valid after a successful launch())."""
|
||||
return f"http://127.0.0.1:{self._port}" if self._port else ""
|
||||
|
||||
@staticmethod
|
||||
def _free_port() -> int:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
def _clear_stale_singleton_locks(self):
|
||||
"""Remove leftover Chrome Singleton* locks from a crashed/killed run.
|
||||
|
||||
Chrome allows only one instance per user_data_dir and enforces it with
|
||||
SingletonLock / SingletonSocket / SingletonCookie. On a clean exit these
|
||||
are removed, but a crash or force-quit leaves them behind — the next
|
||||
spawn then hands off to the (dead) "existing" instance and exits without
|
||||
opening the debug port, so CDP never comes up (a permanent, non
|
||||
self-healing failure). This profile is private to us, so clearing stale
|
||||
locks before launch is safe: if our own browser were truly alive, the
|
||||
service would still be connected and we wouldn't be re-launching.
|
||||
"""
|
||||
for name in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
|
||||
p = os.path.join(self._user_data_dir, name)
|
||||
try:
|
||||
# These are symlinks; use lexists so a dangling link is caught.
|
||||
if os.path.lexists(p):
|
||||
os.remove(p)
|
||||
logger.info(f"[Browser] cleared stale Chrome lock: {name}")
|
||||
except OSError as e:
|
||||
logger.debug(f"[Browser] could not remove {name}: {e}")
|
||||
|
||||
def launch(self, ready_timeout: float = 25.0) -> str:
|
||||
"""Spawn Chrome and block until its CDP endpoint answers.
|
||||
|
||||
Returns the CDP endpoint URL. Raises RuntimeError if the endpoint never
|
||||
comes up (the child process is killed in that case).
|
||||
"""
|
||||
os.makedirs(self._user_data_dir, exist_ok=True)
|
||||
self._clear_stale_singleton_locks()
|
||||
self._port = self._free_port()
|
||||
|
||||
args = [
|
||||
self._executable,
|
||||
f"--remote-debugging-port={self._port}",
|
||||
f"--user-data-dir={self._user_data_dir}",
|
||||
# Trim first-run overhead and background chatter for faster starts.
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-features=Translate,OptimizationHints",
|
||||
# A blank first tab keeps startup cheap and predictable.
|
||||
"about:blank",
|
||||
]
|
||||
if self._headless:
|
||||
args.insert(1, "--headless=new")
|
||||
args[1:1] = self._extra_args
|
||||
|
||||
popen_kwargs = {}
|
||||
if sys.platform == "win32":
|
||||
# Detach from any console and never flash a window on Windows.
|
||||
popen_kwargs["creationflags"] = (
|
||||
getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
| getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||
)
|
||||
else:
|
||||
# New session so the child isn't tied to the parent's controlling
|
||||
# terminal / process group (clean teardown, no signal bleed).
|
||||
popen_kwargs["start_new_session"] = True
|
||||
|
||||
logger.info(f"[Browser] Spawning {os.path.basename(self._executable)} "
|
||||
f"on CDP port {self._port} (profile={self._user_data_dir})")
|
||||
self._proc = subprocess.Popen(
|
||||
args,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
**popen_kwargs,
|
||||
)
|
||||
|
||||
if not self._wait_ready(ready_timeout):
|
||||
# Capture the port before close() clears it, so the error is useful.
|
||||
port = self._port
|
||||
self.close()
|
||||
raise RuntimeError(
|
||||
f"Chrome did not expose a CDP endpoint on port {port} "
|
||||
f"within {ready_timeout:.0f}s"
|
||||
)
|
||||
return self.endpoint
|
||||
|
||||
def _wait_ready(self, timeout: float) -> bool:
|
||||
"""Poll DevTools /json/version until Chrome is listening (or times out)."""
|
||||
deadline = time.time() + timeout
|
||||
url = f"http://127.0.0.1:{self._port}/json/version"
|
||||
while time.time() < deadline:
|
||||
# Bail out early if the process died on startup.
|
||||
if self._proc and self._proc.poll() is not None:
|
||||
logger.error(
|
||||
f"[Browser] Chrome exited early (code={self._proc.returncode}) "
|
||||
"before opening the CDP port"
|
||||
)
|
||||
return False
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=1) as r:
|
||||
if r.status == 200:
|
||||
return True
|
||||
except Exception:
|
||||
time.sleep(0.15)
|
||||
return False
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self._proc is not None and self._proc.poll() is None
|
||||
|
||||
def close(self):
|
||||
"""Terminate the spawned Chrome process (idempotent)."""
|
||||
proc = self._proc
|
||||
self._proc = None
|
||||
self._port = None
|
||||
if proc is None:
|
||||
return
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
try:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Browser] error terminating Chrome process: {e}")
|
||||
@@ -13,7 +13,7 @@ from agent.tools.utils.diff import (
|
||||
detect_line_ending,
|
||||
normalize_to_lf,
|
||||
restore_line_endings,
|
||||
normalize_for_fuzzy_match,
|
||||
count_matches,
|
||||
fuzzy_find_text,
|
||||
generate_diff_string
|
||||
)
|
||||
@@ -110,10 +110,10 @@ class Edit(BaseTool):
|
||||
"The old text must match exactly including all whitespace and newlines."
|
||||
)
|
||||
|
||||
# Calculate occurrence count (use fuzzy normalized content for consistency)
|
||||
fuzzy_content = normalize_for_fuzzy_match(normalized_content)
|
||||
fuzzy_old_text = normalize_for_fuzzy_match(normalized_old_text)
|
||||
occurrences = fuzzy_content.count(fuzzy_old_text)
|
||||
# Count occurrences with the same matcher used to locate and
|
||||
# replace (fuzzy_find_text), so the uniqueness guard cannot
|
||||
# disagree with what actually gets replaced.
|
||||
occurrences = count_matches(normalized_content, normalized_old_text)
|
||||
|
||||
if occurrences > 1:
|
||||
return ToolResult.fail(
|
||||
|
||||
@@ -21,6 +21,48 @@ from common.log import logger
|
||||
_STREAMABLE_HTTP_ALIASES = {"streamable-http", "streamable_http", "streamablehttp", "http"}
|
||||
|
||||
|
||||
# Optional callback invoked after an OAuth authorization completes, so the
|
||||
# tool manager can bring the newly-authorized server online. Signature:
|
||||
# reload_fn(server_name: str) -> None. Installed by the tool manager.
|
||||
_reload_callback = None
|
||||
|
||||
|
||||
def set_reload_callback(fn) -> None:
|
||||
"""Register a callback fired after a server's OAuth flow succeeds."""
|
||||
global _reload_callback
|
||||
_reload_callback = fn
|
||||
|
||||
|
||||
def notify_server_authorized(server_name: str) -> None:
|
||||
"""Called by the web callback once tokens are stored for a server."""
|
||||
fn = _reload_callback
|
||||
if fn is None:
|
||||
logger.debug(f"[MCP:{server_name}] Authorized but no reload callback registered")
|
||||
return
|
||||
try:
|
||||
fn(server_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP:{server_name}] reload callback failed: {e}")
|
||||
|
||||
|
||||
def _oauth_redirect_uri() -> str:
|
||||
"""Build the OAuth redirect URI served by the web console callback.
|
||||
|
||||
Priority: explicit mcp_oauth_redirect_base config, otherwise the local
|
||||
web console address (127.0.0.1:<web_port>). Both point at the shared
|
||||
/mcp/oauth/callback route.
|
||||
"""
|
||||
try:
|
||||
from config import conf
|
||||
base = (conf().get("mcp_oauth_redirect_base") or "").strip().rstrip("/")
|
||||
if not base:
|
||||
port = int(os.environ.get("COW_WEB_PORT") or conf().get("web_port", 9899))
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
except Exception:
|
||||
base = "http://127.0.0.1:9899"
|
||||
return f"{base}/mcp/oauth/callback"
|
||||
|
||||
|
||||
class McpClient:
|
||||
"""Single MCP Server client supporting stdio, SSE and Streamable HTTP transports."""
|
||||
|
||||
@@ -56,6 +98,13 @@ class McpClient:
|
||||
self._http_headers: dict = {} # extra headers from user config (e.g. Authorization)
|
||||
self._http_session_id: Optional[str] = None # Mcp-Session-Id assigned by the server
|
||||
|
||||
# OAuth state (streamable-http only). Lazily created when the server
|
||||
# responds with 401 and the user has not supplied a static token.
|
||||
self._oauth = None # OAuthHandler instance
|
||||
# Set to True once a 401 could not be satisfied and the user must
|
||||
# complete the browser authorization. Callers can surface this state.
|
||||
self.needs_auth: bool = False
|
||||
|
||||
# Shared state
|
||||
self._next_id = 1
|
||||
self._id_lock = threading.Lock()
|
||||
@@ -325,13 +374,118 @@ class McpClient:
|
||||
if isinstance(extra_headers, dict):
|
||||
self._http_headers = {str(k): str(v) for k, v in extra_headers.items()}
|
||||
|
||||
# Restore any previously stored OAuth credentials for this server so a
|
||||
# restart reuses the token instead of forcing re-authorization.
|
||||
self._maybe_load_oauth()
|
||||
|
||||
return self._handshake()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OAuth helpers (streamable-http only)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _has_static_auth(self) -> bool:
|
||||
"""True when the user supplied their own Authorization header."""
|
||||
return any(k.lower() == "authorization" for k in self._http_headers)
|
||||
|
||||
def _maybe_load_oauth(self) -> None:
|
||||
"""Attach an OAuthHandler when stored credentials exist for this server."""
|
||||
if self._has_static_auth():
|
||||
return
|
||||
try:
|
||||
from agent.tools.mcp.mcp_oauth import OAuthHandler, load_server_record
|
||||
except Exception:
|
||||
return
|
||||
rec = load_server_record(self.name)
|
||||
# Only create a handler when we have something to reuse; otherwise it
|
||||
# is created lazily on the first 401.
|
||||
if rec.get("access_token") or rec.get("client_id"):
|
||||
self._oauth = OAuthHandler(
|
||||
server_name=self.name,
|
||||
resource_url=self._http_url,
|
||||
redirect_uri=_oauth_redirect_uri(),
|
||||
scope=self.config.get("scope", ""),
|
||||
)
|
||||
|
||||
def _current_bearer(self) -> Optional[str]:
|
||||
"""Return a valid access token, refreshing if needed."""
|
||||
if self._oauth is None:
|
||||
return None
|
||||
return self._oauth.get_valid_access_token()
|
||||
|
||||
def _begin_oauth(self, www_authenticate: str = "") -> None:
|
||||
"""Kick off the OAuth flow after a 401: discover, register, prompt user."""
|
||||
if self._has_static_auth():
|
||||
return
|
||||
try:
|
||||
from agent.tools.mcp.mcp_oauth import OAuthHandler
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP:{self.name}] OAuth module unavailable: {e}")
|
||||
return
|
||||
|
||||
if self._oauth is None:
|
||||
self._oauth = OAuthHandler(
|
||||
server_name=self.name,
|
||||
resource_url=self._http_url,
|
||||
redirect_uri=_oauth_redirect_uri(),
|
||||
scope=self.config.get("scope", ""),
|
||||
)
|
||||
|
||||
if not self._oauth.ensure_registered(www_authenticate):
|
||||
logger.warning(
|
||||
f"[MCP:{self.name}] OAuth discovery/registration failed; "
|
||||
f"cannot authorize automatically"
|
||||
)
|
||||
return
|
||||
|
||||
auth_url = self._oauth.build_authorization_url()
|
||||
if not auth_url:
|
||||
logger.warning(f"[MCP:{self.name}] Failed to build authorization URL")
|
||||
return
|
||||
|
||||
self.needs_auth = True
|
||||
logger.warning(
|
||||
f"[MCP:{self.name}] ⚠️ Authorization required. Open this URL in a "
|
||||
f"browser to authorize, then this server will come online automatically:\n"
|
||||
f" {auth_url}"
|
||||
)
|
||||
# On a machine with a local browser (desktop/dev), open it directly.
|
||||
if os.environ.get("COW_DESKTOP") == "1" or not os.environ.get("COW_HEADLESS"):
|
||||
try:
|
||||
import webbrowser
|
||||
webbrowser.open(auth_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _streamable_http_send(self, message: dict) -> dict:
|
||||
"""POST a JSON-RPC request and return the response (JSON or SSE-wrapped)."""
|
||||
return self._streamable_http_post(message, expect_response=True)
|
||||
|
||||
def _streamable_http_post(self, message: dict, expect_response: bool) -> dict:
|
||||
def _handle_401(self, err, message: dict, expect_response: bool, retried: bool) -> dict:
|
||||
"""Handle a 401: refresh the token and retry once, else begin OAuth."""
|
||||
www_auth = ""
|
||||
try:
|
||||
www_auth = err.headers.get("WWW-Authenticate", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
err.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# First try a silent refresh with the stored refresh token.
|
||||
if not retried and self._oauth is not None and self._oauth.refresh():
|
||||
logger.info(f"[MCP:{self.name}] Token refreshed after 401, retrying")
|
||||
return self._streamable_http_post(message, expect_response, _retried=True)
|
||||
|
||||
# No usable token — start (or restart) the interactive OAuth flow.
|
||||
self._begin_oauth(www_auth)
|
||||
raise IOError(
|
||||
f"[MCP:{self.name}] streamable-http HTTP 401: authorization required "
|
||||
f"(complete the OAuth flow to enable this server)"
|
||||
)
|
||||
|
||||
def _streamable_http_post(self, message: dict, expect_response: bool, _retried: bool = False) -> dict:
|
||||
"""
|
||||
POST a JSON-RPC message over Streamable HTTP.
|
||||
|
||||
@@ -351,6 +505,12 @@ class McpClient:
|
||||
if sid:
|
||||
headers["Mcp-Session-Id"] = sid
|
||||
headers.update(self._http_headers)
|
||||
# Inject OAuth bearer token when we have one (unless the user set a
|
||||
# static Authorization header, which takes precedence).
|
||||
if not self._has_static_auth():
|
||||
token = self._current_bearer()
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
req = urllib.request.Request(
|
||||
self._http_url,
|
||||
@@ -362,6 +522,9 @@ class McpClient:
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=30)
|
||||
except urllib.error.HTTPError as e:
|
||||
# 401 is the spec-compliant "needs authorization" signal.
|
||||
if e.code == 401 and not self._has_static_auth():
|
||||
return self._handle_401(e, message, expect_response, _retried)
|
||||
# Surface the server-provided error body for easier debugging
|
||||
detail = ""
|
||||
try:
|
||||
|
||||
466
agent/tools/mcp/mcp_oauth.py
Normal file
466
agent/tools/mcp/mcp_oauth.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
MCP OAuth 2.1 client (authorization code + PKCE) with zero external deps.
|
||||
|
||||
Implements the subset of the MCP authorization spec needed to connect to
|
||||
remote MCP servers that guard their endpoint behind OAuth (e.g. Xmind):
|
||||
|
||||
1. Metadata discovery via RFC 9728 (protected-resource) + RFC 8414
|
||||
(authorization-server) .well-known documents.
|
||||
2. Dynamic Client Registration (RFC 7591) to obtain a client_id.
|
||||
3. PKCE (RFC 7636, S256) authorization-code flow.
|
||||
4. Token exchange + refresh, persisted to ~/.cow/mcp_oauth.json.
|
||||
|
||||
The actual browser round-trip is completed out-of-band: McpClient generates
|
||||
an authorization URL, the user opens it, and the web console callback
|
||||
(/mcp/oauth/callback) feeds the returned code back into finish_authorization().
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Optional
|
||||
|
||||
from common.log import logger
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Token store: ~/.cow/mcp_oauth.json {server_name: {...credentials...}}
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_STORE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _store_path() -> str:
|
||||
base = os.path.expanduser("~/.cow")
|
||||
try:
|
||||
os.makedirs(base, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return os.path.join(base, "mcp_oauth.json")
|
||||
|
||||
|
||||
def _load_store() -> dict:
|
||||
path = _store_path()
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth] Failed to read token store: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _save_store(store: dict) -> None:
|
||||
path = _store_path()
|
||||
tmp = f"{path}.tmp"
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(store, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, path)
|
||||
# Credentials file: restrict to owner read/write when possible.
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth] Failed to persist token store: {e}")
|
||||
|
||||
|
||||
def load_server_record(server_name: str) -> dict:
|
||||
with _STORE_LOCK:
|
||||
return dict(_load_store().get(server_name, {}))
|
||||
|
||||
|
||||
def save_server_record(server_name: str, record: dict) -> None:
|
||||
with _STORE_LOCK:
|
||||
store = _load_store()
|
||||
store[server_name] = record
|
||||
_save_store(store)
|
||||
|
||||
|
||||
def clear_server_record(server_name: str) -> None:
|
||||
with _STORE_LOCK:
|
||||
store = _load_store()
|
||||
if server_name in store:
|
||||
store.pop(server_name, None)
|
||||
_save_store(store)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pending authorizations, keyed by the OAuth `state` param.
|
||||
# Populated when an authorization URL is generated; consumed by the
|
||||
# web callback when the browser redirects back with ?code&state.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_PENDING_LOCK = threading.Lock()
|
||||
_PENDING: dict = {} # state -> {"handler": OAuthHandler, "created": ts}
|
||||
_PENDING_TTL = 600 # seconds
|
||||
|
||||
|
||||
def _register_pending(state: str, handler: "OAuthHandler") -> None:
|
||||
with _PENDING_LOCK:
|
||||
_prune_pending_locked()
|
||||
_PENDING[state] = {"handler": handler, "created": time.time()}
|
||||
|
||||
|
||||
def _prune_pending_locked() -> None:
|
||||
now = time.time()
|
||||
stale = [s for s, v in _PENDING.items() if now - v["created"] > _PENDING_TTL]
|
||||
for s in stale:
|
||||
_PENDING.pop(s, None)
|
||||
|
||||
|
||||
def pop_pending(state: str) -> Optional["OAuthHandler"]:
|
||||
with _PENDING_LOCK:
|
||||
_prune_pending_locked()
|
||||
entry = _PENDING.pop(state, None)
|
||||
return entry["handler"] if entry else None
|
||||
|
||||
|
||||
def has_pending() -> bool:
|
||||
with _PENDING_LOCK:
|
||||
_prune_pending_locked()
|
||||
return bool(_PENDING)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HTTP helpers (stdlib only)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_UA = "CowAgent-MCP-OAuth/1.0"
|
||||
|
||||
|
||||
def _http_get_json(url: str, timeout: int = 15) -> Optional[dict]:
|
||||
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": _UA})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw)
|
||||
except urllib.error.HTTPError as e:
|
||||
logger.debug(f"[MCP-OAuth] GET {url} -> HTTP {e.code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"[MCP-OAuth] GET {url} failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _http_post_form(url: str, fields: dict, timeout: int = 20) -> dict:
|
||||
body = urllib.parse.urlencode(fields).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _UA,
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def _http_post_json(url: str, payload: dict, timeout: int = 20) -> dict:
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": _UA,
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Discovery (RFC 9728 + RFC 8414)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _origin(url: str) -> str:
|
||||
p = urllib.parse.urlparse(url)
|
||||
return f"{p.scheme}://{p.netloc}"
|
||||
|
||||
|
||||
def discover_metadata(resource_url: str, www_authenticate: str = "") -> Optional[dict]:
|
||||
"""
|
||||
Resolve the authorization server metadata for a protected MCP resource.
|
||||
|
||||
Returns a dict with at least authorization_endpoint + token_endpoint,
|
||||
plus registration_endpoint when the server supports DCR. Returns None
|
||||
when discovery fails.
|
||||
"""
|
||||
as_metadata_url = _parse_resource_metadata_url(www_authenticate)
|
||||
|
||||
# 1) Protected-resource metadata (RFC 9728) to locate the auth server.
|
||||
auth_server = None
|
||||
prm = None
|
||||
if as_metadata_url:
|
||||
prm = _http_get_json(as_metadata_url)
|
||||
if prm is None:
|
||||
origin = _origin(resource_url)
|
||||
prm = _http_get_json(f"{origin}/.well-known/oauth-protected-resource")
|
||||
if prm and isinstance(prm.get("authorization_servers"), list) and prm["authorization_servers"]:
|
||||
auth_server = prm["authorization_servers"][0]
|
||||
|
||||
# 2) Authorization-server metadata (RFC 8414). Fall back to the resource
|
||||
# origin when the resource did not advertise a separate auth server.
|
||||
base = auth_server or _origin(resource_url)
|
||||
asm = _fetch_as_metadata(base)
|
||||
if not asm:
|
||||
return None
|
||||
|
||||
if not asm.get("authorization_endpoint") or not asm.get("token_endpoint"):
|
||||
logger.warning("[MCP-OAuth] Authorization server metadata missing required endpoints")
|
||||
return None
|
||||
|
||||
# Derive the scope to request. Prefer the resource's required_scopes
|
||||
# (RFC 9728), then its scopes_supported, then the auth server's
|
||||
# scopes_supported. Stored so callers don't have to configure it.
|
||||
discovered_scope = ""
|
||||
if prm:
|
||||
scopes = prm.get("required_scopes") or prm.get("scopes_supported")
|
||||
if isinstance(scopes, list) and scopes:
|
||||
discovered_scope = " ".join(str(s) for s in scopes)
|
||||
if not discovered_scope and isinstance(asm.get("scopes_supported"), list) and asm["scopes_supported"]:
|
||||
discovered_scope = " ".join(str(s) for s in asm["scopes_supported"])
|
||||
if discovered_scope:
|
||||
asm["_discovered_scope"] = discovered_scope
|
||||
return asm
|
||||
|
||||
|
||||
def _parse_resource_metadata_url(www_authenticate: str) -> Optional[str]:
|
||||
"""Extract resource_metadata="..." from a WWW-Authenticate: Bearer header."""
|
||||
if not www_authenticate:
|
||||
return None
|
||||
# naive but sufficient parse for `resource_metadata="URL"`
|
||||
marker = "resource_metadata="
|
||||
idx = www_authenticate.find(marker)
|
||||
if idx < 0:
|
||||
return None
|
||||
rest = www_authenticate[idx + len(marker):].strip()
|
||||
if rest.startswith('"'):
|
||||
end = rest.find('"', 1)
|
||||
return rest[1:end] if end > 0 else None
|
||||
# unquoted, up to comma/space
|
||||
for sep in (",", " "):
|
||||
if sep in rest:
|
||||
rest = rest.split(sep, 1)[0]
|
||||
return rest or None
|
||||
|
||||
|
||||
def _fetch_as_metadata(base: str) -> Optional[dict]:
|
||||
"""Try both RFC 8414 and OIDC well-known locations."""
|
||||
base = base.rstrip("/")
|
||||
candidates = [
|
||||
f"{base}/.well-known/oauth-authorization-server",
|
||||
f"{base}/.well-known/openid-configuration",
|
||||
]
|
||||
for url in candidates:
|
||||
data = _http_get_json(url)
|
||||
if data and data.get("authorization_endpoint"):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PKCE
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _make_pkce() -> tuple:
|
||||
verifier = _b64url(secrets.token_bytes(32))
|
||||
challenge = _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# OAuthHandler: per-server OAuth state machine
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
class OAuthHandler:
|
||||
"""Drives the OAuth flow and token lifecycle for a single MCP server."""
|
||||
|
||||
def __init__(self, server_name: str, resource_url: str, redirect_uri: str,
|
||||
scope: str = "", client_name: str = "CowAgent"):
|
||||
self.server_name = server_name
|
||||
self.resource_url = resource_url
|
||||
self.redirect_uri = redirect_uri
|
||||
self.scope = scope
|
||||
self.client_name = client_name
|
||||
|
||||
rec = load_server_record(server_name)
|
||||
self.metadata: dict = rec.get("metadata", {})
|
||||
self.client_id: Optional[str] = rec.get("client_id")
|
||||
self.client_secret: Optional[str] = rec.get("client_secret")
|
||||
self.access_token: Optional[str] = rec.get("access_token")
|
||||
self.refresh_token: Optional[str] = rec.get("refresh_token")
|
||||
self.expires_at: float = float(rec.get("expires_at", 0) or 0)
|
||||
self._verifier: Optional[str] = None
|
||||
|
||||
# --- persistence -------------------------------------------------
|
||||
|
||||
def _persist(self) -> None:
|
||||
save_server_record(self.server_name, {
|
||||
"resource_url": self.resource_url,
|
||||
"metadata": self.metadata,
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"access_token": self.access_token,
|
||||
"refresh_token": self.refresh_token,
|
||||
"expires_at": self.expires_at,
|
||||
})
|
||||
|
||||
# --- token access ------------------------------------------------
|
||||
|
||||
def get_valid_access_token(self, leeway: int = 60) -> Optional[str]:
|
||||
"""Return a usable access token, refreshing proactively when near expiry."""
|
||||
if not self.access_token:
|
||||
return None
|
||||
if self.expires_at and time.time() >= self.expires_at - leeway:
|
||||
if not self.refresh():
|
||||
return None
|
||||
return self.access_token
|
||||
|
||||
def refresh(self) -> bool:
|
||||
"""Refresh the access token using the stored refresh token."""
|
||||
if not self.refresh_token or not self.metadata.get("token_endpoint"):
|
||||
return False
|
||||
fields = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": self.refresh_token,
|
||||
"client_id": self.client_id or "",
|
||||
}
|
||||
if self.client_secret:
|
||||
fields["client_secret"] = self.client_secret
|
||||
try:
|
||||
resp = _http_post_form(self.metadata["token_endpoint"], fields)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] refresh failed: {e}")
|
||||
return False
|
||||
return self._absorb_token_response(resp)
|
||||
|
||||
# --- authorization-code flow ------------------------------------
|
||||
|
||||
def ensure_registered(self, www_authenticate: str = "") -> bool:
|
||||
"""Discover metadata + register a client if not already done."""
|
||||
if not self.metadata.get("authorization_endpoint"):
|
||||
meta = discover_metadata(self.resource_url, www_authenticate)
|
||||
if not meta:
|
||||
return False
|
||||
self.metadata = meta
|
||||
# Adopt the scope discovered from metadata when the user didn't set one.
|
||||
if not self.scope and self.metadata.get("_discovered_scope"):
|
||||
self.scope = self.metadata["_discovered_scope"]
|
||||
logger.info(f"[MCP-OAuth:{self.server_name}] Using discovered scope: {self.scope}")
|
||||
if not self.client_id:
|
||||
if not self._register_client():
|
||||
return False
|
||||
self._persist()
|
||||
return True
|
||||
|
||||
def _register_client(self) -> bool:
|
||||
reg_endpoint = self.metadata.get("registration_endpoint")
|
||||
if not reg_endpoint:
|
||||
logger.warning(
|
||||
f"[MCP-OAuth:{self.server_name}] No registration_endpoint; "
|
||||
f"DCR unavailable. Provide client_id manually."
|
||||
)
|
||||
return False
|
||||
payload = {
|
||||
"client_name": self.client_name,
|
||||
"redirect_uris": [self.redirect_uri],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
}
|
||||
if self.scope:
|
||||
payload["scope"] = self.scope
|
||||
try:
|
||||
resp = _http_post_json(reg_endpoint, payload)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] DCR failed: {e}")
|
||||
return False
|
||||
client_id = resp.get("client_id")
|
||||
if not client_id:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] DCR returned no client_id")
|
||||
return False
|
||||
self.client_id = client_id
|
||||
self.client_secret = resp.get("client_secret")
|
||||
logger.info(f"[MCP-OAuth:{self.server_name}] Registered client_id={client_id}")
|
||||
return True
|
||||
|
||||
def build_authorization_url(self) -> Optional[str]:
|
||||
"""Create an authorization URL and register this handler as pending."""
|
||||
if not self.metadata.get("authorization_endpoint") or not self.client_id:
|
||||
return None
|
||||
self._verifier, challenge = _make_pkce()
|
||||
state = secrets.token_urlsafe(24)
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": self.client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": state,
|
||||
}
|
||||
if self.scope:
|
||||
params["scope"] = self.scope
|
||||
# Advertise the resource we intend to access (RFC 8707).
|
||||
params["resource"] = self.resource_url
|
||||
_register_pending(state, self)
|
||||
return f"{self.metadata['authorization_endpoint']}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
def finish_authorization(self, code: str) -> bool:
|
||||
"""Exchange an authorization code for tokens."""
|
||||
if not self.metadata.get("token_endpoint") or not self._verifier:
|
||||
return False
|
||||
fields = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"client_id": self.client_id or "",
|
||||
"code_verifier": self._verifier,
|
||||
"resource": self.resource_url,
|
||||
}
|
||||
if self.client_secret:
|
||||
fields["client_secret"] = self.client_secret
|
||||
try:
|
||||
resp = _http_post_form(self.metadata["token_endpoint"], fields)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] token exchange failed: {e}")
|
||||
return False
|
||||
ok = self._absorb_token_response(resp)
|
||||
self._verifier = None
|
||||
return ok
|
||||
|
||||
def _absorb_token_response(self, resp: dict) -> bool:
|
||||
access = resp.get("access_token")
|
||||
if not access:
|
||||
logger.warning(f"[MCP-OAuth:{self.server_name}] token response missing access_token: {resp}")
|
||||
return False
|
||||
self.access_token = access
|
||||
if resp.get("refresh_token"):
|
||||
self.refresh_token = resp["refresh_token"]
|
||||
expires_in = resp.get("expires_in")
|
||||
self.expires_at = time.time() + int(expires_in) if expires_in else 0
|
||||
self._persist()
|
||||
logger.info(f"[MCP-OAuth:{self.server_name}] Access token stored")
|
||||
return True
|
||||
159
agent/tools/mcp/tool_retrieval.py
Normal file
159
agent/tools/mcp/tool_retrieval.py
Normal file
@@ -0,0 +1,159 @@
|
||||
# encoding:utf-8
|
||||
"""
|
||||
On-demand MCP tool retrieval.
|
||||
|
||||
Pure, stateless selection helpers used by the streaming executor to decide
|
||||
which MCP tools to inject into a given LLM turn. Vector precompute + caching
|
||||
live in ToolManager (the tool-lifecycle owner, a process-wide singleton);
|
||||
only the context-aware selection lives here, because only the executor knows
|
||||
the conversation context.
|
||||
|
||||
Invariants (per maintainer review of the feature proposal):
|
||||
* Built-in tools are never handled here — the caller injects them in full.
|
||||
* Any failure / missing input returns None so the caller falls back to
|
||||
full injection; tools must never be silently dropped.
|
||||
* Selection is union-accumulated across turns by the caller (only-grows),
|
||||
so a tool that already produced a tool_use in the message history can
|
||||
never disappear from the schema mid-run (which would make Claude/MiniMax
|
||||
raise a message-format error).
|
||||
"""
|
||||
import math
|
||||
from typing import Dict, List, Optional, Sequence, Set
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
_HAS_NUMPY = True
|
||||
except ImportError:
|
||||
_HAS_NUMPY = False
|
||||
|
||||
# How many trailing messages to concatenate into the retrieval query. Tool
|
||||
# needs drift across a multi-turn tool-call loop, so a single (initial) user
|
||||
# query is not enough; a short recent window captures the drift without
|
||||
# bloating the query with stale context.
|
||||
DEFAULT_QUERY_MESSAGES = 5
|
||||
|
||||
|
||||
def build_retrieval_query(messages: list, max_messages: int = DEFAULT_QUERY_MESSAGES) -> str:
|
||||
"""Concatenate the text of the most recent messages into a retrieval query.
|
||||
|
||||
Only ``text`` content blocks are kept; ``tool_use`` / ``tool_result`` blocks
|
||||
are skipped so the query stays short and focused on natural-language intent
|
||||
rather than large serialized tool payloads.
|
||||
|
||||
Args:
|
||||
messages: Claude-style message list, each ``{"role", "content"}`` where
|
||||
content is either a string or a list of typed blocks.
|
||||
max_messages: Size of the trailing window to consider.
|
||||
|
||||
Returns:
|
||||
A single string (possibly empty if no text is found).
|
||||
"""
|
||||
if not messages:
|
||||
return ""
|
||||
|
||||
parts: List[str] = []
|
||||
for message in messages[-max_messages:]:
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if isinstance(content, str):
|
||||
if content.strip():
|
||||
parts.append(content.strip())
|
||||
continue
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") == "text":
|
||||
text = block.get("text", "")
|
||||
if isinstance(text, str) and text.strip():
|
||||
parts.append(text.strip())
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
"""Cosine similarity of two equal-length vectors; 0.0 on degenerate input."""
|
||||
if not a or not b or len(a) != len(b):
|
||||
return 0.0
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = math.sqrt(sum(x * x for x in a))
|
||||
norm_b = math.sqrt(sum(y * y for y in b))
|
||||
if norm_a == 0 or norm_b == 0:
|
||||
return 0.0
|
||||
return dot / (norm_a * norm_b)
|
||||
|
||||
|
||||
def select_mcp_tools(
|
||||
query_vector: Optional[Sequence[float]],
|
||||
tool_vectors: Dict[str, Sequence[float]],
|
||||
top_k: int,
|
||||
already_selected: Optional[Set[str]] = None,
|
||||
) -> Optional[Set[str]]:
|
||||
"""Return the accumulated set of MCP tool names to inject this turn.
|
||||
|
||||
Computes cosine similarity between ``query_vector`` and each candidate
|
||||
tool vector, keeps the ``top_k`` best, and unions them with
|
||||
``already_selected`` so the injected set only ever grows within a run.
|
||||
|
||||
Args:
|
||||
query_vector: Embedding of the current retrieval query, or None.
|
||||
tool_vectors: ``{mcp_tool_name: vector}`` for candidate MCP tools.
|
||||
top_k: Max number of tools to add from this turn's ranking.
|
||||
already_selected: Names accumulated in previous turns of this run.
|
||||
|
||||
Returns:
|
||||
The union set of tool names to inject, or None to signal
|
||||
"fall back to full injection" (no query vector, empty/invalid index,
|
||||
or any unexpected error). This function never raises.
|
||||
"""
|
||||
accumulated: Set[str] = set(already_selected) if already_selected else set()
|
||||
|
||||
if not query_vector or not tool_vectors or top_k <= 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
expected_dim = len(query_vector)
|
||||
# Only rank candidates whose vector dimensionality matches the query.
|
||||
# A dimension mismatch means the index was built with a different
|
||||
# embedding model; ranking across dims is meaningless.
|
||||
candidates = {
|
||||
name: vec
|
||||
for name, vec in tool_vectors.items()
|
||||
if vec and len(vec) == expected_dim
|
||||
}
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
ranked = _rank_by_similarity(query_vector, candidates)
|
||||
for name, _score in ranked[:top_k]:
|
||||
accumulated.add(name)
|
||||
return accumulated
|
||||
except Exception:
|
||||
# Selection must never break the agent — fall back to full injection.
|
||||
return None
|
||||
|
||||
|
||||
def _rank_by_similarity(
|
||||
query_vector: Sequence[float],
|
||||
candidates: Dict[str, Sequence[float]],
|
||||
) -> List[tuple]:
|
||||
"""Return ``[(name, score), ...]`` sorted by descending cosine similarity.
|
||||
|
||||
Uses numpy when available (vectorized, matching the memory-search path),
|
||||
with a pure-Python fallback so the feature works without numpy installed.
|
||||
"""
|
||||
names = list(candidates.keys())
|
||||
|
||||
if _HAS_NUMPY:
|
||||
matrix = np.array([candidates[n] for n in names], dtype=np.float32) # (N, D)
|
||||
q_vec = np.array(query_vector, dtype=np.float32) # (D,)
|
||||
dots = matrix @ q_vec # (N,)
|
||||
row_norms = np.linalg.norm(matrix, axis=1) # (N,)
|
||||
q_norm = float(np.linalg.norm(q_vec))
|
||||
denominators = row_norms * q_norm
|
||||
np.maximum(denominators, 1e-10, out=denominators) # avoid div-by-zero
|
||||
sims = dots / denominators
|
||||
order = np.argsort(sims)[::-1]
|
||||
return [(names[i], float(sims[i])) for i in order]
|
||||
|
||||
scored = [(n, cosine_similarity(query_vector, candidates[n])) for n in names]
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
return scored
|
||||
@@ -4,6 +4,7 @@ Supports text files, images (jpg, png, gif, webp), and PDF files
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,11 +13,17 @@ from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_L
|
||||
from common.utils import expand_path
|
||||
|
||||
|
||||
# Paths whose CONTENT mirrors the process environment (and thus any secrets
|
||||
# loaded from ~/.cow/.env). Reading them bypasses the env_config boundary.
|
||||
# Matches /proc/self/environ, /proc/thread-self/environ and /proc/<pid>/environ.
|
||||
_PROC_ENVIRON_RE = re.compile(r"^/proc/(\d+|self|thread-self)/environ$")
|
||||
|
||||
|
||||
class Read(BaseTool):
|
||||
"""Tool for reading file contents"""
|
||||
|
||||
name: str = "read"
|
||||
description: str = f"Read or inspect file contents. For text/PDF files, returns content (truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB). For images/videos/audio, returns metadata only (file info, size, type). Use offset/limit for large text files."
|
||||
description: str = f"Read or inspect file contents. For text/PDF/Word/Excel/PPT files, returns content (truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB). For images/videos/audio, returns metadata only (file info, size, type). Use offset/limit for large text files."
|
||||
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
@@ -79,9 +86,9 @@ class Read(BaseTool):
|
||||
# Resolve path
|
||||
absolute_path = self._resolve_path(path)
|
||||
|
||||
# Security check: Prevent reading sensitive config files
|
||||
env_config_path = expand_path("~/.cow/.env")
|
||||
if os.path.abspath(absolute_path) == os.path.abspath(env_config_path):
|
||||
# Security check: block credential files and their aliases.
|
||||
# See issue #2913 (/proc/self/environ bypass) and #2863 (scope).
|
||||
if self._is_credential_path(absolute_path):
|
||||
return ToolResult.fail(
|
||||
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
||||
)
|
||||
@@ -141,6 +148,38 @@ class Read(BaseTool):
|
||||
return path
|
||||
return os.path.abspath(os.path.join(self.cwd, path))
|
||||
|
||||
def _is_credential_path(self, absolute_path: str) -> bool:
|
||||
"""Return True if *absolute_path* points at protected credential data.
|
||||
|
||||
Beyond the literal ~/.cow/.env file, this also blocks two real bypass
|
||||
surfaces reported in issue #2913:
|
||||
1. /proc/<pid|self|thread-self>/environ — a second view of the
|
||||
process environment that leaks secrets loaded from ~/.cow/.env.
|
||||
2. Symlinks resolving to ~/.cow/.env; the previous exact abspath
|
||||
match kept the link target and could be bypassed.
|
||||
|
||||
Scope is kept deliberately narrow (only the credential file and its
|
||||
environ aliases) so this does NOT re-broaden the block that #2863
|
||||
intentionally narrowed to ~/.cow/.env.
|
||||
"""
|
||||
# Compare on both the normalized path and the symlink-resolved path,
|
||||
# in POSIX form so the /proc regex matches regardless of os.sep.
|
||||
candidates = set()
|
||||
try:
|
||||
candidates.add(os.path.normpath(absolute_path).replace(os.sep, "/"))
|
||||
candidates.add(os.path.realpath(absolute_path).replace(os.sep, "/"))
|
||||
except OSError:
|
||||
candidates.add(absolute_path.replace(os.sep, "/"))
|
||||
|
||||
# 1. /proc environ aliases (checked on raw and symlink-resolved forms).
|
||||
for candidate in candidates:
|
||||
if _PROC_ENVIRON_RE.match(candidate):
|
||||
return True
|
||||
|
||||
# 2. The credential file itself, following symlinks on both sides.
|
||||
env_real = os.path.realpath(expand_path("~/.cow/.env")).replace(os.sep, "/")
|
||||
return env_real in candidates
|
||||
|
||||
def _return_file_metadata(self, absolute_path: str, file_type: str, file_size: int) -> ToolResult:
|
||||
"""
|
||||
Return file metadata for non-readable files (video, audio, binary, etc.)
|
||||
@@ -258,8 +297,15 @@ class Read(BaseTool):
|
||||
if offset is not None:
|
||||
if offset < 0:
|
||||
# Negative offset: read from end
|
||||
# -20 means "last 20 lines" → start from (total - 20)
|
||||
start_line = max(0, total_file_lines + offset)
|
||||
# -20 means "last 20 lines" → start from (total - 20).
|
||||
# A file ending in "\n" produces a trailing empty element
|
||||
# from split('\n'); exclude it so offset=-1 returns the
|
||||
# real last line instead of the empty string after the
|
||||
# final newline (and -N returns N real lines).
|
||||
effective_lines = total_file_lines
|
||||
if all_lines and all_lines[-1] == '':
|
||||
effective_lines -= 1
|
||||
start_line = max(0, effective_lines + offset)
|
||||
else:
|
||||
# Positive offset: read from start (1-indexed)
|
||||
start_line = max(0, offset - 1) # Convert to 0-indexed
|
||||
|
||||
@@ -255,6 +255,12 @@ def _execute_agent_task(task: dict, agent_bridge) -> bool:
|
||||
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
||||
return True # agent ran but produced nothing; don't loop
|
||||
|
||||
if action.get("silent", False):
|
||||
logger.info(
|
||||
f"[Scheduler] Task {task['id']} executed successfully in silent mode"
|
||||
)
|
||||
return True
|
||||
|
||||
from channel.channel_factory import create_channel
|
||||
channel = create_channel(channel_type)
|
||||
if not channel:
|
||||
|
||||
@@ -41,6 +41,8 @@ class SchedulerService:
|
||||
self.running = False
|
||||
self.thread = None
|
||||
self._lock = threading.Lock()
|
||||
self._execution_lock = threading.Lock()
|
||||
self._active_task_ids = set()
|
||||
|
||||
def start(self):
|
||||
"""Start the scheduler service"""
|
||||
@@ -85,7 +87,15 @@ class SchedulerService:
|
||||
try:
|
||||
if self._is_task_due(task, now):
|
||||
logger.info(f"[Scheduler] Executing task: {task['id']} - {task['name']}")
|
||||
if not self._claim_task(task['id']):
|
||||
logger.info(
|
||||
f"[Scheduler] Task {task['id']} is already running; skipping this tick"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
ok = self._execute_task(task)
|
||||
finally:
|
||||
self._release_task(task['id'])
|
||||
if not ok:
|
||||
# Leave next_run_at as-is so the next loop retries.
|
||||
# Cron tasks within the catch-up window will keep
|
||||
@@ -107,6 +117,57 @@ class SchedulerService:
|
||||
except Exception as e:
|
||||
logger.error(f"[Scheduler] Error processing task {task.get('id')}: {e}")
|
||||
|
||||
def run_task_now(self, task_id: str) -> None:
|
||||
"""Queue one immediate execution without changing the task schedule.
|
||||
|
||||
Disabled and one-time tasks may be run manually for testing. The
|
||||
stored ``next_run_at`` remains unchanged, so a manual run never
|
||||
consumes or delays the next scheduled occurrence.
|
||||
|
||||
Raises:
|
||||
ValueError: if the task does not exist.
|
||||
RuntimeError: if the same task is already executing.
|
||||
"""
|
||||
task = self.task_store.get_task(task_id)
|
||||
if not task:
|
||||
raise ValueError(f"Task '{task_id}' not found")
|
||||
if not self._claim_task(task_id):
|
||||
raise RuntimeError(f"Task '{task_id}' is already running")
|
||||
|
||||
def _run():
|
||||
now = datetime.now()
|
||||
try:
|
||||
logger.info(f"[Scheduler] Manually executing task: {task_id} - {task.get('name', '')}")
|
||||
ok = self._execute_task(task)
|
||||
if ok:
|
||||
self.task_store.update_task(task_id, {
|
||||
"last_run_at": now.isoformat(),
|
||||
"last_manual_run_at": now.isoformat(),
|
||||
})
|
||||
logger.info(f"[Scheduler] Manual execution completed: {task_id}")
|
||||
else:
|
||||
logger.warning(f"[Scheduler] Manual execution failed: {task_id}")
|
||||
finally:
|
||||
self._release_task(task_id)
|
||||
|
||||
threading.Thread(
|
||||
target=_run,
|
||||
daemon=True,
|
||||
name=f"scheduler-manual-{task_id}",
|
||||
).start()
|
||||
|
||||
def _claim_task(self, task_id: str) -> bool:
|
||||
"""Prevent scheduled and manual runs of the same task from overlapping."""
|
||||
with self._execution_lock:
|
||||
if task_id in self._active_task_ids:
|
||||
return False
|
||||
self._active_task_ids.add(task_id)
|
||||
return True
|
||||
|
||||
def _release_task(self, task_id: str) -> None:
|
||||
with self._execution_lock:
|
||||
self._active_task_ids.discard(task_id)
|
||||
|
||||
def _is_task_due(self, task: dict, now: datetime) -> bool:
|
||||
"""
|
||||
Check if a task is due to run
|
||||
|
||||
@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
|
||||
"schedule_value": {
|
||||
"type": "string",
|
||||
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
||||
},
|
||||
"silent": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Silent mode (default false): when true, the task runs normally but its result is not pushed. Set true only when the user explicitly says they don't need the result; reminder, notification and broadcast tasks must keep it false"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
|
||||
"channel_type": self.config.get("channel_type", "unknown"),
|
||||
"notify_session_id": notify_session_id,
|
||||
}
|
||||
# silent only applies to ai_task; fixed messages always deliver
|
||||
if kwargs.get("silent", False):
|
||||
action["silent"] = True
|
||||
|
||||
# 针对钉钉单聊,额外存储 sender_staff_id
|
||||
msg = context.kwargs.get("msg")
|
||||
@@ -217,13 +225,16 @@ class SchedulerTool(BaseTool):
|
||||
else:
|
||||
content_desc = f"🤖 AI任务: {ai_task}"
|
||||
|
||||
# Warn the user at creation time so a mistaken silent flag is easy to spot
|
||||
silent_desc = "\n🔇 静默模式: 执行后不会推送结果" if action.get("silent") else ""
|
||||
|
||||
return (
|
||||
f"✅ 定时任务创建成功\n\n"
|
||||
f"📋 任务ID: {task_id}\n"
|
||||
f"📝 名称: {name}\n"
|
||||
f"⏰ 调度: {schedule_desc}\n"
|
||||
f"👤 接收者: {receiver_desc}\n"
|
||||
f"{content_desc}\n"
|
||||
f"{content_desc}{silent_desc}\n"
|
||||
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +54,11 @@ class Send(BaseTool):
|
||||
if not path:
|
||||
return ToolResult.fail("Error: path parameter is required")
|
||||
|
||||
# Pass through remote URLs directly (no local file check): the client
|
||||
# renders the link inline, so no download is needed.
|
||||
if path.lower().startswith(("http://", "https://")):
|
||||
return self._build_url_result(path, message)
|
||||
|
||||
# Resolve path
|
||||
absolute_path = self._resolve_path(path)
|
||||
|
||||
@@ -112,6 +117,46 @@ class Send(BaseTool):
|
||||
|
||||
return ToolResult.success(result)
|
||||
|
||||
def _build_url_result(self, url: str, message: str) -> ToolResult:
|
||||
"""Build a file_to_send result for a remote http(s) URL.
|
||||
|
||||
The URL is passed through as both ``path`` and ``url`` so downstream
|
||||
channels render it inline without downloading it locally.
|
||||
"""
|
||||
# Infer file type from the URL path extension (ignore query string).
|
||||
from urllib.parse import urlparse
|
||||
url_path = urlparse(url).path
|
||||
file_ext = Path(url_path).suffix.lower()
|
||||
file_name = Path(url_path).name or "file"
|
||||
|
||||
if file_ext in self.image_extensions:
|
||||
file_type = "image"
|
||||
mime_type = self._get_image_mime_type(file_ext)
|
||||
elif file_ext in self.video_extensions:
|
||||
file_type = "video"
|
||||
mime_type = self._get_video_mime_type(file_ext)
|
||||
elif file_ext in self.audio_extensions:
|
||||
file_type = "audio"
|
||||
mime_type = self._get_audio_mime_type(file_ext)
|
||||
elif file_ext in self.document_extensions:
|
||||
file_type = "document"
|
||||
mime_type = self._get_document_mime_type(file_ext)
|
||||
else:
|
||||
# Default to image: most pass-through URLs are generated images.
|
||||
file_type = "image"
|
||||
mime_type = "image/jpeg"
|
||||
|
||||
result = {
|
||||
"type": "file_to_send",
|
||||
"file_type": file_type,
|
||||
"path": url,
|
||||
"url": url,
|
||||
"file_name": file_name,
|
||||
"mime_type": mime_type,
|
||||
"message": message or f"正在发送 {file_name}",
|
||||
}
|
||||
return ToolResult.success(result)
|
||||
|
||||
def _resolve_path(self, path: str) -> str:
|
||||
"""Resolve path to absolute path"""
|
||||
path = expand_path(path)
|
||||
|
||||
@@ -71,6 +71,22 @@ class ToolManager:
|
||||
if not hasattr(self, '_mcp_active_configs'):
|
||||
# server_name -> normalized config dict, for diff-based reload.
|
||||
self._mcp_active_configs: dict = {}
|
||||
if not hasattr(self, '_mcp_tool_vectors'):
|
||||
# mcp_tool_name -> embedding vector, used by on-demand tool
|
||||
# retrieval. Populated lazily on first retrieval so users who
|
||||
# never enable the feature pay zero embedding cost.
|
||||
self._mcp_tool_vectors: dict = {}
|
||||
if not hasattr(self, '_mcp_vector_lock'):
|
||||
# Guards incremental index builds so concurrent turns don't
|
||||
# double-embed the same newly-loaded MCP tools.
|
||||
self._mcp_vector_lock = threading.Lock()
|
||||
if not hasattr(self, '_embedding_provider_initialized'):
|
||||
# The embedding provider is created once, lazily, and reused for
|
||||
# both tool-index and per-query embeddings. None means keyword-only
|
||||
# mode (no provider configured) — retrieval then falls back to full
|
||||
# injection at the caller.
|
||||
self._embedding_provider_initialized = False
|
||||
self._embedding_provider = None
|
||||
|
||||
def load_tools(self, tools_dir: str = "", config_dict=None):
|
||||
"""
|
||||
@@ -136,14 +152,7 @@ class ToolManager:
|
||||
except ImportError as e:
|
||||
# Handle missing dependencies with helpful messages
|
||||
error_msg = str(e)
|
||||
if "playwright" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
||||
f" To enable browser tool, run:\n"
|
||||
f" pip install playwright\n"
|
||||
f" playwright install chromium"
|
||||
)
|
||||
elif "markdownify" in error_msg:
|
||||
if "markdownify" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||
f" Install with: pip install markdownify"
|
||||
@@ -206,14 +215,7 @@ class ToolManager:
|
||||
except ImportError as e:
|
||||
# Handle missing dependencies with helpful messages
|
||||
error_msg = str(e)
|
||||
if "playwright" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
||||
f" To enable browser tool, run:\n"
|
||||
f" pip install playwright\n"
|
||||
f" playwright install chromium"
|
||||
)
|
||||
elif "markdownify" in error_msg:
|
||||
if "markdownify" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||
f" Install with: pip install markdownify"
|
||||
@@ -245,14 +247,7 @@ class ToolManager:
|
||||
# If there are missing tools, record warnings
|
||||
if missing_tools:
|
||||
for tool_name in missing_tools:
|
||||
if tool_name == "browser":
|
||||
logger.warning(
|
||||
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
||||
f" To enable browser tool, run:\n"
|
||||
f" pip install playwright\n"
|
||||
f" playwright install chromium"
|
||||
)
|
||||
elif tool_name == "google_search":
|
||||
if tool_name == "google_search":
|
||||
logger.warning(
|
||||
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
||||
f" Get API key from: https://serper.dev\n"
|
||||
@@ -450,17 +445,26 @@ class ToolManager:
|
||||
the others, and never raises out of the worker thread.
|
||||
"""
|
||||
try:
|
||||
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry
|
||||
from agent.tools.mcp.mcp_client import McpClient, McpClientRegistry, set_reload_callback
|
||||
from agent.tools.mcp.mcp_tool import McpTool
|
||||
|
||||
registry = McpClientRegistry()
|
||||
self._mcp_registry = registry
|
||||
# Let the OAuth web callback bring a server online once authorized.
|
||||
set_reload_callback(self.reload_mcp_server)
|
||||
|
||||
for cfg in mcp_servers_config:
|
||||
server_name = cfg.get("name", "<unnamed>")
|
||||
try:
|
||||
client = McpClient(cfg)
|
||||
if not client.initialize():
|
||||
if getattr(client, "needs_auth", False):
|
||||
self._mcp_status[server_name] = "needs_auth"
|
||||
logger.info(
|
||||
f"[MCP] Server '{server_name}' needs authorization — "
|
||||
f"waiting for the user to complete the OAuth flow"
|
||||
)
|
||||
else:
|
||||
self._mcp_status[server_name] = "failed"
|
||||
logger.warning(
|
||||
f"[MCP] Server '{server_name}' failed to initialize — skipping"
|
||||
@@ -502,6 +506,28 @@ class ToolManager:
|
||||
except Exception as e:
|
||||
logger.warning(f"[ToolManager] MCP background loader crashed: {e}")
|
||||
|
||||
def reload_mcp_server(self, server_name: str) -> None:
|
||||
"""Re-initialize a single MCP server (e.g. after OAuth authorization).
|
||||
|
||||
Tears down any existing client for the server and starts it again in
|
||||
the background, so a freshly-stored access token is picked up and the
|
||||
server's tools become available on the next message.
|
||||
"""
|
||||
with self._mcp_lock:
|
||||
cfg = self._mcp_active_configs.get(server_name)
|
||||
if not cfg:
|
||||
logger.warning(f"[MCP] reload requested for unknown server '{server_name}'")
|
||||
return
|
||||
logger.info(f"[MCP] Reloading server '{server_name}' after authorization")
|
||||
self._teardown_mcp_server(server_name)
|
||||
self._mcp_status[server_name] = "pending"
|
||||
threading.Thread(
|
||||
target=self._load_mcp_tools_async,
|
||||
args=([cfg],),
|
||||
daemon=True,
|
||||
name=f"mcp-reload-{server_name}",
|
||||
).start()
|
||||
|
||||
def list_mcp_status(self) -> dict:
|
||||
"""Return {server_name: status} snapshot for UI / debugging."""
|
||||
return dict(self._mcp_status)
|
||||
@@ -523,6 +549,16 @@ class ToolManager:
|
||||
if agent is None or not hasattr(agent, "tools"):
|
||||
return ([], [])
|
||||
|
||||
# Never re-inject MCP tools into a restricted Self-Evolution review agent.
|
||||
# The review agent is created with a deliberately reduced, workspace-guarded
|
||||
# toolset; silently re-adding configured MCP tools here would bypass that
|
||||
# policy boundary (see agent/evolution/executor.py). The flag may live on
|
||||
# the agent itself (Agent) or on the wrapping stream executor's .agent.
|
||||
if getattr(agent, "_evolution_restricted", False) or getattr(
|
||||
getattr(agent, "agent", None), "_evolution_restricted", False
|
||||
):
|
||||
return ([], [])
|
||||
|
||||
from agent.tools.mcp.mcp_tool import McpTool
|
||||
current = self._mcp_tool_instances
|
||||
registry_names = set(current.keys())
|
||||
@@ -564,6 +600,91 @@ class ToolManager:
|
||||
|
||||
return (sorted(added), sorted(removed))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# On-demand MCP tool retrieval support
|
||||
#
|
||||
# The vector index and the embedding provider are owned here (singleton,
|
||||
# process-wide, aligned with the MCP tool lifecycle). The context-aware
|
||||
# selection itself lives in agent.tools.mcp.tool_retrieval, driven by the
|
||||
# executor which is the only place that knows the conversation context.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def count_mcp_tools(self) -> int:
|
||||
"""Return the number of currently loaded MCP tools."""
|
||||
return len(self._mcp_tool_instances)
|
||||
|
||||
def get_mcp_tool_vectors(self) -> dict:
|
||||
"""Return ``{mcp_tool_name: vector}`` for currently loaded MCP tools.
|
||||
|
||||
Lazily embeds any MCP tools not yet in the cache (MCP servers load
|
||||
asynchronously, so tools may appear over time). Returns an empty dict
|
||||
when no embedding provider is available or embedding fails — the caller
|
||||
then falls back to full injection. Never raises.
|
||||
"""
|
||||
try:
|
||||
self._ensure_mcp_tool_vectors()
|
||||
except Exception as e:
|
||||
logger.debug(f"[ToolManager] MCP tool vector build skipped: {e}")
|
||||
return dict(self._mcp_tool_vectors)
|
||||
|
||||
def embed_query(self, text: str):
|
||||
"""Embed a retrieval query with the shared provider.
|
||||
|
||||
Returns the embedding vector, or None if no provider is available or
|
||||
the call fails (caller falls back to full injection). Never raises.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
provider = self._get_embedding_provider()
|
||||
if provider is None:
|
||||
return None
|
||||
try:
|
||||
return provider.embed_query(text)
|
||||
except Exception as e:
|
||||
logger.debug(f"[ToolManager] query embedding failed: {e}")
|
||||
return None
|
||||
|
||||
def _ensure_mcp_tool_vectors(self) -> None:
|
||||
"""Incrementally embed MCP tools that are not yet cached."""
|
||||
# Snapshot to avoid concurrent-mutation while the async loader runs.
|
||||
current = dict(self._mcp_tool_instances)
|
||||
missing = [name for name in current if name not in self._mcp_tool_vectors]
|
||||
if not missing:
|
||||
return
|
||||
|
||||
provider = self._get_embedding_provider()
|
||||
if provider is None:
|
||||
return
|
||||
|
||||
with self._mcp_vector_lock:
|
||||
# Re-check under lock: another thread may have filled these in.
|
||||
missing = [name for name in current if name not in self._mcp_tool_vectors]
|
||||
if not missing:
|
||||
return
|
||||
texts = [self._mcp_tool_embed_text(current[name]) for name in missing]
|
||||
vectors = provider.embed_batch(texts)
|
||||
for name, vec in zip(missing, vectors):
|
||||
self._mcp_tool_vectors[name] = vec
|
||||
|
||||
@staticmethod
|
||||
def _mcp_tool_embed_text(tool) -> str:
|
||||
"""Build the text that represents an MCP tool for embedding."""
|
||||
name = getattr(tool, "name", "") or ""
|
||||
description = getattr(tool, "description", "") or ""
|
||||
return f"{name}: {description}".strip()
|
||||
|
||||
def _get_embedding_provider(self):
|
||||
"""Lazily create and cache the shared embedding provider (or None)."""
|
||||
if not self._embedding_provider_initialized:
|
||||
try:
|
||||
from agent.memory.embedding import create_default_embedding_provider
|
||||
self._embedding_provider = create_default_embedding_provider()
|
||||
except Exception as e:
|
||||
logger.warning(f"[ToolManager] embedding provider init failed: {e}")
|
||||
self._embedding_provider = None
|
||||
self._embedding_provider_initialized = True
|
||||
return self._embedding_provider
|
||||
|
||||
def create_tool(self, name: str) -> BaseTool:
|
||||
"""
|
||||
Get a new instance of a tool by name.
|
||||
|
||||
@@ -15,6 +15,7 @@ from .diff import (
|
||||
normalize_to_lf,
|
||||
restore_line_endings,
|
||||
normalize_for_fuzzy_match,
|
||||
count_matches,
|
||||
fuzzy_find_text,
|
||||
generate_diff_string,
|
||||
FuzzyMatchResult
|
||||
@@ -39,6 +40,7 @@ __all__ = [
|
||||
'normalize_to_lf',
|
||||
'restore_line_endings',
|
||||
'normalize_for_fuzzy_match',
|
||||
'count_matches',
|
||||
'fuzzy_find_text',
|
||||
'generate_diff_string',
|
||||
'FuzzyMatchResult',
|
||||
|
||||
@@ -93,6 +93,40 @@ class FuzzyMatchResult:
|
||||
self.content_for_replacement = content_for_replacement
|
||||
|
||||
|
||||
def _build_fuzzy_pattern(old_text: str) -> Optional[str]:
|
||||
"""
|
||||
Build the whitespace-flexible regex used to locate ``old_text`` fuzzily.
|
||||
|
||||
Returns ``None`` when ``old_text`` has no non-whitespace content to match.
|
||||
This is the single source of truth for fuzzy matching, so that *finding* a
|
||||
match (:func:`fuzzy_find_text`) and *counting* occurrences
|
||||
(:func:`count_matches`) always use the exact same rules.
|
||||
"""
|
||||
stripped = old_text.strip('\n')
|
||||
if not stripped.strip():
|
||||
return None
|
||||
|
||||
source_lines = stripped.split('\n')
|
||||
line_patterns = []
|
||||
for i, line in enumerate(source_lines):
|
||||
tokens = line.split()
|
||||
if not tokens:
|
||||
line_patterns.append(r'[ \t]*')
|
||||
continue
|
||||
# Tolerate any run of blanks between tokens.
|
||||
core = r'[ \t]+'.join(re.escape(tok) for tok in tokens)
|
||||
# First-line leading whitespace is folded into the match only when
|
||||
# old_text itself was indented here; otherwise it stays OUTSIDE the
|
||||
# match so a no-indent old_text preserves (does not swallow and drop)
|
||||
# the file's existing indentation -- mirroring an exact substring
|
||||
# match. Inner lines always tolerate indentation: it sits inside the
|
||||
# matched region and is re-supplied by new_text.
|
||||
if i > 0 or line[:1] in (' ', '\t'):
|
||||
core = r'[ \t]*' + core
|
||||
line_patterns.append(core + r'[ \t]*')
|
||||
return '\n'.join(line_patterns)
|
||||
|
||||
|
||||
def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
||||
"""
|
||||
Find text in content, try exact match first, then fuzzy match
|
||||
@@ -111,24 +145,53 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
||||
content_for_replacement=content
|
||||
)
|
||||
|
||||
# Try fuzzy match
|
||||
fuzzy_content = normalize_for_fuzzy_match(content)
|
||||
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
|
||||
|
||||
index = fuzzy_content.find(fuzzy_old_text)
|
||||
if index != -1:
|
||||
# Fuzzy match successful, use normalized content for replacement
|
||||
# Fuzzy match: the exact substring was not found, most likely because the
|
||||
# whitespace differs (indentation, spaces around operators, trailing
|
||||
# spaces). Locate the region in the ORIGINAL content using a
|
||||
# whitespace-flexible pattern and return offsets into that original
|
||||
# content.
|
||||
#
|
||||
# This must NOT replace inside a whitespace-normalized copy of the file:
|
||||
# doing so previously returned the normalized copy as
|
||||
# content_for_replacement, which caused the whole file to be rewritten
|
||||
# with collapsed indentation (every untouched line got reformatted).
|
||||
pattern = _build_fuzzy_pattern(old_text)
|
||||
if pattern is not None:
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
return FuzzyMatchResult(
|
||||
found=True,
|
||||
index=index,
|
||||
match_length=len(fuzzy_old_text),
|
||||
content_for_replacement=fuzzy_content
|
||||
index=match.start(),
|
||||
match_length=match.end() - match.start(),
|
||||
content_for_replacement=content
|
||||
)
|
||||
|
||||
# Not found
|
||||
return FuzzyMatchResult(found=False)
|
||||
|
||||
|
||||
def count_matches(content: str, old_text: str) -> int:
|
||||
"""
|
||||
Count occurrences of ``old_text`` using the SAME strategy as
|
||||
:func:`fuzzy_find_text`: an exact substring when one is present, otherwise
|
||||
the whitespace-flexible fuzzy regex.
|
||||
|
||||
The edit tool's uniqueness guard must agree with the matcher that actually
|
||||
performs the replacement. Counting through a separate normalization pass
|
||||
(the previous approach) could disagree with the regex used to locate and
|
||||
replace, so both paths now share :func:`_build_fuzzy_pattern`.
|
||||
"""
|
||||
if not old_text:
|
||||
return 0
|
||||
# Mirror fuzzy_find_text: prefer exact matching when it applies.
|
||||
if content.find(old_text) != -1:
|
||||
return content.count(old_text)
|
||||
pattern = _build_fuzzy_pattern(old_text)
|
||||
if pattern is None:
|
||||
return 0
|
||||
return len(re.findall(pattern, content))
|
||||
|
||||
|
||||
def generate_diff_string(old_content: str, new_content: str) -> dict:
|
||||
"""
|
||||
Generate unified diff string
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
"""
|
||||
Shared SSRF guard utilities for tools that fetch model-supplied URLs.
|
||||
|
||||
A URL is only considered safe when it uses an http/https scheme, has a
|
||||
hostname, that hostname resolves, and every resolved address is a public
|
||||
(internet-routable) address. Loopback, private (RFC1918 / ULA), link-local
|
||||
(incl. the 169.254.169.254 cloud-metadata endpoint) and otherwise reserved
|
||||
addresses are rejected, for both IPv4 and IPv6.
|
||||
SSRF protection is OPT-IN and disabled by default, because legitimate use
|
||||
cases (local dev servers, LAN services, proxy fake-ip resolution) need to
|
||||
reach non-public addresses. Enable it by setting the config option
|
||||
``web_security_ssrf_protection: true`` (or env ``WEB_SECURITY_SSRF_PROTECTION``).
|
||||
|
||||
When enabled, a URL is only considered safe when it uses an http/https
|
||||
scheme, has a hostname, that hostname resolves, and every resolved address
|
||||
is a public (internet-routable) address. Loopback, private (RFC1918 / ULA),
|
||||
link-local (incl. the 169.254.169.254 cloud-metadata endpoint) and otherwise
|
||||
reserved addresses are rejected, for both IPv4 and IPv6.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _ssrf_protection_enabled() -> bool:
|
||||
"""Return True only when SSRF protection is explicitly turned on.
|
||||
|
||||
Disabled by default. Reads the env var first, then falls back to the
|
||||
global config; any failure to read config is treated as "disabled" so
|
||||
the guard never breaks normal fetching.
|
||||
"""
|
||||
env = os.getenv("WEB_SECURITY_SSRF_PROTECTION")
|
||||
if env is not None:
|
||||
return env.strip().lower() in ("1", "true", "yes", "on")
|
||||
try:
|
||||
from config import conf
|
||||
return bool(conf().get("web_security_ssrf_protection", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
|
||||
"""Return True if the address is not safe to connect to (non-public)."""
|
||||
return (
|
||||
@@ -28,8 +51,11 @@ def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
|
||||
def assert_public_ip(ip_str: str) -> None:
|
||||
"""Raise ValueError if the given literal IP is a non-public address.
|
||||
|
||||
Used to re-validate the concrete address a redirect resolved to.
|
||||
No-op when SSRF protection is disabled (the default). Used to re-validate
|
||||
the concrete address a redirect resolved to.
|
||||
"""
|
||||
if not _ssrf_protection_enabled():
|
||||
return
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
if _is_blocked_ip(ip):
|
||||
raise ValueError(
|
||||
@@ -41,13 +67,17 @@ def assert_public_ip(ip_str: str) -> None:
|
||||
def validate_url_safe(url: str) -> None:
|
||||
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
|
||||
|
||||
Resolves the hostname to its IP address(es) and blocks any that fall
|
||||
No-op when SSRF protection is disabled (the default). When enabled,
|
||||
resolves the hostname to its IP address(es) and blocks any that fall
|
||||
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
|
||||
schemes, or hosts that fail DNS resolution.
|
||||
|
||||
Raises:
|
||||
ValueError: if the URL targets a disallowed address.
|
||||
"""
|
||||
if not _ssrf_protection_enabled():
|
||||
return
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
||||
|
||||
@@ -53,7 +53,7 @@ _DISCOVERABLE_MODELS = [
|
||||
("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"),
|
||||
("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"),
|
||||
("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN37_PLUS, "DashScope"),
|
||||
("claude_api_key", const.CLAUDEAPI, const.CLAUDE_4_6_SONNET, "Claude"),
|
||||
("claude_api_key", const.CLAUDEAPI, const.CLAUDE_SONNET_5, "Claude"),
|
||||
("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"),
|
||||
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
|
||||
("zhipu_ai_api_key", const.ZHIPU_AI, const.GLM_4_7, "ZhipuAI"),
|
||||
@@ -162,7 +162,7 @@ class Vision(BaseTool):
|
||||
"Error: No model available for Vision.\n"
|
||||
"The main model does not support vision and no other API keys are configured.\n"
|
||||
"Options:\n"
|
||||
" 1. Switch to a multimodal model (e.g. ernie-4.5-turbo-vl, qwen3.7-plus, claude-sonnet-4-6, gemini-2.0-flash)\n"
|
||||
" 1. Switch to a multimodal model (e.g. claude-sonnet-5, qwen3.7-plus, gemini-2.0-flash, ernie-4.5-turbo-vl)\n"
|
||||
" 2. Configure OPENAI_API_KEY: env_config(action=\"set\", key=\"OPENAI_API_KEY\", value=\"your-key\")\n"
|
||||
" 3. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")"
|
||||
)
|
||||
|
||||
16
app.py
16
app.py
@@ -15,9 +15,9 @@ import threading
|
||||
|
||||
_channel_mgr = None
|
||||
|
||||
# Desktop mode: a lighter runtime for the packaged Electron client. The plugin
|
||||
# framework is still bundled (it's tiny and on the web channel's import path),
|
||||
# but we skip loading actual plugins and MCP tools to keep startup fast.
|
||||
# Desktop mode: a lighter runtime for the packaged Electron client. Plugins are
|
||||
# loaded in a background thread (so command plugins like cow_cli/godcmd work
|
||||
# without slowing startup), while MCP warmup is still skipped to keep it fast.
|
||||
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
|
||||
|
||||
|
||||
@@ -80,7 +80,15 @@ class ChannelManager:
|
||||
if self._primary_channel is None and channels:
|
||||
self._primary_channel = channels[0][1]
|
||||
|
||||
if first_start and not DESKTOP_MODE:
|
||||
if first_start:
|
||||
if DESKTOP_MODE:
|
||||
# Load plugins in the background so command plugins
|
||||
# (cow_cli / godcmd, e.g. /status, #help) work in the
|
||||
# desktop client, without blocking web-service readiness.
|
||||
threading.Thread(
|
||||
target=PluginManager().load_plugins, daemon=True
|
||||
).start()
|
||||
else:
|
||||
PluginManager().load_plugins()
|
||||
|
||||
# Cloud client is optional. It is only started when
|
||||
|
||||
@@ -5,7 +5,13 @@ Agent Bridge - Integrates Agent system with existing COW bridge
|
||||
import os
|
||||
from typing import Optional, List
|
||||
|
||||
from agent.protocol import Agent, LLMModel, LLMRequest, get_cancel_registry
|
||||
from agent.protocol import (
|
||||
Agent,
|
||||
LLMModel,
|
||||
LLMRequest,
|
||||
get_cancel_registry,
|
||||
get_steer_registry,
|
||||
)
|
||||
from bridge.agent_event_handler import AgentEventHandler
|
||||
from bridge.agent_initializer import AgentInitializer
|
||||
from bridge.bridge import Bridge
|
||||
@@ -360,6 +366,11 @@ class AgentBridge:
|
||||
|
||||
return agent
|
||||
|
||||
def steer_session(self, session_id: str, instruction: str):
|
||||
"""Inject an explicit instruction into one active session."""
|
||||
logger.info(f"[AgentBridge] steer new instruction: session={session_id}, content={instruction}")
|
||||
return get_steer_registry().submit(session_id, instruction)
|
||||
|
||||
def get_agent(self, session_id: str = None) -> Optional[Agent]:
|
||||
"""
|
||||
Get agent instance for the given session
|
||||
@@ -452,6 +463,8 @@ class AgentBridge:
|
||||
agent = None
|
||||
request_id = None
|
||||
cancel_event = None
|
||||
token_key = None
|
||||
steer_inbox = None
|
||||
try:
|
||||
# Extract session_id from context for user isolation
|
||||
if context:
|
||||
@@ -534,12 +547,15 @@ class AgentBridge:
|
||||
pass
|
||||
|
||||
try:
|
||||
if session_id:
|
||||
steer_inbox = get_steer_registry().register(session_id)
|
||||
# Use agent's run_stream method with event handler
|
||||
response = agent.run_stream(
|
||||
user_message=query,
|
||||
on_event=event_handler.handle_event,
|
||||
clear_history=clear_history,
|
||||
cancel_event=cancel_event,
|
||||
steer_inbox=steer_inbox,
|
||||
)
|
||||
finally:
|
||||
# Clear the mid-run flag so idle scans can review this session.
|
||||
@@ -562,6 +578,8 @@ class AgentBridge:
|
||||
registry.unregister(token_key)
|
||||
except Exception:
|
||||
pass
|
||||
if session_id and steer_inbox is not None:
|
||||
get_steer_registry().unregister(session_id, steer_inbox)
|
||||
|
||||
# Persist new messages generated during this run
|
||||
if session_id:
|
||||
@@ -643,6 +661,11 @@ class AgentBridge:
|
||||
get_cancel_registry().unregister(request_id or session_id)
|
||||
except Exception:
|
||||
pass
|
||||
if session_id and steer_inbox is not None:
|
||||
try:
|
||||
get_steer_registry().unregister(session_id, steer_inbox)
|
||||
except Exception:
|
||||
pass
|
||||
return Reply(ReplyType.ERROR, f"Agent error: {str(e)}")
|
||||
|
||||
def _schedule_mcp_hot_reload(self, agent):
|
||||
@@ -684,11 +707,21 @@ class AgentBridge:
|
||||
"""
|
||||
file_type = file_info.get("file_type", "file")
|
||||
file_path = file_info.get("path")
|
||||
# Remote URLs are passed through as-is; local paths get a file:// prefix
|
||||
# so the channel can read them from disk.
|
||||
remote_url = file_info.get("url", "")
|
||||
is_remote = bool(remote_url) and remote_url.lower().startswith(("http://", "https://"))
|
||||
|
||||
def _to_channel_url(p: str) -> str:
|
||||
if is_remote:
|
||||
return remote_url
|
||||
if p and p.lower().startswith(("http://", "https://")):
|
||||
return p
|
||||
return f"file://{p}"
|
||||
|
||||
# For images, use IMAGE_URL type (channel will handle upload)
|
||||
if file_type == "image":
|
||||
# Convert local path to file:// URL for channel processing
|
||||
file_url = f"file://{file_path}"
|
||||
file_url = _to_channel_url(file_path)
|
||||
logger.info(f"[AgentBridge] Sending image: {file_url}")
|
||||
reply = Reply(ReplyType.IMAGE_URL, file_url)
|
||||
# Attach text message if present (for channels that support text+image)
|
||||
@@ -698,7 +731,7 @@ class AgentBridge:
|
||||
|
||||
# For all file types (document, video, audio), use FILE type
|
||||
if file_type in ["document", "video", "audio"]:
|
||||
file_url = f"file://{file_path}"
|
||||
file_url = _to_channel_url(file_path)
|
||||
logger.info(f"[AgentBridge] Sending {file_type}: {file_url}")
|
||||
reply = Reply(ReplyType.FILE, file_url)
|
||||
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
|
||||
@@ -708,7 +741,7 @@ class AgentBridge:
|
||||
return reply
|
||||
|
||||
# For all other file types (tar.gz, zip, etc.), also use FILE type
|
||||
file_url = f"file://{file_path}"
|
||||
file_url = _to_channel_url(file_path)
|
||||
logger.info(f"[AgentBridge] Sending generic file: {file_url}")
|
||||
reply = Reply(ReplyType.FILE, file_url)
|
||||
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
|
||||
|
||||
@@ -17,10 +17,6 @@ from common.utils import expand_path
|
||||
# Module-level lock to serialize scheduler init across concurrent sessions
|
||||
_scheduler_init_lock = threading.Lock()
|
||||
|
||||
# Track whether the embedding model log has been printed in this process,
|
||||
# so we avoid spamming it once per session.
|
||||
_embedding_logged: bool = False
|
||||
|
||||
|
||||
class AgentInitializer:
|
||||
"""
|
||||
@@ -306,223 +302,15 @@ class AgentInitializer:
|
||||
"""
|
||||
Initialize the embedding provider for memory.
|
||||
|
||||
Two paths:
|
||||
Delegates to the shared factory so agent init, knowledge sync and
|
||||
index rebuild all select the same provider:
|
||||
A. Default (no `embedding_provider` in config.json):
|
||||
Auto-init OpenAI -> LinkAI fallback. Existing 1536-dim indices
|
||||
keep working.
|
||||
Auto-init OpenAI -> LinkAI fallback.
|
||||
B. Explicit (`embedding_provider` is set):
|
||||
Initialize the requested vendor with unified dim (default 1024).
|
||||
If the index was built with a different dim, vector search will
|
||||
quietly return no results (cosine returns 0) and keyword search
|
||||
takes over until the user runs /memory rebuild-index.
|
||||
Initialize the requested vendor.
|
||||
"""
|
||||
from agent.memory import create_embedding_provider
|
||||
from config import conf
|
||||
|
||||
explicit_provider = (conf().get("embedding_provider") or "").strip().lower()
|
||||
|
||||
if not explicit_provider:
|
||||
return self._init_embedding_provider_legacy(session_id=session_id)
|
||||
|
||||
return self._init_embedding_provider_explicit(
|
||||
memory_config, explicit_provider, session_id=session_id,
|
||||
)
|
||||
|
||||
def _init_embedding_provider_legacy(self, session_id: Optional[str] = None):
|
||||
"""Legacy auto-init path: OpenAI -> LinkAI. Preserved verbatim for compat."""
|
||||
from agent.memory import create_embedding_provider
|
||||
from config import conf
|
||||
|
||||
embedding_provider = None
|
||||
embedding_model = None
|
||||
|
||||
openai_api_key = conf().get("open_ai_api_key", "")
|
||||
openai_api_base = conf().get("open_ai_api_base", "")
|
||||
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||
try:
|
||||
model = "text-embedding-3-small"
|
||||
embedding_provider = create_embedding_provider(
|
||||
provider="openai",
|
||||
model=model,
|
||||
api_key=openai_api_key,
|
||||
api_base=openai_api_base or "https://api.openai.com/v1"
|
||||
)
|
||||
embedding_model = f"openai/{model}"
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentInitializer] OpenAI embedding failed: {e}")
|
||||
|
||||
if embedding_provider is None:
|
||||
linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "")
|
||||
linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
|
||||
if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||
try:
|
||||
model = "text-embedding-3-small"
|
||||
embedding_provider = create_embedding_provider(
|
||||
provider="linkai",
|
||||
model=model,
|
||||
api_key=linkai_api_key,
|
||||
api_base=f"{linkai_api_base}/v1"
|
||||
)
|
||||
embedding_model = f"linkai/{model}"
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentInitializer] LinkAI embedding failed: {e}")
|
||||
|
||||
if embedding_provider is not None and embedding_model:
|
||||
global _embedding_logged
|
||||
if not _embedding_logged:
|
||||
logger.info(
|
||||
f"[AgentInitializer] Embedding model in use: {embedding_model} "
|
||||
f"(dim={embedding_provider.dimensions})"
|
||||
)
|
||||
_embedding_logged = True
|
||||
|
||||
return embedding_provider
|
||||
|
||||
def _init_embedding_provider_explicit(
|
||||
self,
|
||||
memory_config,
|
||||
provider_key: str,
|
||||
session_id: Optional[str] = None,
|
||||
):
|
||||
"""Explicit-provider path: build the configured vendor.
|
||||
|
||||
If the index was built with a different dim, vector search will
|
||||
silently return no results (cosine returns 0 for mismatched dims)
|
||||
and keyword search takes over. Users switch vendors by running
|
||||
/memory rebuild-index — see docs.
|
||||
"""
|
||||
from agent.memory import create_embedding_provider
|
||||
from agent.memory.embedding import EMBEDDING_VENDORS
|
||||
from config import conf
|
||||
|
||||
# Custom providers ("custom:<id>") resolve credentials
|
||||
# from the custom_providers list.
|
||||
resolved_provider_key = provider_key
|
||||
if provider_key.startswith("custom:"):
|
||||
resolved_provider_key = "custom"
|
||||
|
||||
meta = EMBEDDING_VENDORS.get(resolved_provider_key)
|
||||
if meta is None:
|
||||
logger.error(
|
||||
f"[AgentInitializer] Unknown embedding_provider '{provider_key}'. "
|
||||
f"Supported: {sorted(EMBEDDING_VENDORS.keys())}. "
|
||||
f"Memory will run in keyword-only mode."
|
||||
)
|
||||
return None
|
||||
|
||||
api_key = self._resolve_embedding_api_key(provider_key)
|
||||
api_base = self._resolve_embedding_api_base(provider_key, meta["default_base_url"])
|
||||
|
||||
if not api_key:
|
||||
logger.error(
|
||||
f"[AgentInitializer] embedding_provider='{provider_key}' is set but its "
|
||||
f"API key is missing. Memory will run in keyword-only mode."
|
||||
)
|
||||
return None
|
||||
|
||||
model = (conf().get("embedding_model") or "").strip()
|
||||
# Custom providers without a model fall back to the provider's default.
|
||||
if not model and resolved_provider_key == "custom":
|
||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||
_, custom_id = parse_custom_bot_type(provider_key)
|
||||
if custom_id:
|
||||
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||
if entry and entry.get("model"):
|
||||
model = entry["model"]
|
||||
if not model and resolved_provider_key != "custom":
|
||||
model = meta["default_model"]
|
||||
try:
|
||||
cfg_dim = int(conf().get("embedding_dimensions") or 0)
|
||||
except (TypeError, ValueError):
|
||||
cfg_dim = 0
|
||||
dim = cfg_dim if cfg_dim > 0 else meta["default_dimensions"]
|
||||
|
||||
try:
|
||||
provider = create_embedding_provider(
|
||||
provider=resolved_provider_key,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
dimensions=dim,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[AgentInitializer] Failed to init embedding provider "
|
||||
f"'{provider_key}/{model}': {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
global _embedding_logged
|
||||
if not _embedding_logged:
|
||||
logger.info(
|
||||
f"[AgentInitializer] Embedding model in use: "
|
||||
f"{provider_key}/{model} (dim={provider.dimensions})"
|
||||
)
|
||||
_embedding_logged = True
|
||||
return provider
|
||||
|
||||
@staticmethod
|
||||
def _resolve_embedding_api_key(provider_key: str) -> str:
|
||||
"""Pick the API key for an explicit embedding provider from config."""
|
||||
from config import conf
|
||||
|
||||
# Custom providers ("custom:<id>") resolve from the custom_providers list.
|
||||
if provider_key.startswith("custom:"):
|
||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||
_, custom_id = parse_custom_bot_type(provider_key)
|
||||
if custom_id:
|
||||
providers = get_custom_providers()
|
||||
entry = _find_provider_by_id(providers, custom_id)
|
||||
if entry:
|
||||
return entry.get("api_key", "")
|
||||
return ""
|
||||
|
||||
key_map = {
|
||||
"openai": "open_ai_api_key",
|
||||
"linkai": "linkai_api_key",
|
||||
"dashscope": "dashscope_api_key",
|
||||
"doubao": "ark_api_key",
|
||||
"zhipu": "zhipu_ai_api_key",
|
||||
}
|
||||
field = key_map.get(provider_key)
|
||||
if not field:
|
||||
return ""
|
||||
value = conf().get(field, "") or ""
|
||||
if value in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||
return ""
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _resolve_embedding_api_base(provider_key: str, default_base: str) -> str:
|
||||
"""Pick the API base for an explicit embedding provider from config."""
|
||||
from config import conf
|
||||
|
||||
# Custom providers ("custom:<id>") resolve from the custom_providers list.
|
||||
if provider_key.startswith("custom:"):
|
||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||
_, custom_id = parse_custom_bot_type(provider_key)
|
||||
if custom_id:
|
||||
providers = get_custom_providers()
|
||||
entry = _find_provider_by_id(providers, custom_id)
|
||||
if entry and entry.get("api_base"):
|
||||
return entry["api_base"]
|
||||
return default_base
|
||||
|
||||
base_map = {
|
||||
"openai": "open_ai_api_base",
|
||||
"linkai": "linkai_api_base",
|
||||
"doubao": "ark_base_url",
|
||||
"zhipu": "zhipu_ai_api_base",
|
||||
}
|
||||
field = base_map.get(provider_key)
|
||||
if not field:
|
||||
return default_base
|
||||
value = (conf().get(field) or "").strip()
|
||||
if not value:
|
||||
return default_base
|
||||
if provider_key == "linkai" and not value.rstrip("/").endswith("/v1"):
|
||||
return f"{value.rstrip('/')}/v1"
|
||||
return value
|
||||
from agent.memory import create_default_embedding_provider
|
||||
return create_default_embedding_provider()
|
||||
|
||||
def _sync_memory(self, memory_manager, session_id: Optional[str] = None):
|
||||
"""Sync memory database"""
|
||||
|
||||
@@ -453,6 +453,10 @@ class ChatChannel(Channel):
|
||||
if stripped in self._BYPASS_QUEUE_COMMANDS:
|
||||
self._handle_cancel_command(context, session_id)
|
||||
return
|
||||
if re.match(r"^/steer(?:\s|$)", stripped):
|
||||
instruction = context.content.strip()[len("/steer"):].strip()
|
||||
self._handle_steer_command(context, session_id, instruction)
|
||||
return
|
||||
|
||||
with self.lock:
|
||||
if session_id not in self.sessions:
|
||||
@@ -488,6 +492,49 @@ class ChatChannel(Channel):
|
||||
except Exception as e:
|
||||
logger.warning(f"[chat_channel] /cancel fast-path failed: {e}")
|
||||
|
||||
def _handle_steer_command(
|
||||
self,
|
||||
context: Context,
|
||||
session_id: str,
|
||||
instruction: str,
|
||||
) -> None:
|
||||
"""Send explicit guidance to the active run without queueing it."""
|
||||
try:
|
||||
from agent.protocol import SteerStatus
|
||||
from bridge.bridge import Bridge
|
||||
|
||||
result = Bridge().get_agent_bridge().steer_session(session_id, instruction)
|
||||
messages = {
|
||||
SteerStatus.ACCEPTED: _t(
|
||||
"↪️ 已引导当前任务。", "↪️ Active task redirected."
|
||||
),
|
||||
SteerStatus.INACTIVE: _t(
|
||||
"当前没有可引导的任务。", "No active task to steer."
|
||||
),
|
||||
SteerStatus.CLOSING: _t(
|
||||
"当前任务已结束,无法再引导。", "The active task is already finishing."
|
||||
),
|
||||
SteerStatus.AMBIGUOUS: _t(
|
||||
"当前会话有多个任务在运行,无法确定引导目标。",
|
||||
"Multiple tasks are active in this session; the steering target is ambiguous.",
|
||||
),
|
||||
SteerStatus.FULL: _t(
|
||||
"引导指令过多,请等待当前任务处理后再试。",
|
||||
"Too many steering updates are pending; try again after the agent processes them.",
|
||||
),
|
||||
SteerStatus.INVALID: _t(
|
||||
"用法:/steer <引导指令>", "Usage: /steer <instruction>"
|
||||
),
|
||||
}
|
||||
text = messages[result.status]
|
||||
logger.info(
|
||||
f"[chat_channel] /steer fast-path: session={session_id}, "
|
||||
f"status={result.status.value}"
|
||||
)
|
||||
self._send_reply(context, Reply(ReplyType.TEXT, text))
|
||||
except Exception as e:
|
||||
logger.warning(f"[chat_channel] /steer fast-path failed: {e}")
|
||||
|
||||
# 消费者函数,单独线程,用于从消息队列中取出消息并处理
|
||||
def consume(self):
|
||||
while True:
|
||||
@@ -515,6 +562,42 @@ class ChatChannel(Channel):
|
||||
semaphore.release()
|
||||
time.sleep(0.2)
|
||||
|
||||
def cancel_message(self, session_id: str, message_id: str):
|
||||
"""Cancel one channel message without disturbing later queued work.
|
||||
|
||||
Queued contexts are matched by their original channel message ID. An
|
||||
in-flight agent run is cancelled through the per-request token that the
|
||||
channel placed on the context before dispatch.
|
||||
"""
|
||||
removed = 0
|
||||
with self.lock:
|
||||
session = self.sessions.get(session_id)
|
||||
if session is not None:
|
||||
context_queue = session[0]
|
||||
kept = []
|
||||
for _ in range(context_queue.qsize()):
|
||||
context = context_queue.get_nowait()
|
||||
context_queue.task_done()
|
||||
message = context.get("msg") if context is not None else None
|
||||
if getattr(message, "msg_id", None) == message_id:
|
||||
removed += 1
|
||||
else:
|
||||
kept.append(context)
|
||||
for context in kept:
|
||||
context_queue.put(context)
|
||||
|
||||
from agent.protocol import get_cancel_registry
|
||||
|
||||
active = get_cancel_registry().cancel_request(message_id)
|
||||
logger.info(
|
||||
"[chat_channel] message recall: session=%s, message=%s, queued=%s, active=%s",
|
||||
session_id,
|
||||
message_id,
|
||||
removed,
|
||||
active,
|
||||
)
|
||||
return removed, active
|
||||
|
||||
# 取消session_id对应的所有任务,只能取消排队的消息和已提交线程池但未执行的任务
|
||||
def cancel_session(self, session_id):
|
||||
with self.lock:
|
||||
|
||||
@@ -63,7 +63,7 @@ python3 app.py
|
||||
2. 进入应用详情 -> 事件订阅
|
||||
3. 选择 **将事件发送至开发者服务器**
|
||||
4. 填写请求地址: `http://your-domain:9891/`
|
||||
5. 添加事件: `im.message.receive_v1` (接收消息v2.0)
|
||||
5. 添加事件: `im.message.receive_v1` (接收消息v2.0) 和 `im.message.recalled_v1` (消息撤回)
|
||||
6. 保存配置
|
||||
|
||||
### 4. 注意事项
|
||||
@@ -101,7 +101,7 @@ python3 app.py
|
||||
1. 登录[飞书开放平台](https://open.feishu.cn/)
|
||||
2. 进入应用详情 -> 事件订阅
|
||||
3. 选择 **使用长连接接收事件**
|
||||
4. 添加事件: `im.message.receive_v1` (接收消息v2.0)
|
||||
4. 添加事件: `im.message.receive_v1` (接收消息v2.0) 和 `im.message.recalled_v1` (消息撤回)
|
||||
5. 保存配置
|
||||
|
||||
### 5. 注意事项
|
||||
@@ -168,7 +168,7 @@ Address already in use
|
||||
### 收不到消息
|
||||
|
||||
1. 检查飞书应用的事件订阅配置
|
||||
2. 确认已添加 `im.message.receive_v1` 事件
|
||||
2. 确认已添加 `im.message.receive_v1` 和 `im.message.recalled_v1` 事件
|
||||
3. 检查应用权限: 需要 `im:message` 权限
|
||||
4. 查看日志中的错误信息
|
||||
|
||||
|
||||
@@ -28,6 +28,17 @@ from bridge.context import ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from channel.chat_channel import ChatChannel, check_prefix
|
||||
from channel.feishu.feishu_message import FeishuMessage
|
||||
from channel.feishu.feishu_static_card import (
|
||||
build_text_delivery,
|
||||
resolve_markdown_images,
|
||||
upload_public_image_to_feishu,
|
||||
)
|
||||
from channel.feishu.feishu_progress_card import FeishuProgressState
|
||||
from channel.feishu.feishu_scheduler_card import (
|
||||
build_scheduler_card,
|
||||
handle_scheduler_action,
|
||||
tasks_for_receivers,
|
||||
)
|
||||
from common import utils
|
||||
from common.expired_dict import ExpiredDict
|
||||
from common.log import logger
|
||||
@@ -240,6 +251,8 @@ class FeiShuChanel(ChatChannel):
|
||||
super().__init__()
|
||||
# 历史消息id暂存,用于幂等控制
|
||||
self.receivedMsgs = ExpiredDict(60 * 60 * 7.1)
|
||||
# Route recall events back to the session that accepted the message.
|
||||
self._message_sessions = ExpiredDict(60 * 60 * 7.1)
|
||||
self._http_server = None
|
||||
self._ws_client = None
|
||||
self._ws_thread = None
|
||||
@@ -350,6 +363,10 @@ class FeiShuChanel(ChatChannel):
|
||||
def _startup_websocket(self):
|
||||
"""启动长连接接收事件(websocket模式)"""
|
||||
_ensure_lark_imported()
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import (
|
||||
P2CardActionTriggerResponse,
|
||||
)
|
||||
|
||||
logger.debug("[FeiShu] Starting in websocket mode...")
|
||||
|
||||
# 创建事件处理器
|
||||
@@ -372,10 +389,40 @@ class FeiShuChanel(ChatChannel):
|
||||
except Exception as e:
|
||||
logger.error(f"[FeiShu] websocket handle message error: {e}", exc_info=True)
|
||||
|
||||
def handle_message_recalled_event(
|
||||
data: lark.im.v1.P2ImMessageRecalledV1,
|
||||
) -> None:
|
||||
"""Cancel only the task created by the recalled Feishu message."""
|
||||
try:
|
||||
logger.info("[FeiShu] websocket received message recall event")
|
||||
event_dict = json.loads(lark.JSON.marshal(data))
|
||||
self._handle_message_recalled_event(event_dict.get("event", {}))
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[FeiShu] websocket handle message recall error: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def handle_card_action(data):
|
||||
"""Handle Card 2.0 button callbacks and update the card in place."""
|
||||
try:
|
||||
event_dict = json.loads(lark.JSON.marshal(data))
|
||||
response = self._handle_card_action_event(event_dict.get("event", {}))
|
||||
return P2CardActionTriggerResponse(response)
|
||||
except Exception as e:
|
||||
logger.error(f"[FeiShu] websocket handle card action error: {e}", exc_info=True)
|
||||
return P2CardActionTriggerResponse(
|
||||
{"toast": {"type": "error", "content": "Task update failed"}}
|
||||
)
|
||||
|
||||
# 构建事件分发器
|
||||
event_handler = lark.EventDispatcherHandler.builder("", "") \
|
||||
.register_p2_im_message_receive_v1(handle_message_event) \
|
||||
event_handler = (
|
||||
lark.EventDispatcherHandler.builder("", "")
|
||||
.register_p2_im_message_receive_v1(handle_message_event)
|
||||
.register_p2_im_message_recalled_v1(handle_message_recalled_event)
|
||||
.register_p2_card_action_trigger(handle_card_action)
|
||||
.build()
|
||||
)
|
||||
|
||||
def start_client_with_retry():
|
||||
"""Run ws client in this thread with its own event loop to avoid conflicts."""
|
||||
@@ -470,6 +517,120 @@ class FeiShuChanel(ChatChannel):
|
||||
# so reaching here means the bot was indeed mentioned.
|
||||
return True
|
||||
|
||||
def _get_scheduler_task_store(self):
|
||||
"""Reuse the live scheduler store, with a path-compatible fallback."""
|
||||
from agent.tools.scheduler.integration import get_task_store
|
||||
|
||||
task_store = get_task_store()
|
||||
if task_store is not None:
|
||||
return task_store
|
||||
|
||||
from agent.tools.scheduler.task_store import TaskStore
|
||||
|
||||
workspace_root = utils.expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
return TaskStore(os.path.join(workspace_root, "scheduler", "tasks.json"))
|
||||
|
||||
def _send_scheduler_card(self, feishu_msg, is_group: bool, receive_id_type: str) -> bool:
|
||||
"""Reply to ``/tasks`` with tasks scoped to the current chat."""
|
||||
task_store = self._get_scheduler_task_store()
|
||||
receivers = {feishu_msg.other_user_id}
|
||||
tasks = tasks_for_receivers(task_store.list_tasks(), receivers)
|
||||
card = build_scheduler_card(tasks)
|
||||
headers = {
|
||||
"Authorization": "Bearer " + feishu_msg.access_token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
content = json.dumps(card, ensure_ascii=False)
|
||||
|
||||
if is_group and feishu_msg.msg_id:
|
||||
url = (
|
||||
"https://open.feishu.cn/open-apis/im/v1/messages/"
|
||||
f"{feishu_msg.msg_id}/reply"
|
||||
)
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
json={"msg_type": "interactive", "content": content},
|
||||
timeout=(5, 10),
|
||||
)
|
||||
else:
|
||||
url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=headers,
|
||||
params={"receive_id_type": receive_id_type},
|
||||
json={
|
||||
"receive_id": feishu_msg.other_user_id,
|
||||
"msg_type": "interactive",
|
||||
"content": content,
|
||||
},
|
||||
timeout=(5, 10),
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("code") == 0:
|
||||
logger.info("[FeiShu] scheduler card sent")
|
||||
return True
|
||||
logger.error(
|
||||
"[FeiShu] scheduler card failed, "
|
||||
f"code={result.get('code')}, msg={result.get('msg')}"
|
||||
)
|
||||
return False
|
||||
|
||||
def _handle_card_action_event(self, event: dict) -> dict:
|
||||
"""Apply a scheduler card action within its chat/operator ownership scope."""
|
||||
action = event.get("action") or {}
|
||||
value = action.get("value") or {}
|
||||
if value.get("cowagent") != "scheduler":
|
||||
return {}
|
||||
|
||||
context = event.get("context") or {}
|
||||
operator = event.get("operator") or {}
|
||||
callback_receivers = {
|
||||
receiver
|
||||
for receiver in (context.get("open_chat_id"), operator.get("open_id"))
|
||||
if receiver
|
||||
}
|
||||
target_receiver = value.get("receiver")
|
||||
allowed_receivers = (
|
||||
{target_receiver}
|
||||
if target_receiver and target_receiver in callback_receivers
|
||||
else set()
|
||||
)
|
||||
response = handle_scheduler_action(
|
||||
value,
|
||||
self._get_scheduler_task_store(),
|
||||
allowed_receivers,
|
||||
)
|
||||
logger.info(
|
||||
"[FeiShu] scheduler card action handled, "
|
||||
f"action={value.get('action')}, task_id={value.get('task_id')}"
|
||||
)
|
||||
return response
|
||||
|
||||
def _handle_message_recalled_event(self, event: dict):
|
||||
"""Cancel one recalled message while preserving later queued messages."""
|
||||
message_id = event.get("message_id")
|
||||
if not message_id:
|
||||
logger.warning(f"[FeiShu] invalid message recall event: {event}")
|
||||
return 0, False
|
||||
|
||||
session_id = self._message_sessions.get(message_id)
|
||||
if not session_id:
|
||||
logger.info(
|
||||
f"[FeiShu] ignored recall for unknown message, message_id={message_id}"
|
||||
)
|
||||
return 0, False
|
||||
|
||||
result = self.cancel_message(session_id, message_id)
|
||||
self._message_sessions.pop(message_id, None)
|
||||
logger.info(
|
||||
"[FeiShu] recalled message cancelled, "
|
||||
f"message_id={message_id}, session_id={session_id}, "
|
||||
f"queued={result[0]}, active={result[1]}"
|
||||
)
|
||||
return result
|
||||
|
||||
def _handle_message_event(self, event: dict):
|
||||
"""
|
||||
处理消息事件的核心逻辑
|
||||
@@ -521,6 +682,11 @@ class FeiShuChanel(ChatChannel):
|
||||
if not feishu_msg:
|
||||
return
|
||||
|
||||
if feishu_msg.ctype == ContextType.TEXT and feishu_msg.content.strip().lower() == "/tasks":
|
||||
if not self._send_scheduler_card(feishu_msg, is_group, receive_id_type):
|
||||
logger.warning("[FeiShu] /tasks card delivery failed")
|
||||
return
|
||||
|
||||
# 处理文件缓存逻辑
|
||||
from channel.file_cache import get_file_cache
|
||||
file_cache = get_file_cache()
|
||||
@@ -591,13 +757,17 @@ class FeiShuChanel(ChatChannel):
|
||||
|
||||
context = self._compose_context(
|
||||
feishu_msg.ctype,
|
||||
feishu_msg.content,
|
||||
feishu_msg.content_with_quote(),
|
||||
isgroup=is_group,
|
||||
msg=feishu_msg,
|
||||
receive_id_type=receive_id_type,
|
||||
no_need_at=True
|
||||
)
|
||||
if context:
|
||||
# Feishu recall events only include message_id/chat_id. Keep the
|
||||
# accepted route and use message_id as the agent cancellation key.
|
||||
context["request_id"] = msg_id
|
||||
self._message_sessions[msg_id] = context["session_id"]
|
||||
# 流式回复模式:向 context 注入 on_event 回调,agent 每产出一段文字时会调用它。
|
||||
# 回调内部先发送一条占位消息获取 message_id,之后通过 PATCH 接口原地更新内容,
|
||||
# 实现打字机效果。回调结束时设置 context["feishu_streamed"]=True,
|
||||
@@ -628,7 +798,18 @@ class FeiShuChanel(ChatChannel):
|
||||
logger.debug(f"[FeiShu] sending reply, type={context.type}, content={reply.content[:100]}...")
|
||||
reply_content = reply.content
|
||||
content_key = "text"
|
||||
if reply.type == ReplyType.IMAGE_URL:
|
||||
prepared_content_json = None
|
||||
if reply.type == ReplyType.TEXT:
|
||||
# Render Markdown text replies as Feishu cards; falls back to plain text automatically.
|
||||
delivery_text = resolve_markdown_images(
|
||||
reply.content,
|
||||
lambda url: upload_public_image_to_feishu(
|
||||
url,
|
||||
access_token,
|
||||
),
|
||||
)
|
||||
msg_type, prepared_content_json = build_text_delivery(delivery_text)
|
||||
elif reply.type == ReplyType.IMAGE_URL:
|
||||
# 图片上传
|
||||
reply_content = self._upload_image_url(reply.content, access_token)
|
||||
if not reply_content:
|
||||
@@ -690,7 +871,11 @@ class FeiShuChanel(ChatChannel):
|
||||
can_reply = is_group and msg and hasattr(msg, 'msg_id') and msg.msg_id
|
||||
|
||||
# Build content JSON
|
||||
content_json = json.dumps(reply_content, ensure_ascii=False) if content_key is None else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
||||
content_json = prepared_content_json or (
|
||||
json.dumps(reply_content, ensure_ascii=False)
|
||||
if content_key is None
|
||||
else json.dumps({content_key: reply_content}, ensure_ascii=False)
|
||||
)
|
||||
logger.debug(f"[FeiShu] Sending message: msg_type={msg_type}, content={content_json[:200]}")
|
||||
|
||||
if can_reply:
|
||||
@@ -714,10 +899,393 @@ class FeiShuChanel(ChatChannel):
|
||||
res = res.json()
|
||||
if res.get("code") == 0:
|
||||
logger.info(f"[FeiShu] send message success")
|
||||
elif msg_type == "interactive" and reply.type == ReplyType.TEXT:
|
||||
logger.warning(
|
||||
"[FeiShu] Markdown card failed, falling back to text, "
|
||||
f"code={res.get('code')}, msg={res.get('msg')}"
|
||||
)
|
||||
fallback_data = {
|
||||
"msg_type": "text",
|
||||
"content": json.dumps({"text": reply.content}, ensure_ascii=False),
|
||||
}
|
||||
if can_reply:
|
||||
fallback_res = requests.post(
|
||||
url=url,
|
||||
headers=headers,
|
||||
json=fallback_data,
|
||||
timeout=(5, 10),
|
||||
)
|
||||
else:
|
||||
fallback_data["receive_id"] = context.get("receiver")
|
||||
fallback_res = requests.post(
|
||||
url=url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
json=fallback_data,
|
||||
timeout=(5, 10),
|
||||
)
|
||||
fallback_body = fallback_res.json()
|
||||
if fallback_body.get("code") == 0:
|
||||
logger.info("[FeiShu] text fallback sent successfully")
|
||||
else:
|
||||
logger.error(
|
||||
"[FeiShu] text fallback failed, "
|
||||
f"code={fallback_body.get('code')}, msg={fallback_body.get('msg')}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
||||
|
||||
def _make_feishu_stream_callback(self, context, access_token):
|
||||
"""Route to detailed or plain streaming callback based on config.
|
||||
|
||||
feishu_detailed_card 默认开启:普通对话使用带状态头、
|
||||
思考/工具面板与耗时的详细卡片。关闭后回退到原有的打字机文本卡片
|
||||
(_make_feishu_stream_callback_plain)。
|
||||
"""
|
||||
if conf().get("feishu_detailed_card", True):
|
||||
return self._make_feishu_stream_callback_progress(context, access_token)
|
||||
return self._make_feishu_stream_callback_plain(context, access_token)
|
||||
|
||||
def _make_feishu_stream_callback_progress(self, context, access_token):
|
||||
"""
|
||||
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||
|
||||
流程:
|
||||
1. agent_start → POST /cardkit/v1/cards 创建带 streaming_mode 的状态卡片,
|
||||
随后用 POST /im/v1/messages(或 reply)以 card_id 把卡片发出去
|
||||
2. 后续 message_update → PUT /cardkit/v1/cards/{id}/elements/{eid}/content
|
||||
传入"当前轮"的全量文本,飞书平台自动计算增量并以打字机效果上屏
|
||||
(流式模式下不受 10 QPS 限制)
|
||||
3. message_end(本轮触发工具调用)→ 原地刷新 Reasoning / Tools 面板,
|
||||
后续 turn 继续复用同一张卡片
|
||||
4. agent_end → 用 final_response、最终状态和耗时整卡更新,关闭 streaming_mode,
|
||||
标记 context["feishu_streamed"]=True 让 chat_channel 跳过普通 send()
|
||||
|
||||
前提条件:
|
||||
- 机器人已开通 cardkit:card:write 权限
|
||||
- 飞书客户端 7.20+
|
||||
|
||||
失败降级:
|
||||
- 创建卡片实体失败(缺权限、网络等)→ 不设置 feishu_streamed 标记,让 chat_channel
|
||||
走普通文本回复路径,用户收到完整回复但无打字机效果,并打 warning 日志
|
||||
"""
|
||||
# 共享状态(受 lock 保护)。一个 agent run 始终复用同一张卡片;
|
||||
# reasoning、tools、最终正文和状态头均由 progress_state 统一渲染。
|
||||
progress_state = FeishuProgressState()
|
||||
card_id = [None]
|
||||
message_id = [None]
|
||||
# 占位发送是同步进行的,但用一个 in-flight 标记防止并发的多条 message_update
|
||||
# 事件各自触发一次创建+发送,导致发出多张卡片。
|
||||
init_in_flight = [False]
|
||||
# 一旦初始化失败就长期标记为 disabled,本次回复不再尝试任何流式调用
|
||||
disabled = [False]
|
||||
lock = threading.Lock()
|
||||
|
||||
# ---- 异步推送队列 ----------------------------------------------------
|
||||
# 同步 requests.put 单次 100~300ms,会阻塞 LLM stream 线程读下一个 chunk。
|
||||
# 把推送丢给独立 worker 线程消费 queue,回调本身只做内存追加,立即返回。
|
||||
# 队列里只放"最新累积文本"的快照;worker 用 deduplication 避免重复推同一个
|
||||
# 内容(高频 chunk 场景下队列会堆积,只推最后一个就够了)。
|
||||
import queue as _queue
|
||||
push_queue: "_queue.Queue[str | None]" = _queue.Queue()
|
||||
|
||||
def _push_worker():
|
||||
while True:
|
||||
snapshot = push_queue.get()
|
||||
if snapshot is None:
|
||||
push_queue.task_done()
|
||||
return
|
||||
# 合并队列中已堆积的快照:只推最后一个,省 PUT 次数同时降低延迟
|
||||
merged_count = 1
|
||||
stop = False
|
||||
while True:
|
||||
try:
|
||||
nxt = push_queue.get_nowait()
|
||||
except _queue.Empty:
|
||||
break
|
||||
merged_count += 1
|
||||
if nxt is None:
|
||||
stop = True
|
||||
break
|
||||
snapshot = nxt
|
||||
try:
|
||||
_stream_update_text(snapshot)
|
||||
finally:
|
||||
for _ in range(merged_count):
|
||||
push_queue.task_done()
|
||||
if stop:
|
||||
return
|
||||
|
||||
push_thread = threading.Thread(target=_push_worker, daemon=True, name="feishu-stream-push")
|
||||
push_thread.start()
|
||||
|
||||
def _drain_push_queue():
|
||||
"""等当前队列里所有 PUT 都完成。message_end/agent_end 在做最终定型前必须 drain,
|
||||
否则 worker 里堆积的旧快照可能在 final_text PUT 之后到达,把最终内容覆盖掉。"""
|
||||
try:
|
||||
push_queue.join()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
msg = context.get("msg")
|
||||
is_group = context.get("isgroup", False)
|
||||
receiver = context.get("receiver")
|
||||
receive_id_type = context.get("receive_id_type", "open_id")
|
||||
headers = {
|
||||
"Authorization": "Bearer " + access_token,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
}
|
||||
# 卡片中富文本组件的 element_id,后续所有 PUT 流式更新都打到这个组件
|
||||
ELEMENT_ID = "stream_md"
|
||||
# 操作序号,每次 PUT 必须严格递增(飞书要求)
|
||||
sequence = [0]
|
||||
|
||||
def _next_sequence():
|
||||
with lock:
|
||||
sequence[0] += 1
|
||||
return sequence[0]
|
||||
|
||||
def _build_card_json():
|
||||
"""Build the initial streaming Card 2.0 payload."""
|
||||
with lock:
|
||||
card = progress_state.build_card(streaming=True)
|
||||
return json.dumps(card, ensure_ascii=False)
|
||||
|
||||
def _create_and_send_card():
|
||||
"""同步执行:创建卡片实体 → 发送消息。任意一步失败则 disabled=True 触发降级"""
|
||||
try:
|
||||
# 步骤 1: 创建卡片实体
|
||||
create_url = "https://open.feishu.cn/open-apis/cardkit/v1/cards"
|
||||
create_body = {"type": "card_json", "data": _build_card_json()}
|
||||
res = requests.post(
|
||||
create_url, headers=headers, json=create_body, timeout=(5, 10)
|
||||
)
|
||||
res_json = res.json()
|
||||
if res_json.get("code") != 0:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: create card failed "
|
||||
f"(code={res_json.get('code')}, msg={res_json.get('msg')}). "
|
||||
f"本次回复已自动降级为普通文本回复(一次性返回完整内容)。"
|
||||
f"如需开启流式打字机效果与完整 Markdown 渲染,请到飞书开放平台 "
|
||||
f"https://open.feishu.cn/app 给机器人开通 cardkit:card:write 权限"
|
||||
f"(创建与更新卡片)并重新发布版本,同时确保飞书客户端 >= 7.20。"
|
||||
)
|
||||
with lock:
|
||||
disabled[0] = True
|
||||
return
|
||||
cid = res_json["data"]["card_id"]
|
||||
with lock:
|
||||
card_id[0] = cid
|
||||
|
||||
# 步骤 2: 通过 card_id 发送消息(群聊优先用 reply,单聊直接 send)
|
||||
content_payload = json.dumps(
|
||||
{"type": "card", "data": {"card_id": cid}}, ensure_ascii=False
|
||||
)
|
||||
can_reply = is_group and msg and hasattr(msg, "msg_id") and msg.msg_id
|
||||
if can_reply:
|
||||
send_url = (
|
||||
f"https://open.feishu.cn/open-apis/im/v1/messages/"
|
||||
f"{msg.msg_id}/reply"
|
||||
)
|
||||
send_body = {"msg_type": "interactive", "content": content_payload}
|
||||
send_res = requests.post(
|
||||
send_url, headers=headers, json=send_body, timeout=(5, 10)
|
||||
)
|
||||
else:
|
||||
send_url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||
params = {"receive_id_type": receive_id_type}
|
||||
send_body = {
|
||||
"receive_id": receiver,
|
||||
"msg_type": "interactive",
|
||||
"content": content_payload,
|
||||
}
|
||||
send_res = requests.post(
|
||||
send_url, headers=headers, params=params, json=send_body,
|
||||
timeout=(5, 10),
|
||||
)
|
||||
send_json = send_res.json()
|
||||
if send_json.get("code") != 0:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: send card failed: {send_json}. 降级为普通文本。"
|
||||
)
|
||||
with lock:
|
||||
disabled[0] = True
|
||||
return
|
||||
mid = send_json["data"]["message_id"]
|
||||
with lock:
|
||||
message_id[0] = mid
|
||||
logger.info(
|
||||
f"[FeiShu] Stream: card created and sent, "
|
||||
f"card_id={cid}, message_id={mid}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: create/send card exception: {e}. 降级为普通文本。"
|
||||
)
|
||||
with lock:
|
||||
disabled[0] = True
|
||||
finally:
|
||||
with lock:
|
||||
init_in_flight[0] = False
|
||||
|
||||
def _stream_update_text(full_text):
|
||||
"""PUT 流式更新文本组件。content 必须是当前组件的全量文本。"""
|
||||
with lock:
|
||||
cid = card_id[0]
|
||||
if not cid:
|
||||
return
|
||||
url = (
|
||||
f"https://open.feishu.cn/open-apis/cardkit/v1/cards/"
|
||||
f"{cid}/elements/{ELEMENT_ID}/content"
|
||||
)
|
||||
body = {
|
||||
"content": full_text,
|
||||
"sequence": _next_sequence(),
|
||||
}
|
||||
try:
|
||||
res = requests.put(url, headers=headers, json=body, timeout=(5, 10))
|
||||
res_json = res.json()
|
||||
if res_json.get("code") != 0:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: update text failed: {res_json}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[FeiShu] Stream: update text exception: {e}")
|
||||
|
||||
def _update_full_card(streaming: bool):
|
||||
"""Refresh panels, header, footer and streaming state in one update."""
|
||||
with lock:
|
||||
cid = card_id[0]
|
||||
full_card = progress_state.build_card(streaming=streaming)
|
||||
if not cid:
|
||||
return
|
||||
put_url = f"https://open.feishu.cn/open-apis/cardkit/v1/cards/{cid}"
|
||||
put_body = {
|
||||
"card": {"type": "card_json", "data": json.dumps(full_card, ensure_ascii=False)},
|
||||
"sequence": _next_sequence(),
|
||||
}
|
||||
try:
|
||||
res = requests.put(put_url, headers=headers, json=put_body, timeout=(5, 10))
|
||||
res_json = res.json()
|
||||
if res_json.get("code") != 0:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: full card update failed: {res_json}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[FeiShu] Stream: full card update exception: {e}"
|
||||
)
|
||||
|
||||
def on_event(event: dict):
|
||||
event_type = event.get("type")
|
||||
data = event.get("data", {})
|
||||
|
||||
# 一旦降级,本次回复不再做任何流式操作
|
||||
with lock:
|
||||
if disabled[0]:
|
||||
return
|
||||
|
||||
if event_type == "agent_start":
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
if card_id[0] is None and not init_in_flight[0]:
|
||||
init_in_flight[0] = True
|
||||
_create_and_send_card()
|
||||
return
|
||||
|
||||
if event_type in ("turn_start", "reasoning_update"):
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
return
|
||||
|
||||
if event_type == "message_update":
|
||||
delta = data.get("delta", "")
|
||||
if not delta:
|
||||
return
|
||||
|
||||
# 第一段:判断是否需要初始化(创建卡片 + 发送)
|
||||
need_init = False
|
||||
with lock:
|
||||
if card_id[0] is None and not init_in_flight[0]:
|
||||
init_in_flight[0] = True
|
||||
need_init = True
|
||||
|
||||
if need_init:
|
||||
_create_and_send_card()
|
||||
# 初始化失败已标记 disabled,下次循环直接 return
|
||||
with lock:
|
||||
if disabled[0]:
|
||||
return
|
||||
|
||||
# 第二段:累加文本,把快照丢给 push worker 异步推送。
|
||||
# 这里不能直接 requests.put,否则会阻塞 LLM stream 线程读下一个 chunk
|
||||
# (实测 DeepSeek 高频小 chunk 场景每个 PUT ~150ms,累积起来非常卡)。
|
||||
snapshot = ""
|
||||
should_push = False
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
if card_id[0]:
|
||||
snapshot = progress_state.current_text
|
||||
should_push = True
|
||||
|
||||
if should_push:
|
||||
push_queue.put(snapshot)
|
||||
|
||||
elif event_type in ("tool_execution_start", "tool_execution_end"):
|
||||
# Refresh the Tools panel as each tool starts/finishes so users
|
||||
# see live status and per-tool elapsed time.
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
has_card = card_id[0] is not None
|
||||
if has_card:
|
||||
_drain_push_queue()
|
||||
_update_full_card(streaming=True)
|
||||
|
||||
elif event_type == "message_end":
|
||||
# 工具轮结束后原地刷新 Reasoning / Tools 面板,保留同一张卡片。
|
||||
tool_calls = data.get("tool_calls", []) or []
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
if not tool_calls:
|
||||
return
|
||||
_drain_push_queue()
|
||||
_update_full_card(streaming=True)
|
||||
|
||||
elif event_type == "agent_cancelled":
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
|
||||
elif event_type == "agent_end":
|
||||
# Finalize the same card with a status header and elapsed footer.
|
||||
with lock:
|
||||
progress_state.consume(event)
|
||||
final_text = progress_state.current_text
|
||||
has_card = card_id[0] is not None
|
||||
init_busy = init_in_flight[0]
|
||||
final_text = resolve_markdown_images(
|
||||
final_text,
|
||||
lambda url: upload_public_image_to_feishu(url, access_token),
|
||||
)
|
||||
with lock:
|
||||
progress_state.current_text = final_text
|
||||
context["feishu_streamed"] = True
|
||||
|
||||
if not has_card and not init_busy:
|
||||
with lock:
|
||||
init_in_flight[0] = True
|
||||
_create_and_send_card()
|
||||
with lock:
|
||||
if disabled[0]:
|
||||
return
|
||||
|
||||
_drain_push_queue()
|
||||
_stream_update_text(final_text)
|
||||
_update_full_card(streaming=False)
|
||||
push_queue.put(None)
|
||||
|
||||
return on_event
|
||||
|
||||
def _make_feishu_stream_callback_plain(self, context, access_token):
|
||||
"""
|
||||
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||
|
||||
@@ -966,9 +1534,15 @@ class FeiShuChanel(ChatChannel):
|
||||
if not cid:
|
||||
return
|
||||
|
||||
preview_text = final_text
|
||||
final_text = resolve_markdown_images(
|
||||
final_text,
|
||||
lambda url: upload_public_image_to_feishu(url, access_token),
|
||||
)
|
||||
|
||||
# 1) 通过整卡更新接口把 streaming_mode 关掉,并改写 summary
|
||||
# (settings 接口的 config 不接受 summary 字段,会报 code=2200)
|
||||
preview_src = (final_text or "").strip().replace("\n", " ")
|
||||
preview_src = (preview_text or "").strip().replace("\n", " ")
|
||||
preview = preview_src[:30] if preview_src else ""
|
||||
full_card = {
|
||||
"schema": "2.0",
|
||||
@@ -1565,6 +2139,8 @@ class FeishuController:
|
||||
FAILED_MSG = '{"success": false}'
|
||||
SUCCESS_MSG = '{"success": true}'
|
||||
MESSAGE_RECEIVE_TYPE = "im.message.receive_v1"
|
||||
MESSAGE_RECALLED_TYPE = "im.message.recalled_v1"
|
||||
CARD_ACTION_TYPE = "card.action.trigger"
|
||||
|
||||
def GET(self):
|
||||
return "Feishu service start success!"
|
||||
@@ -1581,16 +2157,30 @@ class FeishuController:
|
||||
varify_res = {"challenge": request.get("challenge")}
|
||||
return json.dumps(varify_res)
|
||||
|
||||
# 2.消息接收处理
|
||||
# token 校验
|
||||
header = request.get("header")
|
||||
if not header or header.get("token") != channel.feishu_token:
|
||||
# 2. Verify callbacks. Card callbacks may carry the verification
|
||||
# token in event.token while message events carry it in the header.
|
||||
header = request.get("header") or {}
|
||||
event = request.get("event") or {}
|
||||
event_type = header.get("event_type") or request.get("type")
|
||||
callback_token = (
|
||||
header.get("token")
|
||||
or event.get("token")
|
||||
or request.get("token")
|
||||
)
|
||||
if callback_token != channel.feishu_token:
|
||||
return self.FAILED_MSG
|
||||
|
||||
# 处理消息事件
|
||||
event = request.get("event")
|
||||
if header.get("event_type") == self.MESSAGE_RECEIVE_TYPE and event:
|
||||
if event_type == self.CARD_ACTION_TYPE and event:
|
||||
return json.dumps(
|
||||
channel._handle_card_action_event(event),
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
# 3. Handle message events.
|
||||
if event_type == self.MESSAGE_RECEIVE_TYPE and event:
|
||||
channel._handle_message_event(event)
|
||||
elif event_type == self.MESSAGE_RECALLED_TYPE and event:
|
||||
channel._handle_message_recalled_event(event)
|
||||
|
||||
return self.SUCCESS_MSG
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ class FeishuMessage(ChatMessage):
|
||||
self.msg_id = msg.get("message_id")
|
||||
self.create_time = msg.get("create_time")
|
||||
self.is_group = is_group
|
||||
self.quoted_content = ""
|
||||
msg_type = msg.get("message_type")
|
||||
|
||||
if msg_type == "text":
|
||||
@@ -208,6 +209,9 @@ class FeishuMessage(ChatMessage):
|
||||
else:
|
||||
raise NotImplementedError("Unsupported message type: Type:{} ".format(msg_type))
|
||||
|
||||
if self.ctype == ContextType.TEXT:
|
||||
self.quoted_content = self._fetch_quoted_content(msg.get("parent_id"))
|
||||
|
||||
self.from_user_id = sender.get("sender_id").get("open_id")
|
||||
self.to_user_id = event.get("app_id")
|
||||
if is_group:
|
||||
@@ -220,3 +224,99 @@ class FeishuMessage(ChatMessage):
|
||||
# 私聊
|
||||
self.other_user_id = self.from_user_id
|
||||
self.actual_user_id = self.from_user_id
|
||||
|
||||
def content_with_quote(self) -> str:
|
||||
"""Return user text with optional quoted-message context for the agent."""
|
||||
if not self.quoted_content:
|
||||
return self.content
|
||||
return (
|
||||
"[Quoted message]\n{}\n[/Quoted message]\n\n{}".format(
|
||||
self.quoted_content,
|
||||
self.content,
|
||||
)
|
||||
)
|
||||
|
||||
def _fetch_quoted_content(self, parent_id: str) -> str:
|
||||
"""Fetch one parent message, degrading to an empty quote on failure."""
|
||||
if not parent_id or not self.access_token:
|
||||
return ""
|
||||
|
||||
url = "https://open.feishu.cn/open-apis/im/v1/messages/{}".format(parent_id)
|
||||
headers = {"Authorization": "Bearer " + self.access_token}
|
||||
try:
|
||||
response = requests.get(
|
||||
url=url,
|
||||
headers=headers,
|
||||
params={"card_msg_content_type": "raw_card_content"},
|
||||
timeout=(5, 10),
|
||||
)
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"[FeiShu] quoted message fetch failed, parent_id=%s, status=%s",
|
||||
parent_id,
|
||||
response.status_code,
|
||||
)
|
||||
return ""
|
||||
body = response.json()
|
||||
items = (body.get("data") or {}).get("items") or []
|
||||
if body.get("code") != 0 or not items:
|
||||
return ""
|
||||
return self._extract_quoted_text(items[0])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[FeiShu] quoted message fetch error, parent_id=%s: %s",
|
||||
parent_id,
|
||||
exc,
|
||||
)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_quoted_text(item: dict) -> str:
|
||||
msg_type = item.get("msg_type")
|
||||
raw_content = (item.get("body") or {}).get("content") or ""
|
||||
try:
|
||||
content = json.loads(raw_content)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
|
||||
if msg_type == "text":
|
||||
return str(content.get("text") or "").strip()
|
||||
if msg_type == "post":
|
||||
# Some message-history payloads wrap post content in a locale key.
|
||||
if "content" not in content:
|
||||
localized = next(
|
||||
(value for value in content.values() if isinstance(value, dict)),
|
||||
None,
|
||||
)
|
||||
if localized:
|
||||
content = localized
|
||||
|
||||
parts = []
|
||||
title = str(content.get("title") or "").strip()
|
||||
if title:
|
||||
parts.append(title)
|
||||
for block in content.get("content") or []:
|
||||
if not isinstance(block, list):
|
||||
continue
|
||||
for element in block:
|
||||
if not isinstance(element, dict):
|
||||
continue
|
||||
tag = element.get("tag")
|
||||
text = str(element.get("text") or "").strip()
|
||||
if tag == "text" and text:
|
||||
parts.append(text)
|
||||
elif tag == "a" and text:
|
||||
href = str(element.get("href") or "").strip()
|
||||
parts.append("{} ({})".format(text, href) if href else text)
|
||||
elif tag == "img":
|
||||
parts.append("[Image]")
|
||||
return "\n".join(parts).strip()
|
||||
if msg_type == "image":
|
||||
return "[Image]"
|
||||
if msg_type == "file":
|
||||
return "[File: {}]".format(content.get("file_name") or "file")
|
||||
if msg_type == "audio":
|
||||
return "[Audio]"
|
||||
if msg_type == "media":
|
||||
return "[Video: {}]".format(content.get("file_name") or "video")
|
||||
return ""
|
||||
|
||||
247
channel/feishu/feishu_progress_card.py
Normal file
247
channel/feishu/feishu_progress_card.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""State and Card 2.0 rendering for a Feishu agent run."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from common import i18n
|
||||
|
||||
|
||||
_MAX_PANEL_STEPS = 10
|
||||
_MAX_STEP_CHARS = 800
|
||||
|
||||
|
||||
class FeishuProgressState:
|
||||
"""Reduce CowAgent stream events into one renderable Feishu card state."""
|
||||
|
||||
def __init__(self, started_at: Optional[float] = None):
|
||||
self.started_at = time.monotonic() if started_at is None else started_at
|
||||
self.status = "running"
|
||||
self.turns = 0
|
||||
self.current_text = ""
|
||||
self._reasoning_buffer = ""
|
||||
self.reasoning_steps: List[str] = []
|
||||
self.tool_steps: List[Dict[str, Any]] = []
|
||||
self._tool_index: Dict[str, Dict[str, Any]] = {}
|
||||
self.cancelled = False
|
||||
|
||||
def consume(self, event: Dict[str, Any]) -> None:
|
||||
"""Consume one event emitted by ``AgentStreamHandler``."""
|
||||
event_type = event.get("type")
|
||||
data = event.get("data") or {}
|
||||
|
||||
if event_type == "turn_start":
|
||||
self._mark_running_tools_done()
|
||||
turn = data.get("turn")
|
||||
if isinstance(turn, int):
|
||||
self.turns = max(self.turns, turn)
|
||||
else:
|
||||
self.turns += 1
|
||||
if self.turns > 1:
|
||||
self.current_text = ""
|
||||
return
|
||||
|
||||
if event_type == "reasoning_update":
|
||||
self._reasoning_buffer += str(data.get("delta") or "")
|
||||
return
|
||||
|
||||
if event_type == "message_update":
|
||||
self.current_text += str(data.get("delta") or "")
|
||||
return
|
||||
|
||||
if event_type == "message_end":
|
||||
self._commit_reasoning()
|
||||
return
|
||||
|
||||
if event_type == "tool_execution_start":
|
||||
tool_id = data.get("tool_call_id")
|
||||
step = {
|
||||
"summary": str(data.get("tool_name") or "tool"),
|
||||
"status": "running",
|
||||
"started_at": time.monotonic(),
|
||||
"elapsed": None,
|
||||
}
|
||||
self.tool_steps.append(step)
|
||||
if tool_id:
|
||||
self._tool_index[tool_id] = step
|
||||
return
|
||||
|
||||
if event_type == "tool_execution_end":
|
||||
tool_id = data.get("tool_call_id")
|
||||
step = self._tool_index.get(tool_id) if tool_id else None
|
||||
if step is None:
|
||||
# Fall back to the most recent running step when no id match.
|
||||
step = next((s for s in reversed(self.tool_steps) if s["status"] == "running"), None)
|
||||
if step is not None:
|
||||
step["status"] = "error" if data.get("status") not in (None, "success") else "done"
|
||||
elapsed = data.get("execution_time")
|
||||
if elapsed is None and step.get("started_at") is not None:
|
||||
elapsed = time.monotonic() - step["started_at"]
|
||||
step["elapsed"] = elapsed
|
||||
return
|
||||
|
||||
if event_type == "agent_cancelled":
|
||||
self.cancelled = True
|
||||
self.status = "stopped"
|
||||
return
|
||||
|
||||
if event_type == "agent_end":
|
||||
self._commit_reasoning()
|
||||
self._mark_running_tools_done()
|
||||
cancelled = self.cancelled or bool(data.get("cancelled"))
|
||||
if cancelled:
|
||||
self.status = "stopped"
|
||||
self.current_text = self.current_text.rstrip() or "_(stopped)_"
|
||||
else:
|
||||
self.status = "done"
|
||||
final_response = data.get("final_response")
|
||||
if final_response:
|
||||
self.current_text = str(final_response)
|
||||
|
||||
def build_card(self, streaming: bool, now: Optional[float] = None) -> Dict[str, Any]:
|
||||
"""Render the current state as a Feishu Card 2.0 object."""
|
||||
# Localized status header text; en/zh/zh-Hant via i18n.t.
|
||||
title, template = {
|
||||
"running": (i18n.t("处理中", "Working"), "blue"),
|
||||
"done": (i18n.t("完成", "Done"), "green"),
|
||||
"stopped": (i18n.t("已停止", "Stopped"), "grey"),
|
||||
"error": (i18n.t("出错", "Error"), "red"),
|
||||
}.get(self.status, (i18n.t("处理中", "Working"), "blue"))
|
||||
|
||||
main_text = self.current_text or "..."
|
||||
elements: List[Dict[str, Any]] = []
|
||||
|
||||
# Only render the Reasoning panel when there is real reasoning content.
|
||||
# Upstream emits reasoning_update only when deep thinking is enabled, so
|
||||
# an empty reasoning_steps means we should show no panel at all.
|
||||
if self.reasoning_steps:
|
||||
elements.append(
|
||||
_panel(
|
||||
"🤔 {}".format(i18n.t("思考", "Thinking")),
|
||||
[_text_row(step, muted=True) for step in self.reasoning_steps[-_MAX_PANEL_STEPS:]],
|
||||
expanded=streaming,
|
||||
)
|
||||
)
|
||||
|
||||
if self.tool_steps:
|
||||
elements.append(
|
||||
_panel(
|
||||
"🔧 {} ({})".format(i18n.t("工具", "Tools"), len(self.tool_steps)),
|
||||
[
|
||||
_text_row(_format_tool_step(step))
|
||||
for step in self.tool_steps[-_MAX_PANEL_STEPS:]
|
||||
],
|
||||
expanded=streaming,
|
||||
)
|
||||
)
|
||||
|
||||
elements.append(
|
||||
{
|
||||
"tag": "markdown",
|
||||
"element_id": "stream_md",
|
||||
"content": main_text,
|
||||
}
|
||||
)
|
||||
|
||||
elapsed = max(0.0, (time.monotonic() if now is None else now) - self.started_at)
|
||||
turn_label = i18n.t("轮", "turn" if self.turns == 1 else "turns")
|
||||
elements.extend(
|
||||
[
|
||||
{"tag": "hr"},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "{:.1f}s · {} {}".format(elapsed, self.turns, turn_label),
|
||||
"text_size": "notation",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
config: Dict[str, Any] = {
|
||||
"streaming_mode": streaming,
|
||||
"update_multi": True,
|
||||
"enable_forward_interaction": True,
|
||||
"summary": {"content": _summary(main_text, title)},
|
||||
}
|
||||
if streaming:
|
||||
config["streaming_config"] = {
|
||||
"print_frequency_ms": {"default": 40},
|
||||
"print_step": {"default": 4},
|
||||
"print_strategy": "fast",
|
||||
}
|
||||
|
||||
card: Dict[str, Any] = {
|
||||
"schema": "2.0",
|
||||
"config": config,
|
||||
"body": {"elements": elements},
|
||||
}
|
||||
# Hide the status header once the run has finished successfully; a plain
|
||||
# answer needs no "Done" banner. Keep the header for running/stopped/error
|
||||
# so users still get progress and failure signals.
|
||||
if self.status != "done":
|
||||
card["header"] = {
|
||||
"template": template,
|
||||
"title": {"tag": "plain_text", "content": title},
|
||||
}
|
||||
return card
|
||||
|
||||
def _commit_reasoning(self) -> None:
|
||||
reasoning = self._reasoning_buffer.strip()
|
||||
if reasoning:
|
||||
self.reasoning_steps.append(reasoning[-_MAX_STEP_CHARS:])
|
||||
self._reasoning_buffer = ""
|
||||
|
||||
def _mark_running_tools_done(self) -> None:
|
||||
for step in self.tool_steps:
|
||||
if step["status"] == "running":
|
||||
step["status"] = "done"
|
||||
if step.get("elapsed") is None and step.get("started_at") is not None:
|
||||
step["elapsed"] = time.monotonic() - step["started_at"]
|
||||
|
||||
|
||||
def _tool_status_label(status: str) -> str:
|
||||
if status == "running":
|
||||
return i18n.t("执行中", "running")
|
||||
if status == "error":
|
||||
return i18n.t("失败", "error")
|
||||
return i18n.t("完成", "done")
|
||||
|
||||
|
||||
def _format_tool_step(step: Dict[str, Any]) -> str:
|
||||
# Tool name plus its own status and elapsed time, e.g. "search · done · 1.2s".
|
||||
parts = [str(step.get("summary") or "tool"), _tool_status_label(step["status"])]
|
||||
elapsed = step.get("elapsed")
|
||||
if isinstance(elapsed, (int, float)):
|
||||
parts.append("{:.1f}s".format(max(0.0, float(elapsed))))
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[str, Any]:
|
||||
return {
|
||||
"tag": "collapsible_panel",
|
||||
"expanded": expanded,
|
||||
"background_color": "grey",
|
||||
# Panel title uses markdown so we can shrink the font via text_size
|
||||
# (plain_text titles ignore text_size and break card rendering).
|
||||
"header": {"title": {"tag": "markdown", "content": title, "text_size": "notation"}},
|
||||
"border": {"color": "grey"},
|
||||
"vertical_spacing": "8px",
|
||||
"padding": "4px 8px",
|
||||
"elements": elements,
|
||||
}
|
||||
|
||||
|
||||
def _text_row(content: str, muted: bool = False) -> Dict[str, Any]:
|
||||
text = {
|
||||
"tag": "plain_text",
|
||||
"content": content,
|
||||
"text_size": "notation",
|
||||
}
|
||||
if muted:
|
||||
text["text_color"] = "grey"
|
||||
return {"tag": "div", "text": text}
|
||||
|
||||
|
||||
def _summary(text: str, fallback: str) -> str:
|
||||
preview = " ".join(text.strip().split())
|
||||
return preview[:60] or fallback
|
||||
185
channel/feishu/feishu_scheduler_card.py
Normal file
185
channel/feishu/feishu_scheduler_card.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Feishu scheduler card rendering and callback handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Set
|
||||
|
||||
|
||||
_MAX_TASKS = 20
|
||||
|
||||
|
||||
def tasks_for_receivers(tasks: Iterable[dict], receivers: Set[str]) -> List[dict]:
|
||||
"""Return Feishu tasks owned by one of the callback's trusted receivers."""
|
||||
visible = []
|
||||
for task in tasks:
|
||||
action = task.get("action") or {}
|
||||
if action.get("channel_type") != "feishu":
|
||||
continue
|
||||
if action.get("receiver") in receivers:
|
||||
visible.append(task)
|
||||
return visible
|
||||
|
||||
|
||||
def build_scheduler_card(tasks: Iterable[dict]) -> Dict[str, Any]:
|
||||
"""Build a Card 2.0 task list with explicit, idempotent actions."""
|
||||
task_list = list(tasks)
|
||||
elements: List[Dict[str, Any]] = []
|
||||
|
||||
if not task_list:
|
||||
elements.append({"tag": "markdown", "content": "No scheduled tasks in this chat."})
|
||||
else:
|
||||
for index, task in enumerate(task_list[:_MAX_TASKS]):
|
||||
if index:
|
||||
elements.append({"tag": "hr"})
|
||||
task_id = str(task.get("id") or "")
|
||||
receiver = str((task.get("action") or {}).get("receiver") or "")
|
||||
enabled = task.get("enabled", True)
|
||||
status = "Enabled" if enabled else "Disabled"
|
||||
next_run = str(task.get("next_run_at") or "Unknown").replace("T", " ")
|
||||
elements.append(
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "**{}** · {}\n`{}` · {}\nNext: {}".format(
|
||||
task.get("name") or "Unnamed task",
|
||||
status,
|
||||
task_id,
|
||||
_format_schedule(task.get("schedule") or {}),
|
||||
next_run,
|
||||
),
|
||||
}
|
||||
)
|
||||
toggle_action = "disable" if enabled else "enable"
|
||||
toggle_text = "Disable" if enabled else "Enable"
|
||||
toggle_type = "default" if enabled else "primary"
|
||||
elements.append(
|
||||
{
|
||||
"tag": "column_set",
|
||||
"columns": [
|
||||
{
|
||||
"tag": "column",
|
||||
"elements": [
|
||||
_button(
|
||||
toggle_text,
|
||||
toggle_type,
|
||||
toggle_action,
|
||||
task_id,
|
||||
receiver,
|
||||
)
|
||||
],
|
||||
},
|
||||
{
|
||||
"tag": "column",
|
||||
"elements": [_button("Delete", "danger", "delete", task_id, receiver)],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
hidden = len(task_list) - _MAX_TASKS
|
||||
if hidden > 0:
|
||||
elements.extend(
|
||||
[
|
||||
{"tag": "hr"},
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": "{} more tasks are hidden.".format(hidden),
|
||||
"text_size": "notation",
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
return {
|
||||
"schema": "2.0",
|
||||
"config": {"update_multi": True, "enable_forward_interaction": False},
|
||||
"header": {
|
||||
"template": "blue",
|
||||
"title": {"tag": "plain_text", "content": "Scheduled tasks"},
|
||||
},
|
||||
"body": {"elements": elements},
|
||||
}
|
||||
|
||||
|
||||
def handle_scheduler_action(
|
||||
value: Dict[str, Any], task_store: Any, allowed_receivers: Set[str]
|
||||
) -> Dict[str, Any]:
|
||||
"""Apply an owned scheduler action and return a Feishu callback response."""
|
||||
if value.get("cowagent") != "scheduler":
|
||||
return {}
|
||||
|
||||
task_id = str(value.get("task_id") or "")
|
||||
action = value.get("action")
|
||||
if not task_id or action not in {"enable", "disable", "delete"}:
|
||||
return _toast("error", "Invalid scheduler action")
|
||||
|
||||
task = task_store.get_task(task_id)
|
||||
if not task:
|
||||
return _response(
|
||||
"info",
|
||||
"Task no longer exists",
|
||||
build_scheduler_card(tasks_for_receivers(task_store.list_tasks(), allowed_receivers)),
|
||||
)
|
||||
|
||||
task_receiver = (task.get("action") or {}).get("receiver")
|
||||
task_channel = (task.get("action") or {}).get("channel_type")
|
||||
value_receiver = value.get("receiver")
|
||||
if (
|
||||
task_channel != "feishu"
|
||||
or task_receiver not in allowed_receivers
|
||||
or (value_receiver and value_receiver != task_receiver)
|
||||
):
|
||||
return _toast("error", "Task is not available in this chat")
|
||||
|
||||
try:
|
||||
if action == "delete":
|
||||
task_store.delete_task(task_id)
|
||||
message = "Task deleted"
|
||||
else:
|
||||
enabled = action == "enable"
|
||||
task_store.enable_task(task_id, enabled)
|
||||
message = "Task enabled" if enabled else "Task disabled"
|
||||
except (OSError, ValueError) as exc:
|
||||
return _toast("error", "Task update failed: {}".format(exc))
|
||||
|
||||
visible = tasks_for_receivers(task_store.list_tasks(), allowed_receivers)
|
||||
return _response("success", message, build_scheduler_card(visible))
|
||||
|
||||
|
||||
def _response(toast_type: str, content: str, card: Dict[str, Any]) -> Dict[str, Any]:
|
||||
response = _toast(toast_type, content)
|
||||
response["card"] = {"type": "raw", "data": card}
|
||||
return response
|
||||
|
||||
|
||||
def _toast(toast_type: str, content: str) -> Dict[str, Any]:
|
||||
return {"toast": {"type": toast_type, "content": content}}
|
||||
|
||||
|
||||
def _button(
|
||||
text: str,
|
||||
button_type: str,
|
||||
action: str,
|
||||
task_id: str,
|
||||
receiver: str,
|
||||
) -> Dict[str, Any]:
|
||||
return {
|
||||
"tag": "button",
|
||||
"text": {"tag": "plain_text", "content": text},
|
||||
"type": button_type,
|
||||
"value": {
|
||||
"cowagent": "scheduler",
|
||||
"action": action,
|
||||
"task_id": task_id,
|
||||
"receiver": receiver,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _format_schedule(schedule: Dict[str, Any]) -> str:
|
||||
schedule_type = schedule.get("type")
|
||||
if schedule_type == "cron":
|
||||
return "cron {}".format(schedule.get("expression") or "?")
|
||||
if schedule_type == "interval":
|
||||
return "every {}s".format(schedule.get("seconds") or "?")
|
||||
if schedule_type == "once":
|
||||
return "once at {}".format(schedule.get("run_at") or "?")
|
||||
return str(schedule_type or "unknown schedule")
|
||||
218
channel/feishu/feishu_static_card.py
Normal file
218
channel/feishu/feishu_static_card.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Helpers for choosing the native Feishu delivery format for text replies."""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
from typing import Callable, Optional, Tuple
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
_BLOCK_MARKDOWN = re.compile(
|
||||
r"(?m)^\s{0,3}(?:#{1,6}\s|>\s|[-*+]\s|\d+[.)]\s|```|~~~)"
|
||||
)
|
||||
_INLINE_MARKDOWN = re.compile(r"(`[^`\n]+`|\*\*[^*\n]+\*\*|\[[^]\n]+\]\([^)\n]+\))")
|
||||
_TABLE_SEPARATOR = re.compile(r"(?m)^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$")
|
||||
_MARKDOWN_IMAGE = re.compile(r"!\[([^\]\n]*)\]\(([^)\s]+)\)")
|
||||
_REDIRECT_CODES = {301, 302, 303, 307, 308}
|
||||
_MAX_REDIRECTS = 3
|
||||
_MAX_REMOTE_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def contains_markdown(text: str) -> bool:
|
||||
"""Return whether *text* contains syntax that benefits from card Markdown."""
|
||||
if not text:
|
||||
return False
|
||||
return bool(
|
||||
_BLOCK_MARKDOWN.search(text)
|
||||
or _INLINE_MARKDOWN.search(text)
|
||||
or _TABLE_SEPARATOR.search(text)
|
||||
)
|
||||
|
||||
|
||||
def build_markdown_card(text: str) -> dict:
|
||||
"""Build an inline Card 2.0 payload with one Markdown element."""
|
||||
return {
|
||||
"schema": "2.0",
|
||||
"config": {},
|
||||
"body": {
|
||||
"elements": [
|
||||
{
|
||||
"tag": "markdown",
|
||||
"content": text,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_text_delivery(text: str) -> Tuple[str, str]:
|
||||
"""Return the Feishu ``msg_type`` and serialized content for a text reply."""
|
||||
if contains_markdown(text):
|
||||
return "interactive", json.dumps(build_markdown_card(text), ensure_ascii=False)
|
||||
return "text", json.dumps({"text": text}, ensure_ascii=False)
|
||||
|
||||
|
||||
def resolve_markdown_images(
|
||||
text: str,
|
||||
uploader: Callable[[str], Optional[str]],
|
||||
max_images: int = 5,
|
||||
) -> str:
|
||||
"""Replace remote Markdown image URLs with Feishu image keys."""
|
||||
cache = {}
|
||||
uploaded = 0
|
||||
|
||||
def replace(match):
|
||||
nonlocal uploaded
|
||||
alt = match.group(1).strip() or "image"
|
||||
target = match.group(2).strip()
|
||||
if target.startswith("img_"):
|
||||
return match.group(0)
|
||||
if urlparse(target).scheme not in ("http", "https"):
|
||||
return match.group(0)
|
||||
|
||||
if target not in cache:
|
||||
if uploaded >= max_images:
|
||||
cache[target] = None
|
||||
else:
|
||||
uploaded += 1
|
||||
try:
|
||||
cache[target] = uploader(target)
|
||||
except Exception:
|
||||
cache[target] = None
|
||||
|
||||
image_key = cache[target]
|
||||
if image_key:
|
||||
return "".format(alt, image_key)
|
||||
return "[Image unavailable: {}]".format(alt)
|
||||
|
||||
return _MARKDOWN_IMAGE.sub(replace, text or "")
|
||||
|
||||
|
||||
def validate_public_image_url(url: str) -> None:
|
||||
"""Reject non-HTTP and non-public image targets before downloading."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("unsupported image URL scheme")
|
||||
if not parsed.hostname:
|
||||
raise ValueError("image URL has no hostname")
|
||||
|
||||
try:
|
||||
literal_address = ipaddress.ip_address(parsed.hostname)
|
||||
resolved_addresses = [literal_address]
|
||||
except ValueError:
|
||||
try:
|
||||
addresses = socket.getaddrinfo(
|
||||
parsed.hostname,
|
||||
parsed.port,
|
||||
socket.AF_UNSPEC,
|
||||
socket.SOCK_STREAM,
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
raise ValueError("cannot resolve image hostname") from exc
|
||||
resolved_addresses = [ipaddress.ip_address(item[4][0]) for item in addresses]
|
||||
|
||||
for address in resolved_addresses:
|
||||
if (
|
||||
address.is_private
|
||||
or address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_reserved
|
||||
or address.is_multicast
|
||||
or address.is_unspecified
|
||||
):
|
||||
raise ValueError("image URL resolves to a non-public address")
|
||||
|
||||
|
||||
def download_public_image(
|
||||
url: str,
|
||||
get=requests.get,
|
||||
max_bytes: int = _MAX_REMOTE_IMAGE_BYTES,
|
||||
) -> Tuple[bytes, str]:
|
||||
"""Download a public image with redirect, type, and size checks."""
|
||||
current = url
|
||||
for _ in range(_MAX_REDIRECTS + 1):
|
||||
validate_public_image_url(current)
|
||||
response = get(
|
||||
current,
|
||||
headers={"User-Agent": "CowAgent/Feishu"},
|
||||
timeout=(5, 15),
|
||||
allow_redirects=False,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
if response.status_code in _REDIRECT_CODES:
|
||||
location = response.headers.get("Location")
|
||||
response.close()
|
||||
if not location:
|
||||
raise ValueError("image redirect has no location")
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
|
||||
if response.status_code != 200:
|
||||
response.close()
|
||||
raise ValueError("image download returned HTTP {}".format(response.status_code))
|
||||
|
||||
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].lower()
|
||||
if not content_type.startswith("image/"):
|
||||
response.close()
|
||||
raise ValueError("remote resource is not an image")
|
||||
|
||||
try:
|
||||
content_length = int(response.headers.get("Content-Length") or 0)
|
||||
except (TypeError, ValueError):
|
||||
content_length = 0
|
||||
if content_length > max_bytes:
|
||||
response.close()
|
||||
raise ValueError("remote image is too large")
|
||||
|
||||
chunks = []
|
||||
downloaded = 0
|
||||
try:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if not chunk:
|
||||
continue
|
||||
downloaded += len(chunk)
|
||||
if downloaded > max_bytes:
|
||||
raise ValueError("remote image is too large")
|
||||
chunks.append(chunk)
|
||||
finally:
|
||||
response.close()
|
||||
return b"".join(chunks), content_type
|
||||
|
||||
raise ValueError("too many image redirects")
|
||||
|
||||
|
||||
def upload_public_image_to_feishu(
|
||||
url: str,
|
||||
access_token: str,
|
||||
post=requests.post,
|
||||
) -> Optional[str]:
|
||||
"""Download a public image and upload its bytes to Feishu."""
|
||||
payload, content_type = download_public_image(url)
|
||||
extension = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/bmp": "bmp",
|
||||
}.get(content_type, "img")
|
||||
response = post(
|
||||
"https://open.feishu.cn/open-apis/im/v1/images",
|
||||
headers={"Authorization": "Bearer " + access_token},
|
||||
data={"image_type": "message"},
|
||||
files={
|
||||
"image": (
|
||||
"markdown-image.{}".format(extension),
|
||||
payload,
|
||||
content_type,
|
||||
)
|
||||
},
|
||||
timeout=(5, 15),
|
||||
)
|
||||
body = response.json()
|
||||
if body.get("code") != 0:
|
||||
return None
|
||||
return (body.get("data") or {}).get("image_key")
|
||||
@@ -36,11 +36,13 @@ TELEGRAM_BOT_COMMANDS = [
|
||||
("help", "Show command help"),
|
||||
("status", "Show running status"),
|
||||
("context", "View/clear conversation context (sub: clear)"),
|
||||
("tasks", "List scheduled tasks for this chat"),
|
||||
("skill", "Manage skills (list/search/install/...)"),
|
||||
("memory", "Manage memory (sub: dream)"),
|
||||
("knowledge", "Manage knowledge base (list/on/off)"),
|
||||
("config", "Show current config"),
|
||||
("cancel", "Cancel running agent task"),
|
||||
("steer", "Guide the running agent task"),
|
||||
("logs", "Show recent logs"),
|
||||
("version", "Show version"),
|
||||
]
|
||||
|
||||
@@ -47,12 +47,15 @@
|
||||
This runs synchronously in <head> so the correct class is on <html>
|
||||
before any CSS or body rendering occurs. -->
|
||||
<script>
|
||||
// Map an arbitrary locale string (zh-CN, en-US, fr ...) to 'zh' / 'en',
|
||||
// Map an arbitrary locale string (zh-CN, en-US, fr ...) to 'zh' / 'zh-Hant' / 'en',
|
||||
// or '' when unrecognized so callers can fall through to the next source.
|
||||
window.__cowNormalizeLang__ = function(raw) {
|
||||
if (!raw) return '';
|
||||
var v = String(raw).trim().toLowerCase();
|
||||
var v = String(raw).trim().toLowerCase().replace('_', '-');
|
||||
if (v === 'auto') return '';
|
||||
// Handle Traditional Chinese variants first (more specific)
|
||||
if (v === 'zh-hant' || v.indexOf('zh-hant-') === 0 || v === 'zh-tw' || v === 'zh-hk') return 'zh-Hant';
|
||||
// Then Simplified Chinese
|
||||
if (v.indexOf('zh') === 0) return 'zh';
|
||||
if (v.indexOf('en') === 0) return 'en';
|
||||
return '';
|
||||
@@ -267,14 +270,29 @@
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<!-- Language Selector (dropdown) -->
|
||||
<div id="lang-selector" class="relative">
|
||||
<button id="lang-toggle" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
|
||||
cursor-pointer transition-colors duration-150"
|
||||
onclick="toggleLanguage()">
|
||||
onclick="toggleLangMenu(event)">
|
||||
<i class="fas fa-globe text-xs"></i>
|
||||
<span id="lang-label">EN</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-60"></i>
|
||||
</button>
|
||||
<div id="lang-menu" class="hidden absolute right-0 mt-1 min-w-[120px] py-1 rounded-lg z-50
|
||||
bg-white dark:bg-slate-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10">
|
||||
<button class="lang-menu-item w-full text-left px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300
|
||||
hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer" data-lang="zh"
|
||||
onclick="selectLanguage('zh')">简体中文</button>
|
||||
<button class="lang-menu-item w-full text-left px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300
|
||||
hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer" data-lang="zh-Hant"
|
||||
onclick="selectLanguage('zh-Hant')">繁體中文</button>
|
||||
<button class="lang-menu-item w-full text-left px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300
|
||||
hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer" data-lang="en"
|
||||
onclick="selectLanguage('en')">English</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button id="theme-toggle" class="p-2 rounded-lg text-slate-500 dark:text-slate-400
|
||||
@@ -304,6 +322,14 @@
|
||||
cursor-pointer transition-colors duration-150" title="GitHub">
|
||||
<i class="fab fa-github text-lg"></i>
|
||||
</a>
|
||||
|
||||
<!-- Logout Button (hidden by default) -->
|
||||
<button id="logout-btn-header" class="p-2 rounded-lg text-slate-500 dark:text-slate-400
|
||||
hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-500/10 dark:hover:text-red-400
|
||||
cursor-pointer transition-colors duration-150 hidden"
|
||||
onclick="handleLogout()" title="Logout" data-i18n-title="logout">
|
||||
<i class="fas fa-sign-out-alt text-base"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Content Area -->
|
||||
@@ -459,6 +485,20 @@
|
||||
<i class="fas fa-microphone text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<button id="steer-btn"
|
||||
class="hidden flex-shrink-0 w-10 h-10 items-center justify-center rounded-lg
|
||||
border border-primary-300 dark:border-primary-700
|
||||
text-primary-500 hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
disabled:text-slate-300 dark:disabled:text-slate-600
|
||||
disabled:border-slate-200 dark:disabled:border-slate-700
|
||||
disabled:cursor-not-allowed cursor-pointer transition-colors duration-150"
|
||||
type="button"
|
||||
data-i18n-title="steer_active"
|
||||
data-i18n-aria-label="steer_active"
|
||||
aria-label="引导当前任务"
|
||||
title="引导当前任务">
|
||||
<i class="fas fa-arrow-turn-up text-sm"></i>
|
||||
</button>
|
||||
<button id="send-btn"
|
||||
class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg
|
||||
bg-primary-400 text-white hover:bg-primary-500
|
||||
@@ -829,11 +869,11 @@
|
||||
<!-- VIEW: Knowledge -->
|
||||
<!-- ====================================================== -->
|
||||
<div id="view-knowledge" class="view">
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10">
|
||||
<div class="w-full max-w-[1600px] mx-auto">
|
||||
<div class="flex-1 min-h-0 overflow-y-auto md:overflow-hidden p-4 md:p-8 lg:p-10 md:flex md:flex-col">
|
||||
<div class="w-full max-w-[1600px] mx-auto md:flex-1 md:min-h-0 md:flex md:flex-col">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6 md:flex-shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="knowledge_title">知识库</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="knowledge_desc">浏览和探索你的知识库</p>
|
||||
@@ -841,10 +881,6 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
|
||||
<span id="knowledge-action-status" class="text-xs opacity-0 transition-opacity duration-200"></span>
|
||||
<button onclick="createKnowledgeCategory()"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium cursor-pointer transition-colors">
|
||||
<i class="fas fa-folder-plus"></i><span data-i18n="knowledge_new_category">新建分类</span>
|
||||
</button>
|
||||
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
|
||||
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
|
||||
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
|
||||
@@ -855,6 +891,28 @@
|
||||
<i class="fas fa-diagram-project mr-1.5"></i><span data-i18n="knowledge_tab_graph">图谱</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="knowledge-new-menu" class="relative">
|
||||
<button onclick="toggleKnowledgeNewMenu(event)"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium cursor-pointer transition-colors">
|
||||
<i class="fas fa-plus"></i><span data-i18n="knowledge_new">新建</span><i class="fas fa-chevron-down text-[9px] ml-0.5"></i>
|
||||
</button>
|
||||
<div id="knowledge-new-menu-list"
|
||||
class="hidden absolute right-0 mt-1.5 w-44 z-50 bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-lg shadow-lg py-1">
|
||||
<button onclick="createKnowledgeCategory(); closeKnowledgeNewMenu()"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
|
||||
<i class="fas fa-folder-plus w-3.5 text-slate-400"></i><span data-i18n="knowledge_new_category">新建分类</span>
|
||||
</button>
|
||||
<button onclick="createKnowledgeDocument(); closeKnowledgeNewMenu()"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
|
||||
<i class="fas fa-file-circle-plus w-3.5 text-slate-400"></i><span data-i18n="knowledge_new_document">新建文档</span>
|
||||
</button>
|
||||
<button onclick="selectKnowledgeImportFiles(); closeKnowledgeNewMenu()"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
|
||||
<i class="fas fa-file-arrow-up w-3.5 text-slate-400"></i><span data-i18n="knowledge_import_documents">导入文档</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input id="knowledge-import-input" type="file" class="hidden" multiple accept=".md,.txt,text/markdown,text/plain">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -877,12 +935,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Documents panel -->
|
||||
<div id="knowledge-panel-docs" class="hidden">
|
||||
<div class="flex flex-col md:flex-row gap-4 md:gap-6" style="min-height: calc(100vh - 220px)">
|
||||
<div id="knowledge-panel-docs" class="hidden md:flex-1 md:min-h-0">
|
||||
<div class="flex flex-col md:flex-row gap-4 md:gap-6 md:h-full">
|
||||
<!-- File tree -->
|
||||
<div id="knowledge-sidebar" class="w-full md:w-72 lg:w-80 flex-shrink-0">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-slate-200 dark:border-white/10">
|
||||
<div id="knowledge-sidebar" class="w-full md:w-72 lg:w-80 flex-shrink-0 md:h-full">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden flex flex-col md:h-full">
|
||||
<div class="px-4 py-3 border-b border-slate-200 dark:border-white/10 flex-shrink-0">
|
||||
<div class="relative">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-xs"></i>
|
||||
<input id="knowledge-search" type="text" placeholder="Search..."
|
||||
@@ -890,19 +948,19 @@
|
||||
oninput="filterKnowledgeTree(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
<div id="knowledge-tree" class="p-2 overflow-y-auto max-h-[50vh] md:max-h-[calc(100vh-300px)]"></div>
|
||||
<div id="knowledge-tree" class="p-2 overflow-y-auto flex-1 max-h-[50vh] md:max-h-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content viewer -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex-1 min-w-0 md:h-full">
|
||||
<div id="knowledge-content-placeholder"
|
||||
class="flex flex-col items-center justify-center py-20 text-slate-400 dark:text-slate-500">
|
||||
class="flex flex-col items-center justify-center py-20 md:h-full text-slate-400 dark:text-slate-500 bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10">
|
||||
<i class="fas fa-file-lines text-3xl mb-3 opacity-40"></i>
|
||||
<p class="text-sm" data-i18n="knowledge_select_hint">选择一个文档查看</p>
|
||||
</div>
|
||||
<div id="knowledge-content-viewer" class="hidden">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-4 md:px-5 py-3 border-b border-slate-200 dark:border-white/10">
|
||||
<div id="knowledge-content-viewer" class="hidden md:h-full">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden flex flex-col md:h-full">
|
||||
<div class="flex items-center gap-3 px-4 md:px-5 py-3 border-b border-slate-200 dark:border-white/10 flex-shrink-0">
|
||||
<button onclick="knowledgeMobileBack()" class="md:hidden p-1 -ml-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">
|
||||
<i class="fas fa-arrow-left text-xs"></i>
|
||||
</button>
|
||||
@@ -911,8 +969,7 @@
|
||||
<span id="knowledge-viewer-path" class="text-xs text-slate-400 dark:text-slate-500 ml-auto font-mono truncate hidden md:inline"></span>
|
||||
</div>
|
||||
<div id="knowledge-viewer-body"
|
||||
class="p-4 md:p-5 overflow-y-auto text-sm msg-content text-slate-700 dark:text-slate-200"
|
||||
style="max-height: calc(100vh - 280px)"></div>
|
||||
class="p-4 md:p-5 overflow-y-auto flex-1 max-h-[60vh] md:max-h-none text-sm msg-content text-slate-700 dark:text-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1083,7 +1140,7 @@
|
||||
|
||||
<!-- Knowledge Action Dialog -->
|
||||
<div id="knowledge-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl w-full max-w-md mx-4 overflow-hidden">
|
||||
<div id="knowledge-dialog-card" class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl w-full max-w-md mx-4">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center">
|
||||
@@ -1097,8 +1154,36 @@
|
||||
<label id="knowledge-dialog-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label>
|
||||
<input id="knowledge-dialog-input" type="text"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500">
|
||||
<select id="knowledge-dialog-select"
|
||||
class="hidden w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-[#222] text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500"></select>
|
||||
<div id="knowledge-dialog-select" class="cfg-dropdown hidden w-full" tabindex="0">
|
||||
<div class="cfg-dropdown-selected">
|
||||
<span class="cfg-dropdown-text">--</span>
|
||||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||||
</div>
|
||||
<div class="cfg-dropdown-menu"></div>
|
||||
</div>
|
||||
<textarea id="knowledge-dialog-textarea" rows="8"
|
||||
class="hidden w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono resize-y"></textarea>
|
||||
<div id="knowledge-document-form" class="hidden space-y-3">
|
||||
<div class="rounded-lg bg-emerald-50 dark:bg-emerald-900/15 border border-emerald-100 dark:border-emerald-800/40 px-3 py-2">
|
||||
<div id="knowledge-document-category-label" class="text-[11px] text-emerald-600 dark:text-emerald-400 mb-0.5"></div>
|
||||
<div id="knowledge-document-path-preview" class="text-xs font-mono text-emerald-700 dark:text-emerald-300 break-all"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label id="knowledge-document-filename-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label>
|
||||
<input id="knowledge-document-filename" type="text"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500"
|
||||
placeholder="note.md">
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<label id="knowledge-document-content-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300"></label>
|
||||
<button id="knowledge-document-template" type="button" class="text-xs text-primary-500 hover:text-primary-600"></button>
|
||||
</div>
|
||||
<textarea id="knowledge-document-content" rows="14"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500 font-mono resize-y"
|
||||
placeholder="# Title Write your notes here..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p id="knowledge-dialog-hint" class="mt-2 text-xs text-slate-400 dark:text-slate-500"></p>
|
||||
<p id="knowledge-dialog-error" class="mt-2 text-xs text-red-500 hidden"></p>
|
||||
</div>
|
||||
|
||||
@@ -1293,6 +1293,18 @@
|
||||
background: rgba(74, 190, 110, 0.1);
|
||||
color: #4ABE6E;
|
||||
}
|
||||
.knowledge-import-drag-over {
|
||||
outline: 2px dashed rgba(74, 190, 110, 0.55);
|
||||
outline-offset: 4px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
#knowledge-dialog-card.knowledge-document-dialog {
|
||||
max-width: 760px;
|
||||
}
|
||||
#knowledge-document-content {
|
||||
min-height: 320px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Graph legend */
|
||||
.knowledge-graph-legend {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
@@ -79,11 +80,42 @@ def _verify_auth_token(token):
|
||||
return hmac.compare_digest(sig, expected)
|
||||
|
||||
|
||||
def _get_bearer_token():
|
||||
"""Extract the token from an `Authorization: Bearer <token>` header.
|
||||
|
||||
The desktop client renders from a file:// origin, so cross-origin cookies
|
||||
to http://127.0.0.1 are unreliable (SameSite=Lax cookies aren't sent). It
|
||||
therefore authenticates via this header instead; browsers keep using the
|
||||
cookie set by /auth/login.
|
||||
"""
|
||||
auth = web.ctx.env.get("HTTP_AUTHORIZATION", "") or ""
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:].strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _get_query_token():
|
||||
"""Extract a token from the `token` query param.
|
||||
|
||||
Needed for SSE endpoints: EventSource can't set an Authorization header,
|
||||
and file:// cookies are unreliable, so the desktop client passes the token
|
||||
in the query string for /stream and /api/logs.
|
||||
"""
|
||||
try:
|
||||
return web.input(token="").token or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _check_auth():
|
||||
"""Return True if request is authenticated or password not enabled."""
|
||||
if not _is_password_enabled():
|
||||
return True
|
||||
return _verify_auth_token(web.cookies().get("cow_auth_token", ""))
|
||||
if _verify_auth_token(web.cookies().get("cow_auth_token", "")):
|
||||
return True
|
||||
if _verify_auth_token(_get_bearer_token()):
|
||||
return True
|
||||
return _verify_auth_token(_get_query_token())
|
||||
|
||||
|
||||
def _require_auth():
|
||||
@@ -103,6 +135,36 @@ def _cancel_reply_text(cancelled: int, lang: str) -> str:
|
||||
return "Nothing to cancel." if en else "当前没有可中止的任务。"
|
||||
|
||||
|
||||
def _steer_reply_text(status, lang: str) -> str:
|
||||
from agent.protocol import SteerStatus
|
||||
|
||||
en = (lang or "").lower().startswith("en")
|
||||
messages = {
|
||||
SteerStatus.ACCEPTED: (
|
||||
"↪️ Active task redirected.", "↪️ 已引导当前任务。"
|
||||
),
|
||||
SteerStatus.INACTIVE: (
|
||||
"No active task to steer.", "当前没有可引导的任务。"
|
||||
),
|
||||
SteerStatus.CLOSING: (
|
||||
"The active task is already finishing.", "当前任务已结束,无法再引导。"
|
||||
),
|
||||
SteerStatus.AMBIGUOUS: (
|
||||
"Multiple tasks are active in this session; the steering target is ambiguous.",
|
||||
"当前会话有多个任务在运行,无法确定引导目标。",
|
||||
),
|
||||
SteerStatus.FULL: (
|
||||
"Too many steering updates are pending; try again after the agent processes them.",
|
||||
"引导指令过多,请等待当前任务处理后再试。",
|
||||
),
|
||||
SteerStatus.INVALID: (
|
||||
"Usage: /steer <instruction>", "用法:/steer <引导指令>"
|
||||
),
|
||||
}
|
||||
english, chinese = messages[status]
|
||||
return english if en else chinese
|
||||
|
||||
|
||||
def _get_upload_dir() -> str:
|
||||
from common.utils import expand_path
|
||||
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
@@ -181,6 +243,29 @@ def _read_uploaded_file_bytes(file_obj) -> bytes:
|
||||
raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}")
|
||||
|
||||
|
||||
def _read_uploaded_file_bytes_limited(file_obj, max_bytes: int) -> bytes:
|
||||
"""Read uploaded content and fail once it exceeds max_bytes."""
|
||||
if isinstance(file_obj, bytes):
|
||||
content = file_obj
|
||||
elif isinstance(file_obj, str):
|
||||
content = file_obj.encode("utf-8")
|
||||
elif hasattr(file_obj, "file") and hasattr(file_obj.file, "read"):
|
||||
content = file_obj.file.read(max_bytes + 1)
|
||||
elif hasattr(file_obj, "read"):
|
||||
content = file_obj.read(max_bytes + 1)
|
||||
elif hasattr(file_obj, "value"):
|
||||
content = file_obj.value
|
||||
else:
|
||||
raise ValueError("Unable to read uploaded file content")
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8")
|
||||
if not isinstance(content, bytes):
|
||||
raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}")
|
||||
if len(content) > max_bytes:
|
||||
raise ValueError("file too large")
|
||||
return content
|
||||
|
||||
|
||||
def _raw_web_input():
|
||||
"""Return unprocessed multipart form data when web.py exposes rawinput."""
|
||||
rawinput = getattr(getattr(web, "webapi", None), "rawinput", None)
|
||||
@@ -240,7 +325,13 @@ class WebChannel(ChatChannel):
|
||||
self.session_queues = {} # session_id -> Queue (fallback polling)
|
||||
self.request_to_session = {} # request_id -> session_id
|
||||
self.sse_queues = {} # request_id -> Queue (SSE streaming)
|
||||
# request_id -> last-active timestamp. Refreshed while the SSE
|
||||
# generator is being consumed (client still connected). The janitor
|
||||
# only reclaims queues whose generator stopped refreshing this, so a
|
||||
# long-running but still-streaming reply is never wrongly killed.
|
||||
self.sse_last_active = {}
|
||||
self._http_server = None
|
||||
self._sse_janitor_started = False
|
||||
|
||||
def _generate_msg_id(self):
|
||||
"""生成唯一的消息ID"""
|
||||
@@ -527,14 +618,29 @@ class WebChannel(ChatChannel):
|
||||
file_path = data.get("path", "")
|
||||
file_name = data.get("file_name", os.path.basename(file_path))
|
||||
file_type = data.get("file_type", "file")
|
||||
# Remote URLs are passed through as-is; local files are served
|
||||
# via the backend /api/file endpoint.
|
||||
remote_url = data.get("url", "")
|
||||
is_remote = bool(remote_url) and remote_url.lower().startswith(("http://", "https://"))
|
||||
if is_remote:
|
||||
web_url = remote_url
|
||||
else:
|
||||
from urllib.parse import quote
|
||||
web_url = f"/api/file?path={quote(file_path)}"
|
||||
is_image = file_type == "image"
|
||||
q.put({
|
||||
payload = {
|
||||
"type": "image" if is_image else "file",
|
||||
"content": web_url,
|
||||
"file_name": file_name,
|
||||
})
|
||||
# Preserve the concrete media kind (image/video/audio/...)
|
||||
# so richer clients can render an inline player.
|
||||
"file_type": file_type,
|
||||
}
|
||||
# Expose the local absolute path so the desktop client can open
|
||||
# the file directly (Finder / default app) instead of the browser.
|
||||
if not is_remote and file_path:
|
||||
payload["abs_path"] = file_path
|
||||
q.put(payload)
|
||||
|
||||
return on_event
|
||||
|
||||
@@ -837,6 +943,37 @@ class WebChannel(ChatChannel):
|
||||
"inline_reply": msg_text,
|
||||
})
|
||||
|
||||
# Explicit steering also bypasses the normal session queue. The
|
||||
# Web button sends ``steer: true`` with raw input; typed /steer
|
||||
# commands use the same endpoint and semantics as IM channels.
|
||||
steer_requested = bool(json_data.get("steer", False))
|
||||
is_steer_command = (
|
||||
re.match(r"^/steer(?:\s|$)", stripped_prompt) is not None
|
||||
)
|
||||
if steer_requested or is_steer_command:
|
||||
instruction = (
|
||||
(prompt or "").strip()[len("/steer"):].strip()
|
||||
if is_steer_command
|
||||
else (prompt or "").strip()
|
||||
)
|
||||
from bridge.bridge import Bridge
|
||||
result = Bridge().get_agent_bridge().steer_session(
|
||||
session_id, instruction
|
||||
)
|
||||
lang = (json_data.get("lang") or "zh").lower()
|
||||
msg_text = _steer_reply_text(result.status, lang)
|
||||
logger.info(
|
||||
f"[WebChannel] steer fast-path: session={session_id}, "
|
||||
f"status={result.status.value}, lang={lang}"
|
||||
)
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"request_id": "",
|
||||
"stream": False,
|
||||
"steered": result.accepted,
|
||||
"inline_reply": msg_text,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# Append file references to the prompt (same format as QQ channel)
|
||||
if attachments:
|
||||
file_refs = []
|
||||
@@ -865,6 +1002,7 @@ class WebChannel(ChatChannel):
|
||||
|
||||
if use_sse:
|
||||
self.sse_queues[request_id] = Queue()
|
||||
self.sse_last_active[request_id] = time.time()
|
||||
|
||||
trigger_prefixs = conf().get("single_chat_prefix", [""])
|
||||
if check_prefix(prompt, trigger_prefixs) is None:
|
||||
@@ -879,8 +1017,7 @@ class WebChannel(ChatChannel):
|
||||
|
||||
if context is None:
|
||||
logger.warning(f"[WebChannel] Context is None for session {session_id}, message may be filtered")
|
||||
if request_id in self.sse_queues:
|
||||
del self.sse_queues[request_id]
|
||||
self._drop_sse_request(request_id)
|
||||
return json.dumps({"status": "error", "message": "Message was filtered"})
|
||||
|
||||
context["session_id"] = session_id
|
||||
@@ -903,6 +1040,60 @@ class WebChannel(ChatChannel):
|
||||
logger.error(f"Error processing message: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
def _drop_sse_request(self, request_id: str):
|
||||
"""Reclaim all state tied to an SSE request to prevent fd/memory leaks.
|
||||
|
||||
Removing the queue lets the WSGI generator and its socket be released,
|
||||
and dropping request_to_session avoids unbounded map growth.
|
||||
"""
|
||||
self.sse_queues.pop(request_id, None)
|
||||
self.sse_last_active.pop(request_id, None)
|
||||
self.request_to_session.pop(request_id, None)
|
||||
|
||||
def _start_sse_janitor(self):
|
||||
"""Start a background thread that reclaims orphaned SSE queues.
|
||||
|
||||
When a client disconnects before the "done" event arrives (browser
|
||||
closed, session switched, network drop), the generator may keep the
|
||||
queue around to allow reconnection. Without a sweep these orphans
|
||||
accumulate, leaking file descriptors until cheroot raises
|
||||
"[Errno 24] Too many open files".
|
||||
|
||||
Reclamation is based on idle time, not total age: an active stream
|
||||
refreshes ``sse_last_active`` every second while its generator is being
|
||||
consumed, so a long-running reply (even hours long) is never killed
|
||||
while the client stays connected. Only queues that stopped refreshing
|
||||
(client gone) past SSE_IDLE_TIMEOUT are reclaimed.
|
||||
"""
|
||||
if self._sse_janitor_started:
|
||||
return
|
||||
self._sse_janitor_started = True
|
||||
|
||||
SSE_IDLE_TIMEOUT = 1800 # 30 minutes with no client consumption
|
||||
SWEEP_INTERVAL = 60
|
||||
|
||||
def _sweep():
|
||||
while True:
|
||||
time.sleep(SWEEP_INTERVAL)
|
||||
try:
|
||||
now = time.time()
|
||||
stale = [
|
||||
rid for rid, ts in list(self.sse_last_active.items())
|
||||
if now - ts > SSE_IDLE_TIMEOUT
|
||||
]
|
||||
for rid in stale:
|
||||
self._drop_sse_request(rid)
|
||||
if stale:
|
||||
logger.info(
|
||||
f"[WebChannel] SSE janitor reclaimed {len(stale)} "
|
||||
f"idle stream(s)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebChannel] SSE janitor error: {e}")
|
||||
|
||||
t = threading.Thread(target=_sweep, name="sse-janitor", daemon=True)
|
||||
t.start()
|
||||
|
||||
def stream_response(self, request_id: str):
|
||||
"""
|
||||
SSE generator for a given request_id.
|
||||
@@ -927,6 +1118,10 @@ class WebChannel(ChatChannel):
|
||||
|
||||
try:
|
||||
while time.time() < deadline:
|
||||
# Mark the stream alive on every loop. While the client keeps
|
||||
# consuming, the generator runs and refreshes this, so the
|
||||
# janitor won't reclaim a long-running but active stream.
|
||||
self.sse_last_active[request_id] = time.time()
|
||||
try:
|
||||
item = q.get(timeout=1)
|
||||
except Empty:
|
||||
@@ -955,13 +1150,21 @@ class WebChannel(ChatChannel):
|
||||
# voice_attach payload through to the browser.
|
||||
post_done = True
|
||||
post_deadline = time.time() + 2 # 2s post-attach tail
|
||||
except GeneratorExit:
|
||||
# Client disconnected (WSGI closed the generator). If the reply is
|
||||
# already complete there is nothing to resume, so reclaim now to
|
||||
# release the socket fd. Otherwise keep the queue briefly so a
|
||||
# reconnect with the same request_id can resume; the janitor will
|
||||
# reclaim it if no reconnect happens.
|
||||
if post_done:
|
||||
self._drop_sse_request(request_id)
|
||||
raise
|
||||
finally:
|
||||
# Only drop the queue once the reply is actually complete. If the
|
||||
# client disconnected early (e.g. switched sessions and will
|
||||
# re-attach with the same request_id), keep the queue so the new
|
||||
# connection can resume reading the remaining events.
|
||||
# Drop the queue once the reply is actually complete or the idle
|
||||
# deadline has passed. Early client disconnects are handled by the
|
||||
# GeneratorExit branch above and the background janitor.
|
||||
if post_done or time.time() >= deadline:
|
||||
self.sse_queues.pop(request_id, None)
|
||||
self._drop_sse_request(request_id)
|
||||
|
||||
def cancel_request(self):
|
||||
"""
|
||||
@@ -1062,7 +1265,10 @@ class WebChannel(ChatChannel):
|
||||
def startup(self):
|
||||
configured_host = conf().get("web_host", "")
|
||||
host = configured_host or ("0.0.0.0" if _is_password_enabled() else "127.0.0.1")
|
||||
port = conf().get("web_port", 9899)
|
||||
# The desktop app passes its chosen port via COW_WEB_PORT so its backend
|
||||
# never collides with a source-run web console (default 9899). This makes
|
||||
# the port a single source of truth owned by the Electron shell.
|
||||
port = int(os.environ.get("COW_WEB_PORT") or conf().get("web_port", 9899))
|
||||
is_public_bind = host in ("0.0.0.0", "::")
|
||||
|
||||
self._cleanup_stale_voice_recordings()
|
||||
@@ -1136,6 +1342,7 @@ class WebChannel(ChatChannel):
|
||||
|
||||
urls = (
|
||||
'/', 'RootHandler',
|
||||
'/api/health', 'HealthHandler',
|
||||
'/auth/login', 'AuthLoginHandler',
|
||||
'/auth/check', 'AuthCheckHandler',
|
||||
'/auth/logout', 'AuthLogoutHandler',
|
||||
@@ -1162,7 +1369,9 @@ class WebChannel(ChatChannel):
|
||||
'/api/knowledge/read', 'KnowledgeReadHandler',
|
||||
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
||||
'/api/knowledge/action', 'KnowledgeActionHandler',
|
||||
'/api/knowledge/import', 'KnowledgeImportHandler',
|
||||
'/api/scheduler', 'SchedulerHandler',
|
||||
'/api/scheduler/run', 'SchedulerRunHandler',
|
||||
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
||||
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
||||
'/api/scheduler/delete', 'SchedulerDeleteHandler',
|
||||
@@ -1174,6 +1383,7 @@ class WebChannel(ChatChannel):
|
||||
'/api/messages/delete', 'MessageDeleteHandler',
|
||||
'/api/logs', 'LogsHandler',
|
||||
'/api/version', 'VersionHandler',
|
||||
'/mcp/oauth/callback', 'McpOAuthCallbackHandler',
|
||||
'/assets/(.*)', 'AssetsHandler',
|
||||
)
|
||||
app = web.application(urls, globals(), autoreload=False)
|
||||
@@ -1198,6 +1408,8 @@ class WebChannel(ChatChannel):
|
||||
server.requests.min = 20
|
||||
server.requests.max = 80
|
||||
self._http_server = server
|
||||
# Reclaim orphaned SSE queues so disconnected clients don't leak fds.
|
||||
self._start_sse_janitor()
|
||||
try:
|
||||
server.start()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
@@ -1225,6 +1437,74 @@ class RootHandler:
|
||||
raise web.seeother('/chat')
|
||||
|
||||
|
||||
class HealthHandler:
|
||||
# Unauthenticated liveness probe. The desktop shell polls this to know the
|
||||
# backend is up; it must never require auth (a set web_password would
|
||||
# otherwise make startup hang). Returns no sensitive data.
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
web.header('Cache-Control', 'no-store')
|
||||
return json.dumps({"status": "ok"})
|
||||
|
||||
|
||||
class McpOAuthCallbackHandler:
|
||||
"""OAuth redirect target for MCP servers requiring authorization.
|
||||
|
||||
The browser lands here after the user authorizes a remote MCP server.
|
||||
We exchange the authorization code for tokens and bring the server
|
||||
online. Unauthenticated by design: the OAuth `state` param is the
|
||||
single-use secret that binds this request to a pending authorization.
|
||||
"""
|
||||
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'text/html; charset=utf-8')
|
||||
params = web.input(code="", state="", error="", error_description="")
|
||||
|
||||
def _page(title: str, message: str) -> str:
|
||||
return (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
||||
f"<title>{title}</title></head>"
|
||||
"<body style='font-family:-apple-system,Segoe UI,Roboto,sans-serif;"
|
||||
"max-width:520px;margin:64px auto;padding:0 20px;text-align:center;color:#1f2328'>"
|
||||
f"<h2>{title}</h2><p style='color:#57606a'>{message}</p></body></html>"
|
||||
)
|
||||
|
||||
if params.error:
|
||||
logger.warning(f"[MCP-OAuth] callback error: {params.error} {params.error_description}")
|
||||
return _page("授权失败", f"{params.error}: {params.error_description or ''}")
|
||||
|
||||
if not params.code or not params.state:
|
||||
return _page("参数缺失", "回调缺少 code 或 state 参数。")
|
||||
|
||||
try:
|
||||
from agent.tools.mcp.mcp_oauth import pop_pending
|
||||
from agent.tools.mcp.mcp_client import notify_server_authorized
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth] callback import failed: {e}")
|
||||
return _page("内部错误", "OAuth 模块不可用。")
|
||||
|
||||
handler = pop_pending(params.state)
|
||||
if handler is None:
|
||||
return _page("会话已过期", "授权请求不存在或已过期,请重新触发授权。")
|
||||
|
||||
try:
|
||||
ok = handler.finish_authorization(params.code)
|
||||
except Exception as e:
|
||||
logger.warning(f"[MCP-OAuth] token exchange crashed: {e}")
|
||||
ok = False
|
||||
|
||||
if not ok:
|
||||
return _page("授权失败", "换取令牌失败,请重试。")
|
||||
|
||||
notify_server_authorized(handler.server_name)
|
||||
logger.info(f"[MCP-OAuth] Server '{handler.server_name}' authorized via web callback")
|
||||
return _page(
|
||||
"授权成功",
|
||||
f"MCP 服务 “{handler.server_name}” 已授权,可以返回聊天继续使用了。",
|
||||
)
|
||||
|
||||
|
||||
class AuthCheckHandler:
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
@@ -1252,7 +1532,9 @@ class AuthLoginHandler:
|
||||
token = _create_auth_token()
|
||||
web.setcookie("cow_auth_token", token, expires=_session_expire_seconds(),
|
||||
path="/", httponly=True, samesite="Lax")
|
||||
return json.dumps({"status": "success"})
|
||||
# Also return the token in the body: the desktop client (file:// origin)
|
||||
# can't rely on the cookie and sends it back via an Authorization header.
|
||||
return json.dumps({"status": "success", "token": token})
|
||||
|
||||
|
||||
class AuthLogoutHandler:
|
||||
@@ -1481,15 +1763,14 @@ class ConfigHandler:
|
||||
_RECOMMENDED_MODELS = [
|
||||
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
||||
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
||||
# claude-fable-5 is placed after claude-opus-4-7 (not as the Claude
|
||||
# default) since it is often unavailable due to policy restrictions.
|
||||
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
||||
# claude-sonnet-5 is the Claude default; claude-fable-5 follows right after it.
|
||||
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.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.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
||||
const.DOUBAO_SEED_2_PRO, 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.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
|
||||
const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
||||
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
||||
]
|
||||
@@ -1528,7 +1809,7 @@ class ConfigHandler:
|
||||
"api_base_key": "claude_api_base",
|
||||
"api_base_default": "https://api.anthropic.com/v1",
|
||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||
"models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, 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", {
|
||||
"label": "Gemini",
|
||||
@@ -1544,7 +1825,7 @@ class ConfigHandler:
|
||||
"api_base_key": "open_ai_api_base",
|
||||
"api_base_default": "https://api.openai.com/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", {
|
||||
"label": {"zh": "智谱AI", "en": "GLM"},
|
||||
@@ -1568,7 +1849,7 @@ class ConfigHandler:
|
||||
"api_base_key": "ark_base_url",
|
||||
"api_base_default": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"api_base_placeholder": _PLACEHOLDER_DOUBAO,
|
||||
"models": [const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE],
|
||||
"models": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE],
|
||||
}),
|
||||
("moonshot", {
|
||||
"label": "Kimi",
|
||||
@@ -1576,7 +1857,7 @@ class ConfigHandler:
|
||||
"api_base_key": "moonshot_base_url",
|
||||
"api_base_default": "https://api.moonshot.cn/v1",
|
||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||
"models": [const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||
"models": [const.KIMI_K3, const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||
}),
|
||||
("qianfan", {
|
||||
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
||||
@@ -1691,7 +1972,7 @@ class ConfigHandler:
|
||||
raw_pwd = str(local_config.get("web_password", "") or "")
|
||||
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
||||
|
||||
return json.dumps({
|
||||
result = {
|
||||
"status": "success",
|
||||
"use_agent": use_agent,
|
||||
"title": title,
|
||||
@@ -1708,7 +1989,13 @@ class ConfigHandler:
|
||||
"api_keys": api_keys_masked,
|
||||
"providers": providers,
|
||||
"web_password_masked": masked_pwd,
|
||||
}, ensure_ascii=False)
|
||||
}
|
||||
# The desktop app runs on the local trusted machine, so it can edit
|
||||
# the real password in place (cursor at the end, delete to clear).
|
||||
# Browser access only ever sees the masked value.
|
||||
if os.environ.get("COW_DESKTOP") == "1":
|
||||
result["web_password"] = raw_pwd
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting config: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
@@ -1738,9 +2025,13 @@ class ConfigHandler:
|
||||
return json.dumps({"status": "error", "message": "no valid keys to update"})
|
||||
|
||||
config_path = os.path.join(get_data_root(), "config.json")
|
||||
old_password = "" # Store old password before update
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
file_cfg = json.load(f)
|
||||
# Capture old password before updating
|
||||
if "web_password" in applied:
|
||||
old_password = file_cfg.get("web_password", "")
|
||||
else:
|
||||
file_cfg = {}
|
||||
file_cfg.update(applied)
|
||||
@@ -1758,6 +2049,26 @@ class ConfigHandler:
|
||||
except Exception as lang_err:
|
||||
logger.warning(f"[WebChannel] Failed to apply language: {lang_err}")
|
||||
|
||||
# Check if password was cleared: if there was a password before clearing,
|
||||
# the service is likely bound to 0.0.0.0 (public), so warn the user.
|
||||
password_warning = None
|
||||
if "web_password" in applied:
|
||||
new_password = applied["web_password"]
|
||||
configured_host = file_cfg.get("web_host", "")
|
||||
|
||||
# If password was cleared and there was a password before
|
||||
if not new_password and old_password:
|
||||
# If web_host is not explicitly set, the service auto-binds based on password
|
||||
# With password → 0.0.0.0 (public), without password → 127.0.0.1 (local)
|
||||
# So clearing password when it was previously set means going from public to local
|
||||
if not configured_host or configured_host == "0.0.0.0":
|
||||
password_warning = "password_cleared_with_public_host"
|
||||
logger.warning(
|
||||
"[WebChannel] Password cleared while service is likely bound to 0.0.0.0. "
|
||||
"Consider restarting the service to rebind to 127.0.0.1 "
|
||||
"or explicitly set web_host in config to prevent unauthorized access."
|
||||
)
|
||||
|
||||
# Reset Bridge so that bot routing reflects the new config.
|
||||
# Without this, Bridge keeps its cached bot instance (e.g. LinkAIBot)
|
||||
# even after the user switches bot_type / use_linkai / model in UI.
|
||||
@@ -1770,7 +2081,7 @@ class ConfigHandler:
|
||||
except Exception as reset_err:
|
||||
logger.warning(f"[WebChannel] Failed to reset bridge: {reset_err}")
|
||||
|
||||
return json.dumps({"status": "success", "applied": applied}, ensure_ascii=False)
|
||||
return json.dumps({"status": "success", "applied": applied, "warning": password_warning}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating config: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
@@ -2090,9 +2401,12 @@ class ModelsHandler:
|
||||
# Anything not listed here intentionally hides the model dropdown so
|
||||
# users cannot pin a chat-only model and silently get a 4xx at runtime.
|
||||
_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.
|
||||
"openai": [
|
||||
const.GPT_56_LUNA,
|
||||
const.GPT_56_TERRA,
|
||||
const.GPT_56_SOL,
|
||||
const.GPT_55,
|
||||
const.GPT_54,
|
||||
const.GPT_54_MINI,
|
||||
@@ -2102,10 +2416,10 @@ class ModelsHandler:
|
||||
const.GPT_41_MINI,
|
||||
const.GPT_4o,
|
||||
],
|
||||
"doubao": [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],
|
||||
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
|
||||
"claudeAPI": [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],
|
||||
"qianfan": [const.ERNIE_45_TURBO_VL],
|
||||
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
||||
@@ -2126,9 +2440,10 @@ class ModelsHandler:
|
||||
const.GPT_41_MINI,
|
||||
const.GPT_54_MINI,
|
||||
const.QWEN37_PLUS,
|
||||
const.DOUBAO_SEED_2_PRO,
|
||||
const.DOUBAO_SEED_2_1_PRO,
|
||||
const.KIMI_K2_6,
|
||||
const.CLAUDE_4_6_SONNET,
|
||||
const.CLAUDE_SONNET_5,
|
||||
const.CLAUDE_FABLE_5,
|
||||
const.GEMINI_31_FLASH_LITE_PRE,
|
||||
],
|
||||
# Custom OpenAI-compatible providers have no preset list — model
|
||||
@@ -2357,7 +2672,7 @@ class ModelsHandler:
|
||||
("moonshot", "moonshot_api_key", const.KIMI_K2_6),
|
||||
("doubao", "ark_api_key", const.DOUBAO_SEED_2_PRO),
|
||||
("dashscope", "dashscope_api_key", const.QWEN37_PLUS),
|
||||
("claudeAPI", "claude_api_key", const.CLAUDE_4_6_SONNET),
|
||||
("claudeAPI", "claude_api_key", const.CLAUDE_SONNET_5),
|
||||
("gemini", "gemini_api_key", const.GEMINI_35_FLASH),
|
||||
("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL),
|
||||
("zhipu", "zhipu_ai_api_key", const.GLM_5V_TURBO),
|
||||
@@ -3551,11 +3866,13 @@ class ChannelsHandler:
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
from common import i18n
|
||||
local_config = conf()
|
||||
active_channels = self._active_channel_set()
|
||||
# Desktop build ships without lark-oapi, so hide Feishu from the list.
|
||||
desktop_mode = os.environ.get("COW_DESKTOP") == "1"
|
||||
channels = []
|
||||
is_hant = i18n.get_language() == i18n.ZH_HANT
|
||||
for ch_name, ch_def in self.CHANNEL_DEFS.items():
|
||||
if desktop_mode and ch_name == "feishu":
|
||||
continue
|
||||
@@ -3566,16 +3883,32 @@ class ChannelsHandler:
|
||||
display_val = self._mask_secret(str(raw_val))
|
||||
else:
|
||||
display_val = raw_val
|
||||
|
||||
label_val = f["label"]
|
||||
if is_hant and isinstance(label_val, str):
|
||||
label_val = i18n.to_traditional(label_val)
|
||||
elif is_hant and isinstance(label_val, dict):
|
||||
label_val = label_val.copy()
|
||||
label_val["zh-Hant"] = i18n.to_traditional(label_val.get("zh", ""))
|
||||
|
||||
fields_out.append({
|
||||
"key": f["key"],
|
||||
"label": f["label"],
|
||||
"label": label_val,
|
||||
"type": f["type"],
|
||||
"value": display_val,
|
||||
"default": f.get("default", ""),
|
||||
})
|
||||
|
||||
label_val = ch_def["label"]
|
||||
if is_hant and isinstance(label_val, str):
|
||||
label_val = i18n.to_traditional(label_val)
|
||||
elif is_hant and isinstance(label_val, dict):
|
||||
label_val = label_val.copy()
|
||||
label_val["zh-Hant"] = i18n.to_traditional(label_val.get("zh", ""))
|
||||
|
||||
ch_info = {
|
||||
"name": ch_name,
|
||||
"label": ch_def["label"],
|
||||
"label": label_val,
|
||||
"icon": ch_def["icon"],
|
||||
"color": ch_def["color"],
|
||||
"active": ch_name in active_channels,
|
||||
@@ -4132,16 +4465,26 @@ class ToolsHandler:
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
from agent.tools.tool_manager import ToolManager
|
||||
from common import i18n
|
||||
tm = ToolManager()
|
||||
if not tm.tool_classes:
|
||||
tm.load_tools()
|
||||
tools = []
|
||||
lang = i18n.get_language()
|
||||
for name, cls in tm.tool_classes.items():
|
||||
try:
|
||||
instance = cls()
|
||||
desc = instance.description
|
||||
if lang == i18n.ZH_HANT and desc:
|
||||
desc = i18n.to_traditional(desc)
|
||||
elif lang == "en" and name == "scheduler":
|
||||
desc = (
|
||||
"Create, query and manage scheduled tasks (reminders, periodic tasks, etc.).\n\n"
|
||||
"⚠️ IMPORTANT: Only use this tool when delayed or periodic execution is needed."
|
||||
)
|
||||
tools.append({
|
||||
"name": name,
|
||||
"description": instance.description,
|
||||
"description": desc,
|
||||
})
|
||||
except Exception:
|
||||
tools.append({"name": name, "description": ""})
|
||||
@@ -4158,10 +4501,17 @@ class SkillsHandler:
|
||||
try:
|
||||
from agent.skills.service import SkillService
|
||||
from agent.skills.manager import SkillManager
|
||||
from common import i18n
|
||||
workspace_root = _get_workspace_root()
|
||||
manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills"))
|
||||
service = SkillService(manager)
|
||||
skills = service.query()
|
||||
if i18n.get_language() == i18n.ZH_HANT:
|
||||
for skill in skills:
|
||||
if isinstance(skill, dict):
|
||||
for k, v in list(skill.items()):
|
||||
if k in ("name", "description", "display_name") and isinstance(v, str):
|
||||
skill[k] = i18n.to_traditional(v)
|
||||
return json.dumps({"status": "success", "skills": skills}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Skills API error: {e}")
|
||||
@@ -4250,6 +4600,34 @@ class SchedulerHandler:
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SchedulerRunHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
body = json.loads(web.data())
|
||||
task_id = body.get("task_id")
|
||||
if not task_id:
|
||||
return json.dumps({"status": "error", "message": "task_id required"})
|
||||
|
||||
from agent.tools.scheduler.integration import get_scheduler_service
|
||||
service = get_scheduler_service()
|
||||
if service is None:
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"message": "Scheduler service is not running",
|
||||
})
|
||||
|
||||
service.run_task_now(task_id)
|
||||
return json.dumps({
|
||||
"status": "success",
|
||||
"message": f"Task '{task_id}' queued for immediate execution",
|
||||
}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Scheduler manual run error: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
|
||||
class SchedulerToggleHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
@@ -4323,11 +4701,33 @@ class SchedulerUpdateHandler:
|
||||
|
||||
# Update action
|
||||
if "action" in body:
|
||||
action = body["action"]
|
||||
channel_type = action.get("channel_type", "web")
|
||||
|
||||
# Get the task's original channel_type
|
||||
old_channel = original_task.get("action", {}).get("channel_type", "web")
|
||||
original_action = original_task.get("action", {})
|
||||
if not isinstance(original_action, dict):
|
||||
original_action = {}
|
||||
action_patch = body["action"]
|
||||
if not isinstance(action_patch, dict):
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"message": "Action must be an object."
|
||||
}, ensure_ascii=False)
|
||||
|
||||
# The Web editor only exposes a subset of action fields. Merge
|
||||
# that patch into the stored action so scheduler metadata such
|
||||
# as notify_session_id, silent, and channel-specific delivery
|
||||
# fields survive unrelated edits.
|
||||
action = dict(original_action)
|
||||
action.update(action_patch)
|
||||
action_type = action.get("type")
|
||||
if action_type == "send_message":
|
||||
action.pop("task_description", None)
|
||||
action.pop("silent", None)
|
||||
elif action_type == "agent_task":
|
||||
action.pop("content", None)
|
||||
|
||||
old_channel = original_action.get("channel_type", "web")
|
||||
channel_type = action.get("channel_type") or old_channel
|
||||
action["channel_type"] = channel_type
|
||||
|
||||
# If channel type changed or no receiver, reject the update.
|
||||
# Note: the web UI disables the channel selector, so this branch
|
||||
@@ -4731,6 +5131,71 @@ class KnowledgeActionHandler:
|
||||
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
|
||||
|
||||
|
||||
class KnowledgeImportHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
from agent.knowledge.service import KnowledgeService
|
||||
content_length = int(getattr(web.ctx, "env", {}).get("CONTENT_LENGTH") or 0)
|
||||
if content_length > KnowledgeService.MAX_IMPORT_TOTAL_SIZE:
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"code": 413,
|
||||
"message": "import batch too large",
|
||||
"payload": None,
|
||||
})
|
||||
params = _raw_web_input()
|
||||
target_category = params.get("target_category", "")
|
||||
conflict_strategy = params.get("conflict_strategy", "skip")
|
||||
uploaded = _ensure_list(params.get("files"))
|
||||
single = params.get("file")
|
||||
if single is not None:
|
||||
uploaded.append(single)
|
||||
if not uploaded:
|
||||
return json.dumps({"status": "error", "code": 400, "message": "No files uploaded", "payload": None})
|
||||
if len(uploaded) > KnowledgeService.MAX_IMPORT_FILES:
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"code": 400,
|
||||
"message": f"too many files: max {KnowledgeService.MAX_IMPORT_FILES}",
|
||||
"payload": None,
|
||||
})
|
||||
|
||||
files = []
|
||||
total_size = 0
|
||||
for file_obj in uploaded:
|
||||
if file_obj is None:
|
||||
continue
|
||||
filename = getattr(file_obj, "filename", "") or getattr(file_obj, "name", "")
|
||||
content = _read_uploaded_file_bytes_limited(file_obj, KnowledgeService.MAX_IMPORT_FILE_SIZE)
|
||||
total_size += len(content)
|
||||
if total_size > KnowledgeService.MAX_IMPORT_TOTAL_SIZE:
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"code": 413,
|
||||
"message": "import batch too large",
|
||||
"payload": None,
|
||||
})
|
||||
files.append({
|
||||
"filename": filename,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
result = KnowledgeService(_get_workspace_root()).dispatch("import_documents", {
|
||||
"target_category": target_category,
|
||||
"conflict_strategy": conflict_strategy,
|
||||
"files": files,
|
||||
})
|
||||
return json.dumps({
|
||||
"status": "success" if result["code"] < 300 else "error",
|
||||
**result,
|
||||
}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Knowledge import error: {e}", exc_info=True)
|
||||
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
|
||||
|
||||
|
||||
class VersionHandler:
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.1.1
|
||||
2.1.3
|
||||
|
||||
@@ -7,6 +7,7 @@ from cli.commands.process import start, stop, restart, self_restart, update, sta
|
||||
from cli.commands.context import context
|
||||
from cli.commands.install import install_browser
|
||||
from cli.commands.knowledge import knowledge
|
||||
from cli.commands.backup import backup_command, restore_command
|
||||
|
||||
|
||||
HELP_TEXT = """Usage: cow COMMAND [ARGS]...
|
||||
@@ -24,6 +25,8 @@ Commands:
|
||||
logs View CowAgent logs.
|
||||
skill Manage CowAgent skills.
|
||||
knowledge Manage knowledge base.
|
||||
backup Back up config and agent workspace.
|
||||
restore Restore a CowAgent backup.
|
||||
install-browser Install browser tool (Playwright + Chromium).
|
||||
|
||||
Tip: Memory index management lives in chat — send /memory status or
|
||||
@@ -74,6 +77,8 @@ main.add_command(status)
|
||||
main.add_command(logs)
|
||||
main.add_command(context)
|
||||
main.add_command(knowledge)
|
||||
main.add_command(backup_command)
|
||||
main.add_command(restore_command)
|
||||
main.add_command(install_browser)
|
||||
|
||||
|
||||
|
||||
332
cli/commands/backup.py
Normal file
332
cli/commands/backup.py
Normal file
@@ -0,0 +1,332 @@
|
||||
"""Portable local backup and restore commands for CowAgent user data."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Iterable, Optional, Set
|
||||
|
||||
import click
|
||||
|
||||
from cli.utils import get_project_root
|
||||
|
||||
|
||||
BACKUP_FORMAT = "cowagent-backup"
|
||||
BACKUP_VERSION = 1
|
||||
_SKIP_DIRS = {".git", "__pycache__", "tmp"}
|
||||
_SKIP_FILES = {".DS_Store"}
|
||||
|
||||
|
||||
def _data_root() -> Path:
|
||||
configured = os.environ.get("COW_DATA_DIR")
|
||||
return Path(configured).expanduser().resolve() if configured else Path(get_project_root()).resolve()
|
||||
|
||||
|
||||
def _read_config(data_root: Path) -> dict:
|
||||
path = data_root / "config.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _workspace_from_config(config: dict) -> Path:
|
||||
return Path(config.get("agent_workspace") or "~/cow").expanduser().resolve()
|
||||
|
||||
|
||||
def _legacy_user_data_path(data_root: Path, config: dict) -> Path:
|
||||
appdata_dir = config.get("appdata_dir") or ""
|
||||
return (data_root / appdata_dir / "user_datas.pkl").resolve()
|
||||
|
||||
|
||||
def _is_within(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
return os.path.commonpath([str(path.resolve()), str(root.resolve())]) == str(root.resolve())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _iter_workspace_files(workspace: Path, excluded: Set[Path]):
|
||||
if not workspace.is_dir():
|
||||
return
|
||||
for current, dirnames, filenames in os.walk(str(workspace), followlinks=False):
|
||||
current_path = Path(current)
|
||||
dirnames[:] = [
|
||||
name for name in dirnames
|
||||
if name not in _SKIP_DIRS and not (current_path / name).is_symlink()
|
||||
]
|
||||
for name in filenames:
|
||||
path = current_path / name
|
||||
if name in _SKIP_FILES or name.endswith((".pyc", ".pyo")):
|
||||
continue
|
||||
if path.is_symlink() or path.resolve() in excluded:
|
||||
continue
|
||||
if path.is_file():
|
||||
yield path
|
||||
|
||||
|
||||
def create_backup_archive(
|
||||
output: Path,
|
||||
data_root: Path,
|
||||
workspace: Path,
|
||||
excluded_paths: Optional[Iterable[Path]] = None,
|
||||
) -> dict:
|
||||
"""Create a portable archive containing config and the agent workspace."""
|
||||
output = Path(output).expanduser().resolve()
|
||||
data_root = Path(data_root).expanduser().resolve()
|
||||
workspace = Path(workspace).expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
excluded = {Path(path).expanduser().resolve() for path in (excluded_paths or [])}
|
||||
excluded.add(output)
|
||||
|
||||
config_path = data_root / "config.json"
|
||||
config = _read_config(data_root)
|
||||
legacy_path = _legacy_user_data_path(data_root, config)
|
||||
workspace_files = list(_iter_workspace_files(workspace, excluded))
|
||||
total_bytes = sum(path.stat().st_size for path in workspace_files)
|
||||
|
||||
manifest = {
|
||||
"format": BACKUP_FORMAT,
|
||||
"version": BACKUP_VERSION,
|
||||
"created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
"workspace_source": str(workspace),
|
||||
"contents": {
|
||||
"config": config_path.is_file(),
|
||||
"legacy_user_data": legacy_path.is_file(),
|
||||
"workspace_files": len(workspace_files),
|
||||
"workspace_bytes": total_bytes,
|
||||
},
|
||||
}
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cowagent-backup-"))
|
||||
temp_archive = temp_dir / "backup.zip"
|
||||
try:
|
||||
with zipfile.ZipFile(
|
||||
str(temp_archive), "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True
|
||||
) as archive:
|
||||
archive.writestr(
|
||||
"manifest.json",
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
)
|
||||
if config_path.is_file():
|
||||
archive.write(str(config_path), "data/config.json")
|
||||
if legacy_path.is_file():
|
||||
archive.write(str(legacy_path), "data/user_datas.pkl")
|
||||
for path in workspace_files:
|
||||
relative = path.relative_to(workspace).as_posix()
|
||||
archive.write(str(path), "workspace/" + relative)
|
||||
os.replace(str(temp_archive), str(output))
|
||||
try:
|
||||
os.chmod(str(output), stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
shutil.rmtree(str(temp_dir), ignore_errors=True)
|
||||
|
||||
manifest["archive"] = str(output)
|
||||
return manifest
|
||||
|
||||
|
||||
def _validate_archive(archive: zipfile.ZipFile) -> dict:
|
||||
names = {info.filename for info in archive.infolist()}
|
||||
if "manifest.json" not in names:
|
||||
raise ValueError("archive is missing manifest.json")
|
||||
try:
|
||||
manifest = json.loads(archive.read("manifest.json").decode("utf-8"))
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise ValueError("archive manifest is invalid") from exc
|
||||
if manifest.get("format") != BACKUP_FORMAT or manifest.get("version") != BACKUP_VERSION:
|
||||
raise ValueError("unsupported CowAgent backup format or version")
|
||||
|
||||
for info in archive.infolist():
|
||||
name = info.filename
|
||||
path = PurePosixPath(name)
|
||||
if not name or path.is_absolute() or ".." in path.parts or "\\" in name:
|
||||
raise ValueError(f"unsafe archive path: {name!r}")
|
||||
mode = (info.external_attr >> 16) & 0o170000
|
||||
if mode == stat.S_IFLNK:
|
||||
raise ValueError(f"symbolic links are not allowed in backups: {name!r}")
|
||||
if name != "manifest.json" and not name.startswith(("data/", "workspace/")):
|
||||
raise ValueError(f"unexpected archive entry: {name!r}")
|
||||
return manifest
|
||||
|
||||
|
||||
def _extract_validated(archive: zipfile.ZipFile, destination: Path) -> None:
|
||||
for info in archive.infolist():
|
||||
target = destination.joinpath(*PurePosixPath(info.filename).parts)
|
||||
if info.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(info, "r") as source, target.open("wb") as output:
|
||||
shutil.copyfileobj(source, output)
|
||||
|
||||
|
||||
def _atomic_copy(source: Path, destination: Path, private: bool = False) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temp_name = tempfile.mkstemp(prefix=destination.name + ".", dir=str(destination.parent))
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.copy2(str(source), temp_name)
|
||||
os.replace(temp_name, str(destination))
|
||||
if private:
|
||||
try:
|
||||
os.chmod(str(destination), stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
if os.path.exists(temp_name):
|
||||
os.remove(temp_name)
|
||||
|
||||
|
||||
def restore_backup_archive(
|
||||
archive_path: Path,
|
||||
data_root: Path,
|
||||
workspace: Optional[Path] = None,
|
||||
) -> dict:
|
||||
"""Merge a validated backup into the selected data root and workspace."""
|
||||
archive_path = Path(archive_path).expanduser().resolve()
|
||||
data_root = Path(data_root).expanduser().resolve()
|
||||
current_config = _read_config(data_root)
|
||||
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="cowagent-restore-"))
|
||||
try:
|
||||
with zipfile.ZipFile(str(archive_path), "r") as archive:
|
||||
manifest = _validate_archive(archive)
|
||||
_extract_validated(archive, temp_dir)
|
||||
|
||||
archived_config_path = temp_dir / "data" / "config.json"
|
||||
archived_config = {}
|
||||
if archived_config_path.is_file():
|
||||
with archived_config_path.open("r", encoding="utf-8") as handle:
|
||||
value = json.load(handle)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("archived config.json must contain an object")
|
||||
archived_config = value
|
||||
|
||||
if workspace is not None:
|
||||
target_workspace = Path(workspace).expanduser().resolve()
|
||||
elif current_config.get("agent_workspace"):
|
||||
target_workspace = _workspace_from_config(current_config)
|
||||
else:
|
||||
# Do not trust an archive-controlled absolute destination on a
|
||||
# fresh machine. Portable restores default to the standard local
|
||||
# workspace unless the operator supplies --workspace.
|
||||
target_workspace = Path("~/cow").expanduser().resolve()
|
||||
|
||||
restored_config = dict(archived_config)
|
||||
if restored_config:
|
||||
restored_config["agent_workspace"] = str(target_workspace)
|
||||
appdata_dir = restored_config.get("appdata_dir") or ""
|
||||
if appdata_dir:
|
||||
archived_appdata = (data_root / appdata_dir).resolve()
|
||||
if not _is_within(archived_appdata, data_root):
|
||||
# Keep legacy user data under the selected data root
|
||||
# instead of writing to an archive-controlled path.
|
||||
restored_config["appdata_dir"] = ""
|
||||
config_temp = temp_dir / "restored-config.json"
|
||||
with config_temp.open("w", encoding="utf-8") as handle:
|
||||
json.dump(restored_config, handle, ensure_ascii=False, indent=2)
|
||||
handle.write("\n")
|
||||
_atomic_copy(config_temp, data_root / "config.json", private=True)
|
||||
|
||||
workspace_root = temp_dir / "workspace"
|
||||
restored_files = 0
|
||||
if workspace_root.is_dir():
|
||||
for source in _iter_workspace_files(workspace_root, set()):
|
||||
relative = source.relative_to(workspace_root)
|
||||
destination = target_workspace / relative
|
||||
if not _is_within(destination, target_workspace):
|
||||
raise ValueError(f"unsafe workspace destination: {relative}")
|
||||
_atomic_copy(source, destination)
|
||||
restored_files += 1
|
||||
|
||||
legacy_source = temp_dir / "data" / "user_datas.pkl"
|
||||
if legacy_source.is_file():
|
||||
effective_config = restored_config or current_config
|
||||
legacy_destination = _legacy_user_data_path(data_root, effective_config)
|
||||
_atomic_copy(legacy_source, legacy_destination, private=True)
|
||||
|
||||
return {
|
||||
"manifest": manifest,
|
||||
"workspace": str(target_workspace),
|
||||
"workspace_files": restored_files,
|
||||
"config_restored": bool(restored_config),
|
||||
"legacy_user_data_restored": legacy_source.is_file(),
|
||||
}
|
||||
finally:
|
||||
shutil.rmtree(str(temp_dir), ignore_errors=True)
|
||||
|
||||
|
||||
@click.command("backup")
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(dir_okay=False, path_type=Path),
|
||||
help="Output .zip path (default: ./cow-backup-<timestamp>.zip).",
|
||||
)
|
||||
def backup_command(output: Optional[Path]):
|
||||
"""Back up config, persona, memory, skills, knowledge, and schedules."""
|
||||
data_root = _data_root()
|
||||
config = _read_config(data_root)
|
||||
workspace = _workspace_from_config(config)
|
||||
if output is None:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
output = Path.cwd() / f"cow-backup-{stamp}.zip"
|
||||
result = create_backup_archive(output, data_root, workspace)
|
||||
click.echo(click.style("✓ Backup created", fg="green"))
|
||||
click.echo(f" Archive: {result['archive']}")
|
||||
click.echo(f" Workspace files: {result['contents']['workspace_files']}")
|
||||
click.echo(" Keep this archive private: it may contain API keys and personal data.")
|
||||
|
||||
|
||||
@click.command("restore")
|
||||
@click.argument("archive", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--workspace",
|
||||
type=click.Path(file_okay=False, path_type=Path),
|
||||
help="Restore workspace files to this directory.",
|
||||
)
|
||||
@click.option("--yes", is_flag=True, help="Confirm overwriting matching files.")
|
||||
def restore_command(archive: Path, workspace: Optional[Path], yes: bool):
|
||||
"""Restore a backup without deleting unrelated destination files."""
|
||||
from cli.commands.process import _read_pid
|
||||
|
||||
pid = _read_pid()
|
||||
if pid:
|
||||
raise click.ClickException(
|
||||
f"CowAgent is running (PID: {pid}). Run 'cow stop' before restoring."
|
||||
)
|
||||
if not yes:
|
||||
click.confirm(
|
||||
"Restore this archive and overwrite matching config/workspace files?",
|
||||
abort=True,
|
||||
)
|
||||
|
||||
data_root = _data_root()
|
||||
current_config = _read_config(data_root)
|
||||
current_workspace = _workspace_from_config(current_config)
|
||||
has_current_data = (data_root / "config.json").is_file() or current_workspace.is_dir()
|
||||
if has_current_data:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
rollback = archive.resolve().parent / f"cow-pre-restore-{stamp}.zip"
|
||||
create_backup_archive(
|
||||
rollback,
|
||||
data_root,
|
||||
current_workspace,
|
||||
excluded_paths={archive.resolve()},
|
||||
)
|
||||
click.echo(f"Rollback backup: {rollback}")
|
||||
|
||||
result = restore_backup_archive(archive, data_root, workspace)
|
||||
click.echo(click.style("✓ Backup restored", fg="green"))
|
||||
click.echo(f" Workspace: {result['workspace']}")
|
||||
click.echo(f" Restored files: {result['workspace_files']}")
|
||||
@@ -88,6 +88,69 @@ def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
||||
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:
|
||||
"""CLI: colored click output."""
|
||||
if fg == "yellow":
|
||||
@@ -129,6 +192,7 @@ def run_install_browser(
|
||||
stream = stream or _default_stream
|
||||
python = sys.executable
|
||||
legacy_mode = False
|
||||
frozen = _is_frozen()
|
||||
|
||||
_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
|
||||
# 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.
|
||||
if sys.platform == "win32":
|
||||
if sys.platform == "win32" and not frozen:
|
||||
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
||||
ret = subprocess.call(
|
||||
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
||||
@@ -172,6 +236,16 @@ def run_install_browser(
|
||||
"yellow",
|
||||
)
|
||||
|
||||
if frozen:
|
||||
# Desktop bundle: playwright is already shipped inside the app; there is
|
||||
# no pip and nothing to install. Skip straight to downloading Chromium.
|
||||
installed = _get_installed_version()
|
||||
stream(f"[1/3] Playwright is bundled ({installed or 'ok'}), skipping pip install.", "green")
|
||||
_phase(on_phase, _t(
|
||||
"✅ [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)
|
||||
@@ -189,13 +263,33 @@ def run_install_browser(
|
||||
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":
|
||||
_phase(on_phase, _t(
|
||||
"🔧 [2/3] 正在安装 Linux 系统依赖与轻量中文字体(文泉驿正黑,部分步骤可能需要 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")
|
||||
ret = subprocess.call([python, "-m", "playwright", "install-deps", "chromium"])
|
||||
ret = _playwright_cli(["install-deps", "chromium"])
|
||||
if ret != 0:
|
||||
stream(
|
||||
" 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)…",
|
||||
))
|
||||
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:
|
||||
ver = _version_tuple(installed or "")
|
||||
if ver >= (1, 57, 0):
|
||||
cmd.append("--only-shell")
|
||||
pw_args.append("--only-shell")
|
||||
stream(" (headless shell for Linux server)", None)
|
||||
else:
|
||||
stream(" (full Chromium)", None)
|
||||
@@ -251,6 +345,15 @@ def run_install_browser(
|
||||
stream(" (full browser for Linux desktop)", None)
|
||||
|
||||
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()
|
||||
if use_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.",
|
||||
))
|
||||
|
||||
ret = subprocess.call(cmd, env=env)
|
||||
ret = _playwright_cli(pw_args, env=env)
|
||||
|
||||
if ret != 0 and use_mirror:
|
||||
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…",
|
||||
))
|
||||
env_no_mirror = os.environ.copy()
|
||||
env_no_mirror = dict(env)
|
||||
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:
|
||||
stream("Failed to install Chromium.", "red")
|
||||
@@ -282,6 +385,14 @@ def run_install_browser(
|
||||
|
||||
stream("Verifying browser installation...", None)
|
||||
_phase(on_phase, _t("🔍 正在验证 Playwright 能否正常加载…", "🔍 Verifying that Playwright loads correctly…"))
|
||||
if frozen:
|
||||
# Frozen: no child interpreter to spawn; import in-process instead.
|
||||
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,
|
||||
|
||||
@@ -30,13 +30,14 @@ 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_0620 = "claude-3-5-sonnet-20240620"
|
||||
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_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
||||
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
||||
CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0
|
||||
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent recommended model
|
||||
CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent recommended model
|
||||
CLAUDE_SONNET_5 = "claude-sonnet-5" # Claude Sonnet 5 - default flagship model for Claude
|
||||
|
||||
# Gemini (Google)
|
||||
GEMINI_PRO = "gemini-1.0-pro"
|
||||
@@ -79,6 +80,9 @@ GPT_54 = "gpt-5.4" # GPT-5.4 - Agent recommended model
|
||||
GPT_54_MINI = "gpt-5.4-mini"
|
||||
GPT_54_NANO = "gpt-5.4-nano"
|
||||
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_MINI = "o1-mini"
|
||||
WHISPER_1 = "whisper-1"
|
||||
@@ -138,7 +142,8 @@ GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model
|
||||
|
||||
# Kimi (Moonshot)
|
||||
MOONSHOT = "moonshot"
|
||||
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code - Agent recommended model (default)
|
||||
KIMI_K3 = "kimi-k3" # Kimi K3 - Agent recommended model (default)
|
||||
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code
|
||||
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
|
||||
KIMI_K2 = "kimi-k2"
|
||||
KIMI_K2_5 = "kimi-k2.5"
|
||||
@@ -153,6 +158,8 @@ MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - high-speed
|
||||
|
||||
# Doubao (Volcengine Ark)
|
||||
DOUBAO = "doubao"
|
||||
DOUBAO_SEED_2_1_PRO = "doubao-seed-2-1-pro-260628"
|
||||
DOUBAO_SEED_2_1_TURBO = "doubao-seed-2-1-turbo-260628"
|
||||
DOUBAO_SEED_2_CODE = "doubao-seed-2-0-code-preview-260215"
|
||||
DOUBAO_SEED_2_PRO = "doubao-seed-2-0-pro-260215"
|
||||
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
|
||||
@@ -197,7 +204,7 @@ MODEL_LIST = [
|
||||
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||
|
||||
# Claude
|
||||
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", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||
|
||||
@@ -211,6 +218,7 @@ MODEL_LIST = [
|
||||
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_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_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
||||
O1, O1_MINI,
|
||||
@@ -223,11 +231,12 @@ MODEL_LIST = [
|
||||
QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
|
||||
|
||||
# Doubao
|
||||
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
||||
DOUBAO, DOUBAO_SEED_2_1_PRO, DOUBAO_SEED_2_1_TURBO,
|
||||
DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
||||
|
||||
# Kimi (Moonshot)
|
||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
||||
KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
||||
KIMI_K3, KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
||||
|
||||
# ModelScope
|
||||
MODELSCOPE,
|
||||
|
||||
@@ -7,6 +7,11 @@ across the CLI, startup logs, error messages, agent prompts and channel
|
||||
replies. It must NOT import project config (to avoid circular imports) and
|
||||
must stay dependency-free so it can run at the earliest startup phase.
|
||||
|
||||
Supported language codes (BCP 47 compliant):
|
||||
- "zh" (Simplified Chinese)
|
||||
- "zh-Hant" (Traditional Chinese, script-based tag per Unicode CLDR)
|
||||
- "en" (English)
|
||||
|
||||
Resolution priority (highest first):
|
||||
1. Explicit `cow_lang` from config.json — also covers Docker/CI, since any
|
||||
config key is overridable via its uppercase env var (e.g. COW_LANG=zh),
|
||||
@@ -19,7 +24,10 @@ Resolution priority (highest first):
|
||||
5. Default -> English
|
||||
|
||||
A value of "auto" (the default) triggers detection (steps 2-5). Explicitly
|
||||
setting "zh" or "en" locks the language and skips detection.
|
||||
setting "zh", "zh-Hant", or "en" locks the language and skips detection.
|
||||
|
||||
Note: For backwards compatibility, zh-tw, zh-hk, and other regional variants
|
||||
are automatically normalized to zh-Hant during detection.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -28,10 +36,85 @@ import sys
|
||||
|
||||
# Supported language codes
|
||||
ZH = "zh"
|
||||
ZH_HANT = "zh-Hant"
|
||||
EN = "en"
|
||||
SUPPORTED = (ZH, EN)
|
||||
SUPPORTED = (ZH, ZH_HANT, EN)
|
||||
DEFAULT_LANG = EN
|
||||
|
||||
# Mapped Simplified to Traditional characters in this codebase
|
||||
_SIMPLIFIED = "与专业东丢两严个丰临为举么义乐乔习书乱于云产亲仅从仓们价众优伙会伟传体余侧倾储儿关兴兽内册写冲决况准减几凭凯击划则刚创删别剥办务动区华协单占厂历压参双发变叙台号后吗听启员响嚣团国图场坏块声处备复够头夹妩姗娇娱婶学宝实宠审宽对导将尝尽层属币师带帧帮幂干并广庆库应开异弃张弹强归当录彦彻征径忆态总悦惯戏战户执扩扫抛抢护报拟拥拦择换据数断无旧时昵显晓暂术机杂权条来杰极构枪标树样档桦梦检欢残毕气汇汉汤没泄泼洁测浏润涩淀渊温游湾湿滞满滤灵灿炀点炼烦热爱爷状独猪环现瑶电画监盖盘着睁码础确离种积称稳竞笔签简类粤紧纠红约级纪纯纳线组细织终绍经结绘给络绝统继绪续维综缀缓编缩网罗羁职联聪脑脚脱腾舰艺节苍苏范荐获萝蔼虑补装见观规视览觉触计订认讨让训议讯记讲许论设访证识诉诊词译试诚话询该详语误说请读谁调谢谨谱贝负责贤败账货质费资赋赖赘轩转轮软轻载较辑输边达过运还这进远违连迟适选递逻遥邮邻采释里鉴针钉钟钥钮钱铁链销锁错锤键镜长闭问闲间闺闻闽阅队阳际陆陕险随隐难静韩页项顺须顾预领频题额风飞饭饰馆馈馏马驻驿验骤鱼鸡麦齐"
|
||||
_TRADITIONAL = "與專業東丟兩嚴個豐臨為舉麼義樂喬習書亂於雲產親僅從倉們價眾優夥會偉傳體餘側傾儲兒關興獸內冊寫沖決況準減幾憑凱擊劃則剛創刪別剝辦務動區華協單佔廠歷壓參雙發變敘臺號後嗎聽啟員響囂團國圖場壞塊聲處備復夠頭夾嫵姍嬌娛嬸學寶實寵審寬對導將嘗盡層屬幣師帶幀幫冪幹並廣慶庫應開異棄張彈強歸當錄彥徹徵徑憶態總悅慣戲戰戶執擴掃拋搶護報擬擁攔擇換據數斷無舊時暱顯曉暫術機雜權條來傑極構槍標樹樣檔樺夢檢歡殘畢氣匯漢湯沒洩潑潔測瀏潤澀澱淵溫遊灣溼滯滿濾靈燦煬點煉煩熱愛爺狀獨豬環現瑤電畫監蓋盤著睜碼礎確離種積稱穩競筆籤簡類粵緊糾紅約級紀純納線組細織終紹經結繪給絡絕統繼緒續維綜綴緩編縮網羅羈職聯聰腦腳脫騰艦藝節蒼蘇範薦獲蘿藹慮補裝見觀規視覽覺觸計訂認討讓訓議訊記講許論設訪證識訴診詞譯試誠話詢該詳語誤說請讀誰調謝謹譜貝負責賢敗帳貨質費資檔案影片圖片連結資料資訊支援排程執行帳號密碼憑證埠服務啟用管道終端機控制台"
|
||||
_CHAR_MAP = None
|
||||
|
||||
_PHRASE_MAP = {
|
||||
"默认": "預設",
|
||||
"内存": "記憶體",
|
||||
"配置": "設定",
|
||||
"进程": "處理程序",
|
||||
"目录": "目錄",
|
||||
"文件夹": "資料夾",
|
||||
"文件": "檔案",
|
||||
"视频": "影片",
|
||||
"图片": "圖片",
|
||||
"影象": "影像",
|
||||
"图像": "影像",
|
||||
"链接": "連結",
|
||||
"数据": "資料",
|
||||
"信息": "資訊",
|
||||
"支持": "支援",
|
||||
"定时": "排程",
|
||||
"运行": "執行",
|
||||
"账号": "帳號",
|
||||
"密码": "密碼",
|
||||
"凭据": "憑證",
|
||||
"端口": "埠",
|
||||
"服务": "服務",
|
||||
"激活": "啟用",
|
||||
"通道": "管道",
|
||||
"终端": "終端機",
|
||||
"主控台": "控制台",
|
||||
"创建": "建立",
|
||||
"计算机": "電腦",
|
||||
}
|
||||
|
||||
|
||||
def to_traditional(text):
|
||||
"""Convert Simplified Chinese text to Traditional Chinese.
|
||||
|
||||
Uses a two-tier approach:
|
||||
1. Phrase-level mapping for project-specific terms (e.g., "配置" → "設定")
|
||||
2. OpenCC library (opencc-python-reimplemented) if available for high-quality
|
||||
context-aware conversion, with fallback to built-in character mapping
|
||||
|
||||
This function is designed to work without external dependencies. If OpenCC
|
||||
is not installed, it falls back to a curated 450-character mapping table
|
||||
plus 30+ technical term mappings that cover common project vocabulary.
|
||||
|
||||
For production use with zh-Hant language, installing OpenCC is recommended:
|
||||
pip install opencc-python-reimplemented
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# Replace phrases first
|
||||
for s, t_phrase in _PHRASE_MAP.items():
|
||||
text = text.replace(s, t_phrase)
|
||||
|
||||
try:
|
||||
from opencc import OpenCC
|
||||
cc = OpenCC('s2twp')
|
||||
return cc.convert(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
global _CHAR_MAP
|
||||
if _CHAR_MAP is None:
|
||||
_CHAR_MAP = dict(zip(_SIMPLIFIED, _TRADITIONAL))
|
||||
|
||||
# Replace characters
|
||||
return "".join(_CHAR_MAP.get(c, c) for c in text)
|
||||
|
||||
|
||||
# Resolved language cache; None until first resolution.
|
||||
_resolved_lang = None
|
||||
|
||||
@@ -48,7 +131,10 @@ def _normalize(raw):
|
||||
value = str(raw).strip().lower().replace("_", "-")
|
||||
if value in ("auto", ""):
|
||||
return None
|
||||
# Chinese variants: zh, zh-cn, zh-hans, zh-hans-cn, zh-tw, zh-hk ...
|
||||
# Traditional Chinese variants: zh-tw, zh-hk, zh-hant, zh-hant-tw, zh-hant-hk...
|
||||
if value.startswith("zh-tw") or value.startswith("zh-hk") or "hant" in value:
|
||||
return ZH_HANT
|
||||
# General or Simplified Chinese variants: zh, zh-cn, zh-hans...
|
||||
if value.startswith("zh") or value.startswith("chinese"):
|
||||
return ZH
|
||||
if value.startswith("en") or value.startswith("english"):
|
||||
@@ -167,7 +253,7 @@ def get_language():
|
||||
|
||||
|
||||
def is_zh():
|
||||
return get_language() == ZH
|
||||
return get_language() in (ZH, ZH_HANT)
|
||||
|
||||
|
||||
def t(zh_text, en_text):
|
||||
@@ -176,4 +262,7 @@ def t(zh_text, en_text):
|
||||
Intended for one-off strings where a full message catalog is overkill:
|
||||
t("已中止", "Cancelled")
|
||||
"""
|
||||
return zh_text if get_language() == ZH else en_text
|
||||
lang = get_language()
|
||||
if lang == ZH_HANT:
|
||||
return to_traditional(zh_text)
|
||||
return zh_text if lang == ZH else en_text
|
||||
|
||||
@@ -33,6 +33,12 @@ def _reset_logger(log):
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
log.addHandler(console_handle)
|
||||
# File logging is best-effort: if the log path isn't writable (e.g. a
|
||||
# packaged app installed under Program Files run by a non-admin user, with
|
||||
# an unwritable CWD), fall back to console-only instead of crashing the
|
||||
# whole process at import time.
|
||||
try:
|
||||
file_handle = logging.FileHandler(_log_path(), encoding="utf-8")
|
||||
file_handle.setFormatter(
|
||||
logging.Formatter(
|
||||
@@ -41,7 +47,14 @@ def _reset_logger(log):
|
||||
)
|
||||
)
|
||||
log.addHandler(file_handle)
|
||||
log.addHandler(console_handle)
|
||||
except OSError:
|
||||
console_handle.handle(
|
||||
logging.LogRecord(
|
||||
"log", logging.WARNING, __file__, 0,
|
||||
"[log] file logging disabled (log path not writable): %s",
|
||||
(_log_path(),), None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _get_logger():
|
||||
|
||||
@@ -41,5 +41,6 @@
|
||||
"enable_thinking": false,
|
||||
"reasoning_effort": "high",
|
||||
"knowledge": true,
|
||||
"self_evolution_enabled": true
|
||||
"self_evolution_enabled": true,
|
||||
"mcp_tool_retrieval_enabled": false
|
||||
}
|
||||
|
||||
34
config.py
34
config.py
@@ -1,5 +1,6 @@
|
||||
# encoding:utf-8
|
||||
|
||||
import ast
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
@@ -177,6 +178,7 @@ available_setting = {
|
||||
"feishu_event_mode": "websocket", # Feishu event mode: webhook(HTTP server) or websocket(long connection)
|
||||
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
||||
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
||||
"feishu_detailed_card": True, # render normal chat streaming as a detailed card (status header, thinking/tool panels, elapsed time); off keeps the plain typewriter card
|
||||
# DingTalk config
|
||||
"dingtalk_client_id": "", # DingTalk bot Client ID
|
||||
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
||||
@@ -252,6 +254,7 @@ available_setting = {
|
||||
"web_password": "", # Web console password; empty means no authentication required
|
||||
"web_session_expire_days": 30, # Auth session expiry in days
|
||||
"web_file_serve_root": "~", # Root dir the /api/file endpoint may serve; "/" allows the whole filesystem
|
||||
"mcp_oauth_redirect_base": "", # Base URL for MCP OAuth callback (e.g. http://your-ip:9899); empty uses local web console
|
||||
"agent": True, # whether to enable Agent mode
|
||||
"agent_workspace": "~/cow", # agent workspace path, used to store skills, memory, etc.
|
||||
"agent_max_context_tokens": 50000, # max context tokens in Agent mode
|
||||
@@ -264,8 +267,17 @@ available_setting = {
|
||||
"self_evolution_enabled": False, # switch to enable/disable self-evolution
|
||||
"self_evolution_idle_minutes": 10, # idle time before a session is reviewed
|
||||
"self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger
|
||||
# Deep Dream: nightly memory distillation into MEMORY.md + dream diary.
|
||||
"deep_dream_enabled": True, # scheduled deep dream switch; manual /memory dream is unaffected
|
||||
"skill": {}, # Per-skill runtime config; nested keys flatten to SKILL_<NAME>_<KEY> env vars at startup
|
||||
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
|
||||
# On-demand MCP tool retrieval: when many MCP tools are connected, inject
|
||||
# only the most query-relevant ones instead of all of them. Built-in tools
|
||||
# are always injected in full; degrades to full injection when disabled,
|
||||
# below threshold, or when no embedding provider is available.
|
||||
"mcp_tool_retrieval_enabled": False, # switch for on-demand MCP tool retrieval
|
||||
"mcp_tool_retrieval_threshold": 20, # only retrieve when MCP tool count exceeds this
|
||||
"mcp_tool_retrieval_top_k": 10, # max relevant MCP tools injected per turn
|
||||
}
|
||||
|
||||
|
||||
@@ -307,6 +319,12 @@ class Config(dict):
|
||||
self.user_datas[user] = {}
|
||||
return self.user_datas[user]
|
||||
|
||||
# SECURITY NOTE: pickle.load() can execute arbitrary code during
|
||||
# deserialization. This is safe as long as user_datas.pkl is trusted
|
||||
# (local app data directory, written only by this process). For a future
|
||||
# hardening pass, consider migrating to JSON (json.load/json.dump) if the
|
||||
# data structures are JSON-serializable, or adding an HMAC signature to
|
||||
# detect tampering of the pickle file.
|
||||
def load_user_datas(self):
|
||||
try:
|
||||
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "rb") as f:
|
||||
@@ -320,6 +338,8 @@ class Config(dict):
|
||||
|
||||
def save_user_datas(self):
|
||||
try:
|
||||
# SECURITY: pickle.dump output should only be loaded by this same
|
||||
# process. See note on load_user_datas() above.
|
||||
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "wb") as f:
|
||||
pickle.dump(self.user_datas, f)
|
||||
logger.info("[Config] User datas saved.")
|
||||
@@ -415,11 +435,19 @@ def load_config():
|
||||
if name in available_setting:
|
||||
logger.info("[INIT] override config by environ args: {}={}".format(name, value))
|
||||
try:
|
||||
config[name] = eval(value)
|
||||
# SECURITY: Use ast.literal_eval instead of eval().
|
||||
# ast.literal_eval only parses Python literals (strings, numbers,
|
||||
# tuples, lists, dicts, booleans, None) and CANNOT execute
|
||||
# arbitrary code, preventing environment-variable injection.
|
||||
config[name] = ast.literal_eval(value)
|
||||
except Exception:
|
||||
if value == "false":
|
||||
# literal_eval can raise ValueError/SyntaxError for non-literal
|
||||
# strings, but also TypeError/RecursionError on malformed input
|
||||
# (e.g. unhashable dict keys); catch broadly to avoid crashing
|
||||
# startup, and fall back to treating the value as a plain string.
|
||||
if value.lower() == "false":
|
||||
config[name] = False
|
||||
elif value == "true":
|
||||
elif value.lower() == "true":
|
||||
config[name] = True
|
||||
else:
|
||||
config[name] = value
|
||||
|
||||
@@ -52,15 +52,36 @@ hiddenimports += collect_submodules('models')
|
||||
hiddenimports += collect_submodules('voice')
|
||||
hiddenimports += collect_submodules('bridge')
|
||||
|
||||
# Plugin framework: WebChannel -> ChatChannel imports `from plugins import *`,
|
||||
# so the framework package must be present even though desktop mode never loads
|
||||
# actual plugins (it's only ~tens of KB of code).
|
||||
# Plugin framework + plugins. WebChannel -> ChatChannel imports
|
||||
# `from plugins import *`, and desktop mode loads plugins (in a background
|
||||
# thread) so command plugins like cow_cli/godcmd (/status, #help) work. Plugin
|
||||
# modules are imported dynamically by name in scan_plugins(), so list them
|
||||
# explicitly. The `cli` package is a cow_cli dependency (`from cli import ...`).
|
||||
hiddenimports += [
|
||||
'plugins',
|
||||
'plugins.event',
|
||||
'plugins.plugin',
|
||||
'plugins.plugin_manager',
|
||||
]
|
||||
hiddenimports += collect_submodules('plugins')
|
||||
|
||||
# `cli` powers cow_cli's slash commands (`cow skill install`, `cow status`, …).
|
||||
# Its command modules are imported lazily inside functions, so static analysis
|
||||
# misses them. collect_submodules('cli') alone proved unreliable (a build can
|
||||
# end up with `cli` but not `cli.commands`), so list the command modules
|
||||
# explicitly AND ship the package as data (see datas) as a belt-and-suspenders.
|
||||
hiddenimports += collect_submodules('cli')
|
||||
hiddenimports += [
|
||||
'cli',
|
||||
'cli.cli',
|
||||
'cli.utils',
|
||||
'cli.commands',
|
||||
'cli.commands.skill',
|
||||
'cli.commands.process',
|
||||
'cli.commands.context',
|
||||
'cli.commands.install',
|
||||
'cli.commands.knowledge',
|
||||
]
|
||||
|
||||
# Third-party SDKs that use lazy/conditional imports internally.
|
||||
hiddenimports += collect_submodules('dashscope')
|
||||
@@ -69,12 +90,42 @@ hiddenimports += [
|
||||
'tiktoken_ext.openai_public',
|
||||
]
|
||||
|
||||
# Document parsing libs. The read / web_fetch tools import these lazily inside
|
||||
# functions (e.g. `from pypdf import PdfReader`), so PyInstaller's static
|
||||
# analysis misses them and they'd be dropped from the bundle — leaving the
|
||||
# desktop client unable to read PDF/Word/Excel/PPT. List them explicitly.
|
||||
hiddenimports += [
|
||||
'pypdf',
|
||||
'docx', # python-docx
|
||||
'pptx', # python-pptx
|
||||
'openpyxl',
|
||||
]
|
||||
hiddenimports += collect_submodules('pypdf')
|
||||
hiddenimports += collect_submodules('docx')
|
||||
hiddenimports += collect_submodules('pptx')
|
||||
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 -----------------------------------------------------------
|
||||
# Runtime-read files/dirs that must travel with the executable. Paths are
|
||||
# (source, dest_dir_in_bundle).
|
||||
datas = [
|
||||
(rp('config-template.json'), '.'),
|
||||
(rp('skills'), 'skills'),
|
||||
# PluginManager.scan_plugins() walks the on-disk ./plugins dir at runtime
|
||||
# (it doesn't rely solely on imports), so ship the package directory too.
|
||||
(rp('plugins'), 'plugins'),
|
||||
# Ship the `cli` package as loose files too: onedir adds _internal to
|
||||
# sys.path, so `import cli.commands.*` resolves even if PyInstaller's
|
||||
# submodule collection misses the lazily-imported command modules.
|
||||
(rp('cli'), 'cli'),
|
||||
# Web console served on the backend port: ship chat.html plus its static
|
||||
# assets (~1.9MB) so the browser-based console works as a debug/fallback
|
||||
# entry alongside the Electron UI.
|
||||
@@ -85,6 +136,18 @@ datas = [
|
||||
# Some libraries (tiktoken encodings, etc.) ship data files.
|
||||
datas += collect_data_files('tiktoken_ext', include_py_files=False)
|
||||
|
||||
# python-docx / python-pptx bundle template files (default.docx / default.pptx,
|
||||
# content-type XML) inside their packages; they're loaded at import/parse time,
|
||||
# so ship them or document parsing fails in the frozen build.
|
||||
datas += collect_data_files('docx')
|
||||
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 -------------------------------------------------------------
|
||||
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
|
||||
# mode), tests/docs, and dev-only packages.
|
||||
@@ -94,7 +157,10 @@ excludes = [
|
||||
'pip',
|
||||
'wheel',
|
||||
'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
|
||||
|
||||
19
desktop/build/entitlements.mac.plist
Normal file
19
desktop/build/entitlements.mac.plist
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Electron needs JIT and writable/executable memory for V8. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- PyInstaller backend loads many unsigned/third-party dylibs. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<!-- Allow spawning the bundled backend and other child processes. -->
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
147
desktop/build/notarize-dmg.sh
Executable file
147
desktop/build/notarize-dmg.sh
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# STAGE 2 of the decoupled release pipeline: notarize signed dmgs locally.
|
||||
#
|
||||
# CI (stage 1) already produced code-signed, hardened-runtime dmgs and mirrored
|
||||
# them to R2 as unpublished. Apple's notary service keeps this large PyInstaller
|
||||
# bundle "In Progress" for hours, so we notarize here — off the CI clock — and
|
||||
# staple the ticket straight onto the dmg (users download it ready-to-run).
|
||||
#
|
||||
# What it does for each dmg passed on the command line:
|
||||
# 1. submit the dmg to the notary service ONCE (--no-wait) and remember the id,
|
||||
# 2. poll that SAME id until Accepted/Invalid; network errors are ignored and
|
||||
# retried, and it NEVER resubmits (avoids piling up duplicate submissions),
|
||||
# 3. staple the ticket onto the dmg,
|
||||
# 4. (optional) re-upload the stapled dmg to R2, overwriting the unpublished
|
||||
# copy, so the CDN serves the notarized bytes.
|
||||
#
|
||||
# Auth: uses a stored keychain profile (default: cow-notary). Create it once via
|
||||
# xcrun notarytool store-credentials cow-notary \
|
||||
# --apple-id <id> --team-id <team> --password <app-specific-password>
|
||||
#
|
||||
# Usage:
|
||||
# # notarize + staple only (no upload):
|
||||
# desktop/build/notarize-dmg.sh path/to/CowAgent-1.2.3-arm64.dmg [more.dmg ...]
|
||||
#
|
||||
# # notarize + staple + re-upload to R2 (needs wrangler + Cloudflare creds):
|
||||
# VER=1.2.3 UPLOAD=1 desktop/build/notarize-dmg.sh *.dmg
|
||||
#
|
||||
# Env:
|
||||
# PROFILE keychain profile name (default: cow-notary)
|
||||
# UPLOAD set to 1 to re-upload stapled dmgs to R2
|
||||
# VER version string for the R2 key desktop/v${VER}/<file> (required if UPLOAD=1)
|
||||
# R2_BUCKET R2 bucket (default: cow-skills)
|
||||
# POLL_SECONDS status poll interval (default: 60)
|
||||
# MAX_WAIT_MINUTES give up polling after this long (default: 720 = 12h)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
PROFILE="${PROFILE:-cow-notary}"
|
||||
R2_BUCKET="${R2_BUCKET:-cow-skills}"
|
||||
POLL_SECONDS="${POLL_SECONDS:-60}"
|
||||
MAX_WAIT_MINUTES="${MAX_WAIT_MINUTES:-720}"
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "usage: $0 <dmg> [dmg ...]" >&2
|
||||
echo " set UPLOAD=1 and VER=<version> to also re-upload stapled dmgs to R2" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "${UPLOAD:-0}" = "1" ] && [ -z "${VER:-}" ]; then
|
||||
echo "error: UPLOAD=1 requires VER=<version> (used for the R2 key)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log() { echo "[notarize-dmg] $*"; }
|
||||
|
||||
notarize_one() {
|
||||
local dmg="$1"
|
||||
if [ ! -f "$dmg" ]; then
|
||||
log "SKIP: not a file: $dmg"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# If it's already stapled (e.g. re-run), skip straight to (optional) upload.
|
||||
if xcrun stapler validate "$dmg" >/dev/null 2>&1; then
|
||||
log "$dmg already stapled — skipping notarization."
|
||||
else
|
||||
log "submitting $dmg (no-wait)..."
|
||||
local submit_out submission_id
|
||||
submit_out="$(xcrun notarytool submit "$dmg" \
|
||||
--keychain-profile "$PROFILE" --no-wait --output-format json)"
|
||||
submission_id="$(echo "$submit_out" | /usr/bin/plutil -extract id raw - 2>/dev/null || true)"
|
||||
if [ -z "$submission_id" ] || [ "$submission_id" = "null" ]; then
|
||||
# Fallback parse without plutil (json is single-line).
|
||||
submission_id="$(echo "$submit_out" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')"
|
||||
fi
|
||||
if [ -z "$submission_id" ]; then
|
||||
log "ERROR: could not parse submission id from:"
|
||||
echo "$submit_out" >&2
|
||||
return 1
|
||||
fi
|
||||
log "submission id: $submission_id (polling same id, never resubmitting)"
|
||||
|
||||
local deadline status ts
|
||||
deadline=$(( $(date +%s) + MAX_WAIT_MINUTES * 60 ))
|
||||
while :; do
|
||||
status=""
|
||||
status="$(xcrun notarytool info "$submission_id" \
|
||||
--keychain-profile "$PROFILE" --output-format json 2>/dev/null \
|
||||
| sed -n 's/.*"status":"\([^"]*\)".*/\1/p' || true)"
|
||||
ts="$(date +%H:%M:%S)"
|
||||
log "[$ts] status: ${status:-<query failed, retrying>}"
|
||||
|
||||
case "$status" in
|
||||
Accepted) break ;;
|
||||
Invalid|Rejected)
|
||||
log "notarization $status — fetching log:"
|
||||
xcrun notarytool log "$submission_id" --keychain-profile "$PROFILE" || true
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
log "ERROR: not finished after ${MAX_WAIT_MINUTES} min (id: $submission_id)."
|
||||
log "NOT resubmitting. Check later: xcrun notarytool info $submission_id --keychain-profile $PROFILE"
|
||||
return 1
|
||||
fi
|
||||
sleep "$POLL_SECONDS"
|
||||
done
|
||||
|
||||
log "Accepted; stapling ticket to $dmg"
|
||||
local staple_try=1
|
||||
until xcrun stapler staple "$dmg"; do
|
||||
if [ "$staple_try" -ge 3 ]; then
|
||||
log "ERROR: stapling failed after 3 attempts"
|
||||
return 1
|
||||
fi
|
||||
log "staple failed, retrying in 15s..."
|
||||
sleep 15
|
||||
staple_try=$((staple_try + 1))
|
||||
done
|
||||
xcrun stapler validate "$dmg"
|
||||
log "$dmg notarized + stapled."
|
||||
fi
|
||||
|
||||
if [ "${UPLOAD:-0}" = "1" ]; then
|
||||
local base key
|
||||
base="$(basename "$dmg")"
|
||||
key="desktop/v${VER}/${base}"
|
||||
log "re-uploading stapled dmg -> r2://${R2_BUCKET}/${key}"
|
||||
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
|
||||
--file "$dmg" --remote
|
||||
log "uploaded $base"
|
||||
fi
|
||||
}
|
||||
|
||||
rc=0
|
||||
for dmg in "$@"; do
|
||||
echo "======================================================================"
|
||||
notarize_one "$dmg" || rc=1
|
||||
done
|
||||
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
log "one or more dmgs failed — see output above."
|
||||
exit 1
|
||||
fi
|
||||
log "all done."
|
||||
@@ -42,6 +42,12 @@ python-docx
|
||||
openpyxl
|
||||
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. ----
|
||||
wechatpy
|
||||
pycryptodome
|
||||
|
||||
83
desktop/electron-builder.js
Normal file
83
desktop/electron-builder.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Dynamic electron-builder config.
|
||||
*
|
||||
* We keep the base config in package.json's "build" field and extend it here
|
||||
* only to populate `mac.binaries` — the list of extra Mach-O files that must
|
||||
* be signed with hardened runtime + entitlements.
|
||||
*
|
||||
* Why this is needed:
|
||||
* The Python backend is a PyInstaller onedir bundle shipped via extraResources
|
||||
* into Contents/Resources/backend/. electron-builder only hands the top-level
|
||||
* `.app` to codesign, which does NOT deep-sign the ~180 nested .so/.dylib
|
||||
* files under Resources/. Left unsigned (or without hardened runtime), Apple
|
||||
* notarization rejects the whole app.
|
||||
*
|
||||
* `mac.binaries` is the officially supported way to sign extra binaries: they
|
||||
* are signed inside electron-builder's own signing pass, AFTER it has created
|
||||
* the temporary keychain and imported the Developer ID cert (from CSC_LINK).
|
||||
* A previous afterPack approach failed because afterPack runs BEFORE that
|
||||
* keychain exists, so `codesign` couldn't find the identity.
|
||||
*
|
||||
* Paths are resolved relative to the `.app` at signing time. We enumerate the
|
||||
* pre-build backend source dir (build/dist/cowagent-backend, produced by
|
||||
* PyInstaller before packaging) — its layout mirrors the in-app copy — and map
|
||||
* each Mach-O to its in-app relative path.
|
||||
*/
|
||||
const { execFileSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const config = require('./package.json').build
|
||||
|
||||
// PyInstaller output that gets copied into the app at
|
||||
// Contents/Resources/backend/cowagent-backend (see extraResources).
|
||||
const backendSrc = path.join(__dirname, 'build', 'dist', 'cowagent-backend')
|
||||
const inAppPrefix = path.join('Contents', 'Resources', 'backend', 'cowagent-backend')
|
||||
|
||||
function isMachO(file) {
|
||||
try {
|
||||
return execFileSync('file', ['-b', file], { encoding: 'utf8' }).includes('Mach-O')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function collectBackendBinaries() {
|
||||
if (!fs.existsSync(backendSrc)) {
|
||||
console.warn(`[electron-builder.js] backend not found at ${backendSrc}; mac.binaries left empty`)
|
||||
return []
|
||||
}
|
||||
const rels = []
|
||||
const walk = (dir) => {
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const full = path.join(dir, name)
|
||||
const st = fs.lstatSync(full)
|
||||
if (st.isSymbolicLink()) continue
|
||||
if (st.isDirectory()) {
|
||||
walk(full)
|
||||
continue
|
||||
}
|
||||
if (isMachO(full)) {
|
||||
// Map source path -> in-app relative path (resolved against the .app).
|
||||
const rel = path.relative(backendSrc, full)
|
||||
rels.push(path.join(inAppPrefix, rel))
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(backendSrc)
|
||||
return rels
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const binaries = collectBackendBinaries()
|
||||
console.log(`[electron-builder.js] injecting ${binaries.length} backend binaries into mac.binaries`)
|
||||
// Sign the backend binaries here, but do NOT notarize in CI: Apple's notary
|
||||
// service routinely keeps this large PyInstaller bundle "In Progress" for
|
||||
// hours, which no CI job can afford to block on. Notarization is decoupled
|
||||
// into a manual local step (build/notarize-dmg.sh) run after the CI produces
|
||||
// the signed dmg. The dmg is code-signed and hardened-runtime enabled here,
|
||||
// so it only needs the notarization ticket stapled afterwards.
|
||||
config.mac = { ...config.mac, binaries, notarize: false }
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "cowagent-desktop",
|
||||
"version": "1.0.0",
|
||||
"version": "2.1.3",
|
||||
"description": "CowAgent Desktop Client - AI Agent on your desktop",
|
||||
"main": "dist/main/index.js",
|
||||
"author": "CowAgent",
|
||||
@@ -67,6 +67,12 @@
|
||||
"mac": {
|
||||
"category": "public.app-category.productivity",
|
||||
"icon": "resources/icon.icns",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||
"notarize": false,
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
@@ -74,11 +80,19 @@
|
||||
"arm64",
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"arm64",
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"icon": "resources/icon.ico",
|
||||
"artifactName": "${productName}-Setup-${version}-${arch}.${ext}",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
@@ -90,14 +104,14 @@
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "zhayujie",
|
||||
"repo": "chatgpt-on-wechat"
|
||||
"provider": "generic",
|
||||
"url": "https://cowagent.ai/update/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import http from 'http'
|
||||
import { PythonBackend } from './python-manager'
|
||||
import { buildAppMenu } from './menu'
|
||||
import { createTray, destroyTray } from './tray'
|
||||
import { initUpdater, checkForUpdates, startDownload, quitAndInstall } from './updater'
|
||||
import { initUpdater, checkForUpdates, startDownload, quitAndInstall, setUpdateLanguage } from './updater'
|
||||
|
||||
// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
|
||||
// where the default Electron binary would otherwise report "Electron".
|
||||
@@ -125,6 +125,16 @@ function createWindow() {
|
||||
mainWindow.loadFile(rendererHtml)
|
||||
}
|
||||
|
||||
// Surface renderer-side console output and load failures to the main-process
|
||||
// stdout. Without this, "stuck on initializing" hangs are invisible from the
|
||||
// terminal because all renderer logs stay in the (closed) devtools.
|
||||
mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => {
|
||||
console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`)
|
||||
})
|
||||
mainWindow.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
console.error(`[renderer] did-fail-load ${code} ${desc} ${url}`)
|
||||
})
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show()
|
||||
})
|
||||
@@ -160,14 +170,20 @@ async function startBackend() {
|
||||
pythonBackend = new PythonBackend(backendPath)
|
||||
|
||||
pythonBackend.on('ready', (port: number) => {
|
||||
console.log(`[backend] ready on port ${port}`)
|
||||
mainWindow?.webContents.send('backend-status', { status: 'ready', port })
|
||||
})
|
||||
|
||||
pythonBackend.on('error', (error: string) => {
|
||||
// Mirror to the main-process stdout too: otherwise backend startup errors
|
||||
// are only visible in the renderer devtools, making `npm run dev` hangs
|
||||
// impossible to diagnose from the terminal.
|
||||
console.error(`[backend] error: ${error}`)
|
||||
mainWindow?.webContents.send('backend-status', { status: 'error', error })
|
||||
})
|
||||
|
||||
pythonBackend.on('log', (line: string) => {
|
||||
console.log(`[backend] ${line}`)
|
||||
mainWindow?.webContents.send('backend-log', line)
|
||||
})
|
||||
|
||||
@@ -203,6 +219,15 @@ function setupIPC() {
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
})
|
||||
|
||||
// Open a local file with the OS default app; falls back to revealing it in
|
||||
// the file manager when no handler exists. Returns '' on success.
|
||||
ipcMain.handle('open-path', async (_event, targetPath: string) => {
|
||||
if (!targetPath) return 'empty path'
|
||||
const err = await shell.openPath(targetPath)
|
||||
if (err) shell.showItemInFolder(targetPath)
|
||||
return err
|
||||
})
|
||||
|
||||
// Custom window controls (used by Windows frameless titlebar)
|
||||
ipcMain.handle('window-minimize', () => mainWindow?.minimize())
|
||||
ipcMain.handle('window-maximize', () => {
|
||||
@@ -214,10 +239,28 @@ function setupIPC() {
|
||||
ipcMain.handle('window-close', () => mainWindow?.close())
|
||||
ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false)
|
||||
|
||||
// Auto-update controls (renderer-driven: check, then opt-in download/install)
|
||||
ipcMain.handle('update-check', () => checkForUpdates())
|
||||
ipcMain.handle('update-download', () => startDownload())
|
||||
ipcMain.handle('update-install', () => quitAndInstall())
|
||||
// Current app version, shown in the NavRail footer.
|
||||
ipcMain.handle('get-app-version', () => app.getVersion())
|
||||
|
||||
// Auto-update controls (renderer-driven: check, then opt-in download/install).
|
||||
// The renderer passes its current UI language so downloads can be routed to
|
||||
// the China CDN mirror (zh) or R2 (others).
|
||||
ipcMain.handle('update-check', (_event, lang?: string) => {
|
||||
setUpdateLanguage(lang)
|
||||
checkForUpdates()
|
||||
})
|
||||
ipcMain.handle('update-download', (_event, lang?: string) => {
|
||||
setUpdateLanguage(lang)
|
||||
startDownload()
|
||||
})
|
||||
ipcMain.handle('update-install', () => {
|
||||
// Let the window actually close so the app can fully quit — otherwise the
|
||||
// close-to-tray handler preventDefault()s it, the process stays alive, and
|
||||
// Squirrel.Mac can't swap the app bundle (the update silently no-ops and
|
||||
// relaunching still shows the old version).
|
||||
isQuitting = true
|
||||
quitAndInstall()
|
||||
})
|
||||
|
||||
// Synchronous OS locale lookup (e.g. "zh-CN", "en-US"). Used by the renderer
|
||||
// to pick a sensible default UI language on first run before any paint.
|
||||
@@ -279,10 +322,14 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
await startBackend()
|
||||
|
||||
// Wire auto-update and do a first silent check a few seconds after launch so
|
||||
// it doesn't compete with backend startup for resources.
|
||||
// Wire auto-update: a first silent check a few seconds after launch (so it
|
||||
// doesn't compete with backend startup), then poll every 4 hours so a
|
||||
// long-running window still surfaces new releases. autoDownload is off, so a
|
||||
// found update only lights the badge + opens the panel for the user to opt in.
|
||||
initUpdater(() => mainWindow)
|
||||
setTimeout(() => checkForUpdates(), 5000)
|
||||
const UPDATE_POLL_MS = 4 * 60 * 60 * 1000
|
||||
setInterval(() => checkForUpdates(), UPDATE_POLL_MS)
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
|
||||
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
restartBackend: () => ipcRenderer.invoke('restart-backend'),
|
||||
selectDirectory: () => ipcRenderer.invoke('select-directory'),
|
||||
selectFile: (filters?: Electron.FileFilter[]) => ipcRenderer.invoke('select-file', filters),
|
||||
openPath: (targetPath: string) => ipcRenderer.invoke('open-path', targetPath) as Promise<string>,
|
||||
|
||||
// Each listener registrar returns an unsubscribe fn so renderers can clean
|
||||
// up on unmount / effect re-run and avoid accumulating duplicate handlers.
|
||||
@@ -39,9 +40,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
return () => ipcRenderer.removeListener('menu-action', handler)
|
||||
},
|
||||
|
||||
// Auto-update: trigger checks/download/install and subscribe to status.
|
||||
checkForUpdate: () => ipcRenderer.invoke('update-check'),
|
||||
downloadUpdate: () => ipcRenderer.invoke('update-download'),
|
||||
// Current app version (e.g. "0.0.5"), shown in the NavRail footer.
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
|
||||
// Auto-update: trigger checks/download/install and subscribe to status. The
|
||||
// optional lang routes installer downloads to the China CDN mirror (zh) or R2.
|
||||
checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang),
|
||||
downloadUpdate: (lang?: string) => ipcRenderer.invoke('update-download', lang),
|
||||
installUpdate: () => ipcRenderer.invoke('update-install'),
|
||||
onUpdateStatus: (callback: (status: unknown) => void) => {
|
||||
const handler = (_event: unknown, status: unknown) => callback(status)
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { ChildProcess, spawn } from 'child_process'
|
||||
import { ChildProcess, spawn, execFileSync } from 'child_process'
|
||||
import { EventEmitter } from 'events'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import fs from 'fs'
|
||||
import http from 'http'
|
||||
import net from 'net'
|
||||
|
||||
// Writable data dir for the packaged app (config.json, run.log, user data).
|
||||
// Lives in the user's home so it survives app updates and avoids writing into
|
||||
// the read-only app bundle. Source/dev runs keep using the repo CWD instead.
|
||||
const COW_DATA_DIR = path.join(os.homedir(), '.cow')
|
||||
|
||||
// Fixed port for the desktop backend. Deliberately not 9899 (the web console's
|
||||
// default) so a source-run `python app.py` never collides with the packaged
|
||||
// app. This is a SINGLE SOURCE OF TRUTH shared with the renderer (see
|
||||
// useBackend.ts BACKEND_PORT): the backend is always told to bind exactly here
|
||||
// via COW_WEB_PORT, and the renderer always talks to exactly here. We do NOT
|
||||
// fall back to an OS-random port, because the renderer could never guess it —
|
||||
// instead we proactively free this port before launch (see freePort()).
|
||||
export const DESKTOP_BACKEND_PORT = 9876
|
||||
|
||||
export class PythonBackend extends EventEmitter {
|
||||
private process: ChildProcess | null = null
|
||||
private backendPath: string
|
||||
private port: number = 9899
|
||||
private port: number = DESKTOP_BACKEND_PORT
|
||||
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
|
||||
|
||||
constructor(backendPath: string) {
|
||||
@@ -29,6 +39,77 @@ export class PythonBackend extends EventEmitter {
|
||||
return this.status
|
||||
}
|
||||
|
||||
// Cache the resolved PATH so we only spawn a login shell once per process.
|
||||
private resolvedPath: string | null = null
|
||||
|
||||
/**
|
||||
* Build the PATH the backend should run with.
|
||||
*
|
||||
* When launched from Finder/Dock, a GUI app inherits launchd's minimal PATH
|
||||
* (/usr/bin:/bin:...) and never loads ~/.zshrc, so user-installed CLIs like
|
||||
* `linkai`, `node`, or Homebrew tools are invisible to the agent's bash tool.
|
||||
* We recover the real login-shell PATH (macOS/Linux) and merge in common bin
|
||||
* dirs, so the agent can find these commands regardless of how the app started.
|
||||
*/
|
||||
private resolveEnvPath(): string {
|
||||
if (this.resolvedPath !== null) {
|
||||
return this.resolvedPath
|
||||
}
|
||||
|
||||
const sep = path.delimiter
|
||||
const existing = process.env.PATH || ''
|
||||
const parts: string[] = existing ? existing.split(sep) : []
|
||||
|
||||
// Windows GUI apps already inherit the full system PATH; nothing to fix.
|
||||
if (process.platform !== 'win32') {
|
||||
// Ask the user's login shell for its PATH. `-ilc` runs an interactive
|
||||
// login shell so it sources ~/.zshrc / ~/.zprofile etc.
|
||||
try {
|
||||
const shell = process.env.SHELL || '/bin/zsh'
|
||||
const out = execFileSync(shell, ['-ilc', 'echo -n "__PATH__$PATH"'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
})
|
||||
const marker = out.lastIndexOf('__PATH__')
|
||||
if (marker !== -1) {
|
||||
const shellPath = out.slice(marker + '__PATH__'.length).trim()
|
||||
if (shellPath) {
|
||||
parts.push(...shellPath.split(sep))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Shell probe failed (unusual shell, timeout). Fall back to the
|
||||
// common dirs below so at least the typical install paths work.
|
||||
}
|
||||
|
||||
const home = os.homedir()
|
||||
parts.push(
|
||||
path.join(home, '.local/bin'),
|
||||
'/usr/local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/usr/bin',
|
||||
'/bin',
|
||||
'/usr/sbin',
|
||||
'/sbin',
|
||||
)
|
||||
}
|
||||
|
||||
// De-duplicate while preserving order (first occurrence wins).
|
||||
const seen = new Set<string>()
|
||||
const merged: string[] = []
|
||||
for (const p of parts) {
|
||||
const dir = p.trim()
|
||||
if (dir && !seen.has(dir)) {
|
||||
seen.add(dir)
|
||||
merged.push(dir)
|
||||
}
|
||||
}
|
||||
|
||||
this.resolvedPath = merged.join(sep)
|
||||
return this.resolvedPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the packaged onedir backend executable shipped with the app.
|
||||
* Returns null when not present (e.g. during local development), so we can
|
||||
@@ -66,23 +147,125 @@ export class PythonBackend extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve config.json from the given data dir to read the web port. The
|
||||
* Read an explicit `web_port` from config.json, if the user pinned one. The
|
||||
* packaged build keeps config in COW_DATA_DIR (~/.cow); dev reads it from the
|
||||
* repo path. Returns the default port when no config (or web_port) is found.
|
||||
* repo path. Returns null when unset, so the caller can auto-pick a free port
|
||||
* instead of fighting over a fixed one.
|
||||
*/
|
||||
private readPort(dataDir: string): number {
|
||||
private readConfiguredPort(dataDir: string): number | null {
|
||||
try {
|
||||
const configPath = path.join(dataDir, 'config.json')
|
||||
if (fs.existsSync(configPath)) {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
if (config.web_port) {
|
||||
return config.web_port
|
||||
const p = Number(config.web_port)
|
||||
if (Number.isInteger(p) && p > 0 && p < 65536) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore — fall through to auto-selection
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the port to bind. The whole point is determinism: the renderer must
|
||||
* be able to reach the backend WITHOUT guessing, so we use exactly one fixed
|
||||
* port (DESKTOP_BACKEND_PORT) unless the user explicitly pinned a web_port.
|
||||
* We never auto-roll to a random port — instead start() proactively frees the
|
||||
* fixed port. The returned value is the single source of truth handed to both
|
||||
* the backend (COW_WEB_PORT) and the renderer (getBackendPort IPC).
|
||||
*/
|
||||
private resolvePort(dataDir: string): number {
|
||||
const pinned = this.readConfiguredPort(dataDir)
|
||||
return pinned !== null ? pinned : DESKTOP_BACKEND_PORT
|
||||
}
|
||||
|
||||
/** True if we can bind 127.0.0.1:port right now (i.e. it's free). */
|
||||
private isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const tester = net
|
||||
.createServer()
|
||||
.once('error', () => resolve(false))
|
||||
.once('listening', () => {
|
||||
tester.close(() => resolve(true))
|
||||
})
|
||||
.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure our fixed port is usable before launch by killing whatever is
|
||||
* holding it (almost always a stale backend from a previous run that didn't
|
||||
* shut down cleanly). We only ever target a process actually listening on
|
||||
* 127.0.0.1:<port>, so we won't touch unrelated apps. Best-effort: if we
|
||||
* can't free it we still try to bind and let the backend surface EADDRINUSE.
|
||||
*/
|
||||
private async freePort(port: number): Promise<void> {
|
||||
if (await this.isPortFree(port)) {
|
||||
return
|
||||
}
|
||||
this.emit('log', `Port ${port} is busy — clearing stale process before launch`)
|
||||
const pids = await this.findListenerPids(port)
|
||||
for (const pid of pids) {
|
||||
// Never signal ourselves (Electron could, in theory, be the listener).
|
||||
if (pid === process.pid) continue
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
} catch {
|
||||
// already gone / no permission — ignore
|
||||
}
|
||||
}
|
||||
// Give the OS a beat to release the socket, then force-kill leftovers.
|
||||
await new Promise((r) => setTimeout(r, 600))
|
||||
if (!(await this.isPortFree(port))) {
|
||||
for (const pid of await this.findListenerPids(port)) {
|
||||
if (pid === process.pid) continue
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 9899
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
}
|
||||
}
|
||||
|
||||
/** PIDs listening on 127.0.0.1:<port>, via lsof (POSIX) / netstat (Windows). */
|
||||
private findListenerPids(port: number): Promise<number[]> {
|
||||
return new Promise((resolve) => {
|
||||
const isWin = process.platform === 'win32'
|
||||
const cmd = isWin ? 'netstat' : 'lsof'
|
||||
const args = isWin
|
||||
? ['-ano', '-p', 'tcp']
|
||||
: ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']
|
||||
let out = ''
|
||||
try {
|
||||
const child = spawn(cmd, args)
|
||||
child.stdout?.on('data', (d: Buffer) => (out += d.toString()))
|
||||
child.on('error', () => resolve([]))
|
||||
child.on('close', () => {
|
||||
const pids = new Set<number>()
|
||||
if (isWin) {
|
||||
// Match lines like: TCP 127.0.0.1:9876 ... LISTENING 12345
|
||||
for (const line of out.split('\n')) {
|
||||
if (!/LISTENING/i.test(line)) continue
|
||||
if (!new RegExp(`[:.]${port}\\b`).test(line)) continue
|
||||
const pid = Number(line.trim().split(/\s+/).pop())
|
||||
if (Number.isInteger(pid) && pid > 0) pids.add(pid)
|
||||
}
|
||||
} else {
|
||||
for (const tok of out.split(/\s+/)) {
|
||||
const pid = Number(tok)
|
||||
if (Number.isInteger(pid) && pid > 0) pids.add(pid)
|
||||
}
|
||||
}
|
||||
resolve([...pids])
|
||||
})
|
||||
} catch {
|
||||
resolve([])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
@@ -97,15 +280,16 @@ export class PythonBackend extends EventEmitter {
|
||||
const bundled = this.findBundledBackend()
|
||||
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
|
||||
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
|
||||
this.port = this.readPort(dataDir)
|
||||
|
||||
const alreadyRunning = await this.probeHealth()
|
||||
if (alreadyRunning) {
|
||||
this.status = 'ready'
|
||||
this.emit('log', `Backend already running on port ${this.port}`)
|
||||
this.emit('ready', this.port)
|
||||
return
|
||||
}
|
||||
// Always launch our OWN backend (re-entrancy is guarded above by the status
|
||||
// check, so we never double-spawn for this instance). We don't reuse
|
||||
// whatever happens to be on the port: that's how the app previously attached
|
||||
// to a source-run web console and read the wrong config. The port is fixed
|
||||
// (or the user's pinned web_port) — never random — so the renderer always
|
||||
// knows it. We then proactively free that port (kill stale listeners)
|
||||
// before spawning, so a leftover process from a previous run can't block us.
|
||||
this.port = this.resolvePort(dataDir)
|
||||
await this.freePort(this.port)
|
||||
|
||||
let command: string
|
||||
let args: string[]
|
||||
@@ -114,9 +298,19 @@ export class PythonBackend extends EventEmitter {
|
||||
if (bundled) {
|
||||
command = bundled
|
||||
args = []
|
||||
// The onedir bundle reads data files relative to the executable's dir.
|
||||
cwd = path.dirname(bundled)
|
||||
this.emit('log', `Starting bundled backend: ${bundled}`)
|
||||
// Run from the writable data dir (~/.cow), NOT the install dir. When the
|
||||
// app is installed under Program Files, a non-admin user has no write
|
||||
// permission to the executable's folder, so any relative-path write
|
||||
// during startup would crash the backend (works only as admin). The
|
||||
// bundle reads its read-only resources via sys._MEIPASS, so cwd is free
|
||||
// to point elsewhere.
|
||||
try {
|
||||
fs.mkdirSync(COW_DATA_DIR, { recursive: true })
|
||||
} catch {
|
||||
// ignore — get_data_root() also ensures the dir on the Python side
|
||||
}
|
||||
cwd = COW_DATA_DIR
|
||||
this.emit('log', `Starting bundled backend: ${bundled} (cwd=${cwd})`)
|
||||
} else {
|
||||
const pythonPath = this.findPython()
|
||||
const appPath = path.join(this.backendPath, 'app.py')
|
||||
@@ -138,8 +332,15 @@ export class PythonBackend extends EventEmitter {
|
||||
// app bundle stays read-only; dev runs omit it and keep using the repo.
|
||||
env: {
|
||||
...process.env,
|
||||
// Recover the user's real PATH (login shell + common bin dirs) so the
|
||||
// agent's bash tool can find CLIs like `linkai`/`node` even when the
|
||||
// app is launched from Finder/Dock with launchd's minimal PATH.
|
||||
PATH: this.resolveEnvPath(),
|
||||
PYTHONUNBUFFERED: '1',
|
||||
COW_DESKTOP: '1',
|
||||
// The shell owns the port: tell the backend to bind exactly here so the
|
||||
// two sides can never disagree (and we avoid the 9899 web-console clash).
|
||||
COW_WEB_PORT: String(this.port),
|
||||
...(bundled ? { COW_DATA_DIR } : {}),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
@@ -160,10 +361,15 @@ export class PythonBackend extends EventEmitter {
|
||||
})
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
// If the backend dies before it ever became ready, surface an error now
|
||||
// instead of letting waitForReady spin for the full timeout. A clean exit
|
||||
// (code 0/null, e.g. our own stop()) just marks stopped.
|
||||
const wasReady = this.status === 'ready'
|
||||
this.status = 'stopped'
|
||||
this.emit('log', `Python process exited with code ${code}`)
|
||||
if (code !== 0 && code !== null) {
|
||||
this.emit('error', `Python process exited with code ${code}`)
|
||||
if (!wasReady && code !== 0 && code !== null) {
|
||||
this.status = 'error'
|
||||
this.emit('error', `Backend exited during startup (code ${code})`)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -175,16 +381,6 @@ export class PythonBackend extends EventEmitter {
|
||||
await this.waitForReady()
|
||||
}
|
||||
|
||||
private probeHealth(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
|
||||
resolve(res.statusCode === 200)
|
||||
})
|
||||
req.on('error', () => resolve(false))
|
||||
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
|
||||
})
|
||||
}
|
||||
|
||||
private waitForReady(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// Wall-clock deadline rather than an attempt counter: if the machine
|
||||
@@ -194,7 +390,10 @@ export class PythonBackend extends EventEmitter {
|
||||
const startedAt = Date.now()
|
||||
|
||||
const check = () => {
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
|
||||
// Probe the unauthenticated health endpoint, NOT /config: /config
|
||||
// requires auth once a web_password is set, which would make this poll
|
||||
// 401 forever and hang startup.
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/api/health`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
this.status = 'ready'
|
||||
this.emit('log', `Backend ready on port ${this.port}`)
|
||||
@@ -213,7 +412,9 @@ export class PythonBackend extends EventEmitter {
|
||||
}
|
||||
|
||||
const retry = () => {
|
||||
if (this.status === 'stopped' || this.status === 'ready') {
|
||||
// Backend already settled: ready (done), or stopped/errored by the exit
|
||||
// handler (don't keep polling a dead process — the error was emitted).
|
||||
if (this.status === 'ready' || this.status === 'stopped' || this.status === 'error') {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
// electron-updater is CommonJS: its members live on module.exports, with no
|
||||
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
||||
// import compiles to `electron_updater_1.autoUpdater` and resolves correctly,
|
||||
@@ -17,16 +20,107 @@ export type UpdateStatus =
|
||||
|
||||
let getWindow: () => BrowserWindow | null = () => null
|
||||
|
||||
// Legacy Windows (7/8/8.1) runs the separate Electron-22 build, which must
|
||||
// update to OTHER legacy builds — never the standard build (Electron 33 won't
|
||||
// launch on Win7). The update Function serves that build under /update/legacy/.
|
||||
// We detect the old OS at runtime (os.release() reports the Windows NT version:
|
||||
// 6.1 = Win7, 6.2/6.3 = Win8/8.1, 10.x = Win10/11) rather than via a build
|
||||
// flag, so the same source serves the right feed on whatever it runs on.
|
||||
function isLegacyWindows(): boolean {
|
||||
if (process.platform !== 'win32') return false
|
||||
const major = Number((os.release() || '').split('.')[0])
|
||||
// NT 6.x = Win7/8/8.1; NT 10.x = Win10/11. Old = major < 10.
|
||||
return Number.isFinite(major) && major < 10
|
||||
}
|
||||
|
||||
// The update feed. Both entries hit the same Pages Function
|
||||
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
||||
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
||||
// identical either way, so we can freely switch the feed URL between attempts
|
||||
// to fall back from one download origin to the other. Legacy Windows appends a
|
||||
// /legacy/ segment so it gets the win-legacy release instead of the standard.
|
||||
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
|
||||
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
||||
|
||||
// Which origin the current session prefers, derived from the app UI language
|
||||
// (zh -> China mirror). Downloads that fail on the preferred origin retry once
|
||||
// on the other one before surfacing an error.
|
||||
let preferChina = false
|
||||
// Guard so a single download only ever falls back once (avoids ping-pong).
|
||||
let downloadFellBack = false
|
||||
|
||||
function applyFeedUrl(): void {
|
||||
const url = feedUrlFor(preferChina)
|
||||
try {
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url })
|
||||
log(`feed url set: ${url} (preferChina=${preferChina})`)
|
||||
} catch (err) {
|
||||
log(`feed url set failed: ${(err as Error)?.message || String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Called from the check/download IPC with the renderer's current UI language.
|
||||
export function setUpdateLanguage(lang: string | undefined): void {
|
||||
const china = (lang || '').toLowerCase().startsWith('zh')
|
||||
if (china !== preferChina) {
|
||||
preferChina = china
|
||||
if (app.isPackaged) applyFeedUrl()
|
||||
}
|
||||
}
|
||||
|
||||
// Persist update logs to a file so a user hitting a silent "spinner never
|
||||
// resolves" can just send us userData/logs/updater.log. We can't rely on a
|
||||
// logging dep, so this is a tiny append-only writer, plus console for the
|
||||
// in-app log view / terminal.
|
||||
let logFile: string | null = null
|
||||
|
||||
function initLogFile() {
|
||||
try {
|
||||
const dir = path.join(app.getPath('userData'), 'logs')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
logFile = path.join(dir, 'updater.log')
|
||||
} catch {
|
||||
logFile = null
|
||||
}
|
||||
}
|
||||
|
||||
function log(...parts: unknown[]) {
|
||||
const line = `[${new Date().toISOString()}] [updater] ${parts
|
||||
.map((p) => (typeof p === 'string' ? p : safeStringify(p)))
|
||||
.join(' ')}`
|
||||
// Console: shows up in the terminal (dev) and the packaged app's stdout.
|
||||
console.log(line)
|
||||
if (logFile) {
|
||||
try {
|
||||
fs.appendFileSync(logFile, line + '\n')
|
||||
} catch {
|
||||
// ignore disk errors — logging must never break the updater
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeStringify(v: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(v)
|
||||
} catch {
|
||||
return String(v)
|
||||
}
|
||||
}
|
||||
|
||||
function send(status: UpdateStatus) {
|
||||
getWindow()?.webContents.send('update-status', status)
|
||||
}
|
||||
|
||||
export function initUpdater(windowGetter: () => BrowserWindow | null): void {
|
||||
getWindow = windowGetter
|
||||
initLogFile()
|
||||
|
||||
log(`init: appVersion=${app.getVersion()} packaged=${app.isPackaged} logFile=${logFile ?? '<none>'}`)
|
||||
|
||||
// In dev (not packaged) there's no update feed; skip wiring entirely so
|
||||
// electron-updater doesn't throw on the missing app-update.yml.
|
||||
if (!app.isPackaged) {
|
||||
log('not packaged — updater wiring skipped')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,40 +128,133 @@ export function initUpdater(windowGetter: () => BrowserWindow | null): void {
|
||||
// download, rather than pulling bytes silently in the background.
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
// The desktop channel ships pre-release-tagged builds (e.g. 0.0.8-test), so a
|
||||
// current version like 0.0.7-test must be allowed to compare against, and be
|
||||
// offered, other pre-release versions. Without this electron-updater's semver
|
||||
// compare can silently skip pre-releases and neither "available" nor
|
||||
// "not-available" fires — the UI just spins forever.
|
||||
autoUpdater.allowPrerelease = true
|
||||
autoUpdater.allowDowngrade = false
|
||||
// Point at the preferred origin up front (defaults to R2; switched to the CN
|
||||
// mirror once the renderer reports a zh UI language via setUpdateLanguage).
|
||||
applyFeedUrl()
|
||||
// Route electron-updater's own internal logging to our file too, so we
|
||||
// capture the feed URL, parsed versions and any stack traces it logs.
|
||||
autoUpdater.logger = {
|
||||
info: (m: unknown) => log('eu-info:', m),
|
||||
warn: (m: unknown) => log('eu-warn:', m),
|
||||
error: (m: unknown) => log('eu-error:', m),
|
||||
debug: (m: unknown) => log('eu-debug:', m),
|
||||
} as unknown as typeof autoUpdater.logger
|
||||
|
||||
autoUpdater.on('checking-for-update', () => send({ state: 'checking' }))
|
||||
autoUpdater.on('update-available', (info) =>
|
||||
send({ state: 'available', version: info.version, notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined })
|
||||
)
|
||||
autoUpdater.on('update-not-available', () => send({ state: 'not-available' }))
|
||||
autoUpdater.on('download-progress', (p) =>
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
log(`checking-for-update: current=${app.getVersion()}`)
|
||||
send({ state: 'checking' })
|
||||
})
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
log(`update-available: current=${app.getVersion()} remote=${info.version} -> update needed`)
|
||||
send({
|
||||
state: 'available',
|
||||
version: info.version,
|
||||
notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
|
||||
})
|
||||
})
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
log(`update-not-available: current=${app.getVersion()} remote=${info?.version ?? '<unknown>'} -> up to date`)
|
||||
send({ state: 'not-available' })
|
||||
})
|
||||
autoUpdater.on('download-progress', (p) => {
|
||||
log(`download-progress: ${Math.round(p.percent)}% (${p.transferred}/${p.total} bytes, ${Math.round(p.bytesPerSecond / 1024)} KB/s)`)
|
||||
send({ state: 'downloading', percent: Math.round(p.percent) })
|
||||
)
|
||||
autoUpdater.on('update-downloaded', (info) =>
|
||||
})
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
log(`update-downloaded: version=${info.version} -> ready to install`)
|
||||
send({ state: 'downloaded', version: info.version })
|
||||
)
|
||||
autoUpdater.on('error', (err) =>
|
||||
send({ state: 'error', message: err == null ? 'unknown' : (err.message || String(err)) })
|
||||
)
|
||||
})
|
||||
autoUpdater.on('error', (err) => {
|
||||
const message = err == null ? 'unknown' : err.message || String(err)
|
||||
log(`error: ${message}`, err instanceof Error && err.stack ? err.stack : '')
|
||||
send({ state: 'error', message })
|
||||
})
|
||||
}
|
||||
|
||||
// Silent check shortly after launch; safe to call when not packaged (no-op).
|
||||
// Silent check shortly after launch. When not packaged there's no update feed,
|
||||
// but a manual click should still get visible feedback instead of looking dead:
|
||||
// reply "not-available" so the menu can show "up to date".
|
||||
export function checkForUpdates(): void {
|
||||
if (!app.isPackaged) return
|
||||
if (!app.isPackaged) {
|
||||
// Dev-only UI harness: set COW_MOCK_UPDATE=1 to simulate an available
|
||||
// update so the update panel/menu interactions can be exercised in
|
||||
// `npm run dev` (where there's no real feed). Never runs in a packaged app.
|
||||
if (process.env.COW_MOCK_UPDATE) {
|
||||
const version = process.env.COW_MOCK_UPDATE_VERSION || '9.9.9'
|
||||
log(`checkForUpdates: not packaged, MOCK available version=${version}`)
|
||||
send({ state: 'available', version })
|
||||
return
|
||||
}
|
||||
log('checkForUpdates: not packaged, replying not-available')
|
||||
send({ state: 'not-available' })
|
||||
return
|
||||
}
|
||||
log(`checkForUpdates: requesting feed, current=${app.getVersion()}`)
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
send({ state: 'error', message: err?.message || String(err) })
|
||||
const message = err?.message || String(err)
|
||||
log(`checkForUpdates: request failed: ${message}`, err instanceof Error && err.stack ? err.stack : '')
|
||||
send({ state: 'error', message })
|
||||
})
|
||||
}
|
||||
|
||||
export function startDownload(): void {
|
||||
if (!app.isPackaged) return
|
||||
downloadFellBack = false
|
||||
log(`startDownload: user requested download (preferChina=${preferChina})`)
|
||||
attemptDownload()
|
||||
}
|
||||
|
||||
// Download from the current origin; on failure, switch to the OTHER origin once
|
||||
// and retry. This is the client-side "mirrors back each other" fallback: R2 and
|
||||
// the China CDN hold identical bytes, so a slow/blocked origin can be swapped
|
||||
// transparently without the user noticing.
|
||||
function attemptDownload(): void {
|
||||
autoUpdater.downloadUpdate().catch((err) => {
|
||||
send({ state: 'error', message: err?.message || String(err) })
|
||||
const message = err?.message || String(err)
|
||||
log(`startDownload: failed on ${preferChina ? 'CN' : 'R2'}: ${message}`, err instanceof Error && err.stack ? err.stack : '')
|
||||
if (!downloadFellBack) {
|
||||
downloadFellBack = true
|
||||
preferChina = !preferChina
|
||||
applyFeedUrl()
|
||||
log(`startDownload: retrying on ${preferChina ? 'CN' : 'R2'} mirror`)
|
||||
// Re-check first so electron-updater re-reads the feed from the new origin
|
||||
// before downloading (its cached updateInfo is origin-agnostic here, but a
|
||||
// fresh check keeps the internal state consistent).
|
||||
autoUpdater
|
||||
.checkForUpdates()
|
||||
.then(() => autoUpdater.downloadUpdate())
|
||||
.catch((err2) => {
|
||||
const m2 = err2?.message || String(err2)
|
||||
log(`startDownload: fallback also failed: ${m2}`, err2 instanceof Error && err2.stack ? err2.stack : '')
|
||||
send({ state: 'error', message: m2 })
|
||||
})
|
||||
return
|
||||
}
|
||||
send({ state: 'error', message })
|
||||
})
|
||||
}
|
||||
|
||||
export function quitAndInstall(): void {
|
||||
if (!app.isPackaged) return
|
||||
// isSilent=false (show installer), isForceRunAfter=true (relaunch after).
|
||||
autoUpdater.quitAndInstall(false, true)
|
||||
log('quitAndInstall: relaunching to install update')
|
||||
// Drop window-all-closed handlers first: a lingering handler can keep the
|
||||
// process alive and stop the installer from replacing files / relaunching
|
||||
// (a documented electron-updater gotcha, esp. on Windows NSIS).
|
||||
app.removeAllListeners('window-all-closed')
|
||||
// isSilent=TRUE on Windows. Our installer is now ASSISTED (nsis.oneClick=false
|
||||
// + allowToChangeInstallationDirectory) so the FIRST install shows the
|
||||
// directory/mode wizard. But an UPDATE must NOT re-show that wizard — isSilent
|
||||
// skips it and updates in place. isForceRunAfter=true relaunches after the
|
||||
// silent update. (The old assisted+silent force-run bug, #2179, was fixed
|
||||
// upstream in PR #2278; we're on electron-updater 6.8.9, well past it.)
|
||||
// setImmediate + removeAllListeners are the documented prerequisites for the
|
||||
// relaunch to fire reliably. macOS ignores isSilent entirely.
|
||||
setImmediate(() => autoUpdater.quitAndInstall(true, true))
|
||||
}
|
||||
|
||||
21
desktop/src/renderer/assets.d.ts
vendored
Normal file
21
desktop/src/renderer/assets.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
// Type declarations for static asset imports handled by Vite.
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.jpg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.jpeg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.svg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.webp' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data: blob: http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:*; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*;" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data: blob: http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; media-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; connect-src 'self' https: http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*;" />
|
||||
<title>CowAgent</title>
|
||||
<!-- Local fonts & icons (offline, no CDN) served from publicDir -->
|
||||
<link rel="stylesheet" href="./vendor/fonts/inter/inter.css" />
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { PanelLeftOpen } from 'lucide-react'
|
||||
import { History } from 'lucide-react'
|
||||
import NavRail from './layout/NavRail'
|
||||
import SessionList from './layout/SessionList'
|
||||
import WindowControls from './layout/WindowControls'
|
||||
import StatusScreen from './components/StatusScreen'
|
||||
import LoginGate from './components/LoginGate'
|
||||
import { useBackend } from './hooks/useBackend'
|
||||
import { usePlatform } from './hooks/usePlatform'
|
||||
import { useUIStore } from './store/uiStore'
|
||||
@@ -27,21 +28,50 @@ const App: React.FC = () => {
|
||||
const backend = useBackend()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { isWin } = usePlatform()
|
||||
const { sessionsCollapsed, toggleSessions } = useUIStore()
|
||||
const { isWin, isMac } = usePlatform()
|
||||
const { sessionsCollapsed, toggleSessions, navCollapsed } = useUIStore()
|
||||
const onboardingOpen = useOnboardingStore((s) => s.open)
|
||||
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
|
||||
const [, forceUpdate] = useState(0)
|
||||
// Auth gate for web_password-protected backends. 'checking' until we know
|
||||
// whether login is needed; 'need_login' shows the password screen; 'ok' lets
|
||||
// the main UI render.
|
||||
const [authState, setAuthState] = useState<'checking' | 'need_login' | 'ok'>('checking')
|
||||
|
||||
useEffect(() => {
|
||||
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
||||
}, [backend.status, backend.baseUrl])
|
||||
|
||||
// Once the backend is ready, check whether a web_password is set. If so and
|
||||
// this session isn't authenticated, show the login gate before the app.
|
||||
useEffect(() => {
|
||||
if (backend.status !== 'ready') {
|
||||
setAuthState('checking')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
apiClient
|
||||
.authCheck()
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
const needLogin = res.auth_required && !res.authenticated
|
||||
setAuthState(needLogin ? 'need_login' : 'ok')
|
||||
})
|
||||
.catch(() => {
|
||||
// If the check itself fails, don't hard-block the user — assume no auth
|
||||
// is required (backends without web_password never return errors here).
|
||||
if (!cancelled) setAuthState('ok')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backend.status, backend.baseUrl])
|
||||
|
||||
// First-run check: once the backend is ready, decide whether to show the
|
||||
// onboarding wizard. It's config-driven — shown whenever the chat model isn't
|
||||
// configured (and not dismissed earlier this session); no persisted flag.
|
||||
useEffect(() => {
|
||||
if (backend.status !== 'ready') return
|
||||
if (backend.status !== 'ready' || authState !== 'ok') return
|
||||
let cancelled = false
|
||||
apiClient
|
||||
.getModels()
|
||||
@@ -65,7 +95,7 @@ const App: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backend.status, maybeOpenOnboarding])
|
||||
}, [backend.status, authState, maybeOpenOnboarding])
|
||||
|
||||
// Subscribe to auto-update status from the main process (no-op in dev).
|
||||
useEffect(() => initUpdateListener(), [])
|
||||
@@ -91,6 +121,15 @@ const App: React.FC = () => {
|
||||
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
|
||||
}
|
||||
|
||||
// Backend is up but we're still resolving auth — keep the loading screen.
|
||||
if (authState === 'checking') {
|
||||
return <StatusScreen status="connecting" onRetry={backend.restart} />
|
||||
}
|
||||
|
||||
if (authState === 'need_login') {
|
||||
return <LoginGate onAuthenticated={() => setAuthState('ok')} />
|
||||
}
|
||||
|
||||
const isChat = location.pathname === '/'
|
||||
const showSessions = isChat && !sessionsCollapsed
|
||||
|
||||
@@ -107,10 +146,13 @@ const App: React.FC = () => {
|
||||
{isChat && sessionsCollapsed && (
|
||||
<button
|
||||
onClick={toggleSessions}
|
||||
title={t('nav_expand')}
|
||||
className="titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors"
|
||||
title={t('session_history')}
|
||||
// Keep aligned with the SessionList history button: only nudge
|
||||
// right of the macOS traffic lights when the nav rail is collapsed
|
||||
// (otherwise the lights stay within the rail and don't overlap).
|
||||
className={`titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors ${isMac ? 'mt-1' : ''} ${isMac && navCollapsed ? 'ml-2' : ''}`}
|
||||
>
|
||||
<PanelLeftOpen size={16} />
|
||||
<History size={16} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
KnowledgeList,
|
||||
KnowledgeGraph,
|
||||
KnowledgeAction,
|
||||
KnowledgeImportPayload,
|
||||
} from '../types'
|
||||
|
||||
interface ApiResult {
|
||||
@@ -23,8 +24,16 @@ interface ApiResult {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const AUTH_TOKEN_KEY = 'cow_auth_token'
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl = 'http://127.0.0.1:9899'
|
||||
private baseUrl = 'http://127.0.0.1:9876'
|
||||
// Bearer token for web_password-protected backends. The desktop renderer
|
||||
// runs from a file:// origin, where cross-origin cookies to http://127.0.0.1
|
||||
// aren't sent reliably, so we authenticate via an Authorization header
|
||||
// instead. Persisted in localStorage so it survives reloads.
|
||||
private authToken: string | null =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem(AUTH_TOKEN_KEY) : null
|
||||
|
||||
setBaseUrl(url: string) {
|
||||
this.baseUrl = url
|
||||
@@ -34,13 +43,25 @@ class ApiClient {
|
||||
return this.baseUrl
|
||||
}
|
||||
|
||||
setAuthToken(token: string | null) {
|
||||
this.authToken = token
|
||||
try {
|
||||
if (token) localStorage.setItem(AUTH_TOKEN_KEY, token)
|
||||
else localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
} catch {
|
||||
// localStorage may be unavailable; in-memory token still works this session
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...options,
|
||||
// Send cookies for future web_password auth support
|
||||
// Cookies still work for browser access; the desktop app relies on the
|
||||
// Authorization header below.
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}),
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
@@ -92,8 +113,16 @@ class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
// EventSource can't set an Authorization header, so append the auth token as
|
||||
// a query param for SSE endpoints (the backend accepts it there).
|
||||
private withToken(url: string): string {
|
||||
if (!this.authToken) return url
|
||||
const sep = url.includes('?') ? '&' : '?'
|
||||
return `${url}${sep}token=${encodeURIComponent(this.authToken)}`
|
||||
}
|
||||
|
||||
createSSEStream(requestId: string): EventSource {
|
||||
return new EventSource(`${this.baseUrl}/stream?request_id=${requestId}`)
|
||||
return new EventSource(this.withToken(`${this.baseUrl}/stream?request_id=${requestId}`))
|
||||
}
|
||||
|
||||
async deleteMessage(opts: {
|
||||
@@ -137,11 +166,13 @@ class ApiClient {
|
||||
|
||||
getFileUrl(previewUrl: string): string {
|
||||
if (/^https?:\/\//.test(previewUrl)) return previewUrl
|
||||
return `${this.baseUrl}${previewUrl}`
|
||||
// Served via <img src>, which can't set headers — carry the token in the
|
||||
// query so protected file endpoints load under web_password.
|
||||
return this.withToken(`${this.baseUrl}${previewUrl}`)
|
||||
}
|
||||
|
||||
getServeFileUrl(absPath: string): string {
|
||||
return `${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`
|
||||
return this.withToken(`${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
@@ -315,6 +346,23 @@ class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
// Bulk import: upload .md/.txt files into a target category (multipart).
|
||||
async importKnowledge(
|
||||
files: File[],
|
||||
targetCategory: string
|
||||
): Promise<{ status: string; message?: string; payload?: KnowledgeImportPayload }> {
|
||||
const formData = new FormData()
|
||||
formData.append('target_category', targetCategory)
|
||||
formData.append('conflict_strategy', 'rename')
|
||||
files.forEach((file) => formData.append('files', file, file.name))
|
||||
const res = await fetch(`${this.baseUrl}/api/knowledge/import`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------
|
||||
@@ -324,6 +372,13 @@ class ApiClient {
|
||||
return data.tasks
|
||||
}
|
||||
|
||||
async runTask(taskId: string): Promise<ApiResult> {
|
||||
return this.request('/api/scheduler/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
})
|
||||
}
|
||||
|
||||
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
|
||||
return this.request('/api/scheduler/toggle', {
|
||||
method: 'POST',
|
||||
@@ -372,7 +427,7 @@ class ApiClient {
|
||||
// ---------------------------------------------------------
|
||||
|
||||
createLogStream(): EventSource {
|
||||
return new EventSource(`${this.baseUrl}/api/logs`)
|
||||
return new EventSource(this.withToken(`${this.baseUrl}/api/logs`))
|
||||
}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
@@ -388,14 +443,19 @@ class ApiClient {
|
||||
return this.request('/auth/check')
|
||||
}
|
||||
|
||||
async authLogin(password: string): Promise<ApiResult> {
|
||||
return this.request('/auth/login', {
|
||||
async authLogin(password: string): Promise<ApiResult & { token?: string }> {
|
||||
const res = await this.request<ApiResult & { token?: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
if (res.status === 'success' && res.token) {
|
||||
this.setAuthToken(res.token)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async authLogout(): Promise<ApiResult> {
|
||||
this.setAuthToken(null)
|
||||
return this.request('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
}
|
||||
|
||||
BIN
desktop/src/renderer/src/assets/logo.png
Normal file
BIN
desktop/src/renderer/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -1,15 +1,18 @@
|
||||
import React, { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||
import { Plus, Paperclip, Send, Square, X, File as FileIcon, Loader2 } from 'lucide-react'
|
||||
import { Plus, Paperclip, Square, X, File as FileIcon, Loader2, Trash2 } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import type { Attachment } from '../types'
|
||||
import apiClient from '../api/client'
|
||||
import { PaperPlaneIcon } from './icons'
|
||||
|
||||
export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
|
||||
|
||||
interface SlashCommand {
|
||||
cmd: string
|
||||
desc: string
|
||||
action: 'new' | 'clear'
|
||||
// 'new'/'clear' run a local action; 'send' (default) is a completion that
|
||||
// gets sent to the backend as a normal message (handled by command plugins).
|
||||
action?: 'new' | 'clear'
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
@@ -35,16 +38,54 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Local actions ('new'/'clear') plus completion commands handled by backend
|
||||
// command plugins (cow_cli/godcmd). Commands ending with a space expect an
|
||||
// argument, so selecting them keeps focus in the input instead of sending.
|
||||
const slashCommands: SlashCommand[] = [
|
||||
{ cmd: '/new', desc: t('session_new'), action: 'new' },
|
||||
{ cmd: '/clear', desc: t('chat_clear_context'), action: 'clear' },
|
||||
{ cmd: '/new', desc: t('slash_new'), action: 'new' },
|
||||
{ cmd: '/clear', desc: t('slash_clear'), action: 'clear' },
|
||||
{ cmd: '/help', desc: t('slash_help') },
|
||||
{ cmd: '/status', desc: t('slash_status') },
|
||||
{ cmd: '/context', desc: t('slash_context') },
|
||||
{ cmd: '/skill list', desc: t('slash_skill_list') },
|
||||
{ cmd: '/skill search ', desc: t('slash_skill_search') },
|
||||
{ cmd: '/skill install ', desc: t('slash_skill_install') },
|
||||
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
|
||||
{ cmd: '/knowledge', desc: t('slash_knowledge') },
|
||||
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
|
||||
{ cmd: '/install-browser', desc: t('slash_install_browser') },
|
||||
{ cmd: '/config', desc: t('slash_config') },
|
||||
{ cmd: '/cancel', desc: t('slash_cancel') },
|
||||
{ cmd: '/logs', desc: t('slash_logs') },
|
||||
{ cmd: '/version', desc: t('slash_version') },
|
||||
]
|
||||
const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase()))
|
||||
|
||||
const resetHeight = () => {
|
||||
if (textareaRef.current) textareaRef.current.style.height = '42px'
|
||||
// Resize the textarea to fit its content (single line = 42px, capped at
|
||||
// 180px). Keep overflow hidden until we hit the cap, so an empty/short input
|
||||
// never shows a scrollbar (matches the web console behavior).
|
||||
const autoSize = (el: HTMLTextAreaElement | null) => {
|
||||
if (!el) return
|
||||
el.style.height = '42px'
|
||||
const h = Math.min(el.scrollHeight, 180)
|
||||
el.style.height = h + 'px'
|
||||
el.style.overflowY = el.scrollHeight > 180 ? 'auto' : 'hidden'
|
||||
}
|
||||
|
||||
const resetHeight = () => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
el.style.height = '42px'
|
||||
el.style.overflowY = 'hidden'
|
||||
}
|
||||
|
||||
// Sync the height once on mount so the very first render matches the 42px
|
||||
// single-line height instead of the browser's default textarea size.
|
||||
useEffect(() => {
|
||||
autoSize(textareaRef.current)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Allow the parent to load a draft (e.g. when editing a past user message).
|
||||
useImperativeHandle(ref, () => (draft: string, atts: Attachment[]) => {
|
||||
setText(draft)
|
||||
@@ -53,18 +94,36 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
const el = textareaRef.current
|
||||
if (el) {
|
||||
el.focus()
|
||||
el.style.height = '42px'
|
||||
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
|
||||
autoSize(el)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const runSlash = (c: SlashCommand) => {
|
||||
setText('')
|
||||
setSlashOpen(false)
|
||||
if (c.action === 'new') {
|
||||
setText('')
|
||||
resetHeight()
|
||||
if (c.action === 'new') onNewChat()
|
||||
else if (c.action === 'clear') onClearContext()
|
||||
onNewChat()
|
||||
return
|
||||
}
|
||||
if (c.action === 'clear') {
|
||||
setText('')
|
||||
resetHeight()
|
||||
onClearContext()
|
||||
return
|
||||
}
|
||||
// Completion command. If it expects an argument (trailing space), keep it
|
||||
// in the input so the user can type the argument; otherwise send it now.
|
||||
const needsArg = c.cmd.endsWith(' ')
|
||||
if (needsArg) {
|
||||
setText(c.cmd)
|
||||
requestAnimationFrame(() => textareaRef.current?.focus())
|
||||
} else {
|
||||
onSend(c.cmd.trim(), [])
|
||||
setText('')
|
||||
resetHeight()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
@@ -111,9 +170,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const v = e.target.value
|
||||
setText(v)
|
||||
const el = e.target
|
||||
el.style.height = '42px'
|
||||
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
|
||||
autoSize(e.target)
|
||||
// open slash menu when the input starts with "/" and has no space
|
||||
setSlashOpen(v.startsWith('/') && !v.includes(' '))
|
||||
setSlashIndex(0)
|
||||
@@ -205,18 +262,27 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
|
||||
{/* Slash command menu */}
|
||||
{slashOpen && filtered.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 rounded-xl border border-default bg-elevated shadow-lg overflow-hidden z-30">
|
||||
<div className="absolute bottom-full left-0 right-0 mb-1.5 max-h-80 overflow-y-auto rounded-xl border border-default bg-elevated shadow-xl z-30 p-1.5">
|
||||
<div className="px-2.5 pt-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-content-tertiary">
|
||||
{t('slash_menu_title')}
|
||||
</div>
|
||||
{filtered.map((c, i) => (
|
||||
<button
|
||||
key={c.cmd}
|
||||
onMouseEnter={() => setSlashIndex(i)}
|
||||
onClick={() => runSlash(c)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-left cursor-pointer transition-colors ${
|
||||
className={`w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-lg text-left cursor-pointer transition-colors ${
|
||||
i === slashIndex ? 'bg-accent-soft' : 'hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-accent">{c.cmd}</span>
|
||||
<span className="text-xs text-content-tertiary">{c.desc}</span>
|
||||
<span
|
||||
className={`text-[13px] font-medium font-mono whitespace-nowrap ${
|
||||
i === slashIndex ? 'text-accent' : 'text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{c.cmd}
|
||||
</span>
|
||||
<span className="text-xs text-content-tertiary whitespace-nowrap truncate">{c.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -275,6 +341,13 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
>
|
||||
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Paperclip size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClearContext}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-btn text-content-secondary hover:text-danger hover:bg-danger-soft cursor-pointer transition-colors"
|
||||
title={t('chat_clear_context')}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -296,7 +369,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
onCompositionEnd={() => (composingRef.current = false)}
|
||||
placeholder={t('input_placeholder')}
|
||||
rows={1}
|
||||
className="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-strong bg-inset text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent text-sm leading-relaxed transition-colors resize-none"
|
||||
className="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-strong bg-inset text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent text-sm leading-relaxed transition-colors resize-none overflow-y-hidden"
|
||||
/>
|
||||
|
||||
{isStreaming ? (
|
||||
@@ -311,10 +384,10 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSend}
|
||||
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
||||
className="flex-shrink-0 w-[42px] h-[42px] flex items-center justify-center rounded-btn bg-accent text-white hover:bg-accent-hover disabled:bg-surface-2 disabled:text-content-disabled disabled:cursor-not-allowed cursor-pointer transition-none [&_*]:transition-none"
|
||||
title={t('chat_send')}
|
||||
>
|
||||
<Send size={17} />
|
||||
<PaperPlaneIcon size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
72
desktop/src/renderer/src/components/LoginGate.tsx
Normal file
72
desktop/src/renderer/src/components/LoginGate.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react'
|
||||
import apiClient from '../api/client'
|
||||
import { t } from '../i18n'
|
||||
|
||||
interface LoginGateProps {
|
||||
// Called once the password is accepted (auth cookie set), so the app can
|
||||
// proceed to the main UI.
|
||||
onAuthenticated: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when the backend has a web_password set and the current session isn't
|
||||
* authenticated yet. Submitting the correct password sets an auth cookie
|
||||
* (handled by the backend), after which the app reloads its data.
|
||||
*/
|
||||
const LoginGate: React.FC<LoginGateProps> = ({ onAuthenticated }) => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!password || submitting) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await apiClient.authLogin(password)
|
||||
if (res.status === 'success') {
|
||||
onAuthenticated()
|
||||
} else {
|
||||
setError(t('login_error'))
|
||||
}
|
||||
} catch {
|
||||
setError(t('login_error'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-[#111111]">
|
||||
<form onSubmit={submit} className="text-center space-y-6 max-w-md px-8 w-full">
|
||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mx-auto shadow-lg shadow-primary-500/20" />
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('login_title')}</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{t('login_desc')}</p>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
placeholder={t('login_placeholder')}
|
||||
className="w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-[#1a1a1a] text-slate-800 dark:text-slate-100 text-sm outline-none focus:border-primary-500 transition-colors"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !password}
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{submitting ? t('login_checking') : t('login_submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginGate
|
||||
@@ -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.
|
||||
const defaultLinkOpen =
|
||||
md.renderer.rules.link_open ||
|
||||
@@ -72,16 +100,38 @@ md.renderer.rules.fence = function (tokens, idx, options, env, self) {
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string
|
||||
/**
|
||||
* Intercept clicks on internal document links (relative `.md` hrefs). When
|
||||
* provided, such links open in-app instead of being handed to the OS. Used by
|
||||
* the knowledge viewer so index links open the target doc rather than firing
|
||||
* an "application cannot be opened (-120)" error in Electron.
|
||||
*/
|
||||
onInternalLink?: (href: string) => void
|
||||
}
|
||||
|
||||
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
||||
const Markdown: React.FC<MarkdownProps> = ({ content, onInternalLink }) => {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const html = useMemo(() => md.render(content || ''), [content])
|
||||
|
||||
// Delegate copy clicks on code blocks (buttons are injected as raw HTML).
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// Delegate clicks: copy buttons on code blocks, and internal doc links.
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
// Internal knowledge links (relative *.md), when a handler is provided.
|
||||
if (onInternalLink) {
|
||||
const a = target.closest('a') as HTMLAnchorElement | null
|
||||
if (a) {
|
||||
const href = a.getAttribute('href') || ''
|
||||
if (href.endsWith('.md') && !/^https?:\/\//i.test(href)) {
|
||||
e.preventDefault()
|
||||
onInternalLink(href)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
||||
if (!btn) return
|
||||
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
||||
@@ -94,7 +144,9 @@ const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
||||
btn.textContent = original
|
||||
btn.classList.remove('copied')
|
||||
}, 1600)
|
||||
}, [])
|
||||
},
|
||||
[onInternalLink]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Copy, Check, RefreshCw, Pencil, Trash2, File as FileIcon, Sprout } from 'lucide-react'
|
||||
import { Copy, Check, RefreshCw, Trash2, File as FileIcon, Sprout } from 'lucide-react'
|
||||
import type { ChatMessage } from '../types'
|
||||
import { t } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
@@ -11,6 +11,9 @@ interface MessageBubbleProps {
|
||||
onRegenerate?: (id: string) => void
|
||||
onEdit?: (id: string) => void
|
||||
onDelete?: (msg: ChatMessage) => void
|
||||
/** Fired when an inline image/video finishes loading, so the parent can
|
||||
* re-scroll to the bottom (async media changes bubble height after mount). */
|
||||
onMediaLoad?: () => void
|
||||
}
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
@@ -36,7 +39,7 @@ const HoverAction: React.FC<{ onClick: () => void; title: string; danger?: boole
|
||||
</button>
|
||||
)
|
||||
|
||||
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete }) => {
|
||||
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete, onMediaLoad }) => {
|
||||
const isUser = message.role === 'user'
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
@@ -46,6 +49,16 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
}
|
||||
|
||||
// Open a sent file: prefer the local path via Electron (Finder / default
|
||||
// app); fall back to the served URL in a browser when unavailable.
|
||||
const openAttachment = (att: { abs_path?: string; preview_url?: string; file_path: string }) => {
|
||||
if (att.abs_path && window.electronAPI?.openPath) {
|
||||
window.electronAPI.openPath(att.abs_path)
|
||||
return
|
||||
}
|
||||
window.open(apiClient.getFileUrl(att.preview_url || att.file_path), '_blank')
|
||||
}
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="group flex flex-col items-end px-4 sm:px-6 py-2">
|
||||
@@ -68,16 +81,14 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-[75%] rounded-2xl rounded-br-md px-4 py-2.5 bg-[var(--user-bubble-bg)] text-content">
|
||||
<div className="max-w-[75%] rounded-2xl rounded-br-md px-4 py-2.5 bg-accent text-white">
|
||||
<div className="text-sm whitespace-pre-wrap break-words">{message.content}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
|
||||
{onEdit && message.userSeq != null && (
|
||||
<HoverAction onClick={() => onEdit(message.id)} title={t('msg_edit')}>
|
||||
<Pencil size={13} />
|
||||
</HoverAction>
|
||||
)}
|
||||
{/* Edit entry hidden: editing a past question cascade-deletes all
|
||||
subsequent turns, which surprises users. Kept off until we support
|
||||
non-destructive editing. */}
|
||||
{onDelete && message.userSeq != null && (
|
||||
<HoverAction onClick={() => onDelete(message)} title={t('msg_delete')} danger>
|
||||
<Trash2 size={13} />
|
||||
@@ -110,14 +121,57 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
||||
muted, separated from the final answer by a dashed divider. */}
|
||||
{(hasSteps || hasLiveReasoning) && (
|
||||
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
||||
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
|
||||
{hasSteps && <MessageSteps steps={message.steps!} />}
|
||||
{/* Live reasoning is the current, not-yet-committed thinking, so it
|
||||
must render after all committed steps (tools/thinking), not at
|
||||
the very top of the bubble. */}
|
||||
{hasLiveReasoning && (
|
||||
<div className={hasSteps ? 'mt-1' : ''}>
|
||||
<ThinkingStep content={message.reasoning!} streaming />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final answer */}
|
||||
{message.content && <Markdown content={message.content} />}
|
||||
|
||||
{/* Media attachments sent via the `send` tool (images / files). */}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{message.attachments.map((att, i) =>
|
||||
att.file_type === 'image' ? (
|
||||
<img
|
||||
key={i}
|
||||
src={apiClient.getFileUrl(att.preview_url || att.file_path)}
|
||||
alt={att.file_name}
|
||||
onLoad={() => onMediaLoad?.()}
|
||||
onClick={() => window.open(apiClient.getFileUrl(att.preview_url || att.file_path), '_blank')}
|
||||
className="max-w-[320px] w-full rounded-xl border border-default cursor-zoom-in"
|
||||
/>
|
||||
) : att.file_type === 'video' ? (
|
||||
<video
|
||||
key={i}
|
||||
src={apiClient.getFileUrl(att.preview_url || att.file_path)}
|
||||
controls
|
||||
onLoadedData={() => onMediaLoad?.()}
|
||||
className="max-w-[360px] w-full rounded-xl border border-default"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => openAttachment(att)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-surface-2 rounded-xl text-xs text-content-secondary hover:text-content cursor-pointer"
|
||||
>
|
||||
<FileIcon size={13} />
|
||||
{att.file_name}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCursor && (
|
||||
<div className="flex items-center gap-1 py-0.5">
|
||||
<span className="typing-dot" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronRight, Loader2, Check, X, Brain, Wrench } from 'lucide-react'
|
||||
import { ChevronRight, Loader2, Check, X, Lightbulb } from 'lucide-react'
|
||||
import type { MessageStep } from '../types'
|
||||
import { t } from '../i18n'
|
||||
import Markdown from './Markdown'
|
||||
|
||||
/**
|
||||
@@ -16,8 +17,8 @@ const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ cont
|
||||
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<Brain size={12} className="flex-shrink-0" />
|
||||
<span className="flex-1">{streaming ? 'Thinking…' : 'Thought for a moment'}</span>
|
||||
<Lightbulb size={13} className={`flex-shrink-0 text-amber-400 ${streaming ? 'animate-pulse' : ''}`} />
|
||||
<span className="flex-1">{streaming ? t('thinking_in_progress') : t('thinking_done')}</span>
|
||||
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||
</div>
|
||||
{expanded && (
|
||||
@@ -49,7 +50,6 @@ const ToolStep: React.FC<{ step: MessageStep }> = ({ step }) => {
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<Wrench size={11} className="flex-shrink-0 opacity-70" />
|
||||
<span className={`font-medium ${isError ? 'text-danger' : ''}`}>{step.name}</span>
|
||||
{step.execution_time !== undefined && (
|
||||
<span className="opacity-60">{step.execution_time}s</span>
|
||||
|
||||
@@ -1,55 +1,60 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Download, RefreshCw, X, Loader2 } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { Download, RefreshCw, X, Loader2, AlertTriangle } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import { useUpdateStore, hasPendingUpdate } from '../store/updateStore'
|
||||
import { useUpdateStore, hasAvailableUpdate } from '../store/updateStore'
|
||||
|
||||
// Compact update panel anchored to the NavRail footer. Only mounts content
|
||||
// when there's a pending update; otherwise renders nothing so it stays out of
|
||||
// the way until electron-updater reports a new version.
|
||||
// Compact update panel anchored to the NavRail footer. Shown whenever an update
|
||||
// is available AND the panel is open (auto-opened on detection, re-openable via
|
||||
// "check for update"). Dismissing (×) just closes it; the menu can re-open it.
|
||||
const UpdateBanner: React.FC = () => {
|
||||
const state = useUpdateStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
const open = state.panelOpen
|
||||
|
||||
const pending = hasPendingUpdate(state)
|
||||
const available = hasAvailableUpdate(state)
|
||||
const status = state.status
|
||||
const errored = status?.state === 'error'
|
||||
|
||||
// Auto-open the panel the moment a new version is first detected.
|
||||
useEffect(() => {
|
||||
if (status?.state === 'available') setOpen(true)
|
||||
}, [status?.state])
|
||||
// Full-screen "installing…" overlay: bridges the otherwise blank window
|
||||
// between clicking "restart to install" and the app actually quitting to
|
||||
// swap the bundle. (The gap AFTER quit, before relaunch, is OS-level and
|
||||
// can't be covered.)
|
||||
if (state.installing) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-3 bg-base/90 backdrop-blur-sm">
|
||||
<Loader2 size={28} className="animate-spin text-accent" />
|
||||
<p className="text-sm text-content-secondary">{t('update_installing')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!pending) return null
|
||||
// Show the panel when it's open AND we either know of an update or hit an
|
||||
// error. Keeping it up on error is important: a failed download must surface
|
||||
// a visible message instead of silently doing nothing.
|
||||
if (!open || (!available && !errored)) return null
|
||||
|
||||
const version = state.version
|
||||
const preparing = state.preparing
|
||||
const downloading = status?.state === 'downloading'
|
||||
const downloaded = status?.state === 'downloaded'
|
||||
// macOS emits a second progress pass (verify) after hitting 100%; show it as
|
||||
// an indeterminate "verifying" state rather than a bar restarting from 0.
|
||||
const verifying = downloading && state.progressPeaked
|
||||
const busy = preparing || downloading
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-14 left-2 right-2 z-40">
|
||||
{/* Collapsed pill: a red-dotted button that re-opens the panel. */}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="relative w-full flex items-center gap-2 rounded-btn bg-accent-soft text-accent px-3 py-2 text-[13px] font-medium cursor-pointer hover:bg-accent-soft/80 transition-colors"
|
||||
>
|
||||
<span className="absolute -top-1 -left-1 h-2 w-2 rounded-full bg-danger" />
|
||||
<Download size={15} />
|
||||
<span className="truncate">{t('update_available')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-2 left-2 right-2 z-40">
|
||||
<div className="rounded-lg border border-default bg-elevated shadow-lg p-3 space-y-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold text-content">{t('update_available')}</p>
|
||||
{version && <p className="text-xs text-content-tertiary mt-0.5">v{version}</p>}
|
||||
<p className="text-[13px] font-semibold text-content">
|
||||
{errored ? t('update_failed') : t('update_available')}
|
||||
</p>
|
||||
{!errored && version && (
|
||||
<p className="text-xs text-content-tertiary mt-0.5">v{version}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
state.dismiss()
|
||||
}}
|
||||
onClick={() => state.dismiss()}
|
||||
className="text-content-tertiary hover:text-content cursor-pointer flex-shrink-0"
|
||||
title={t('update_later')}
|
||||
>
|
||||
@@ -57,7 +62,39 @@ const UpdateBanner: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{downloading && (
|
||||
{errored && (
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-start gap-2 text-xs text-content-secondary">
|
||||
<AlertTriangle size={13} className="text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="break-words">
|
||||
{status?.state === 'error' ? status.message : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => state.download()}
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
{t('update_retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!errored && preparing && (
|
||||
<div className="flex items-center gap-2 text-xs text-content-secondary py-1">
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
<span>{t('update_preparing')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!errored && downloading && verifying && (
|
||||
<div className="flex items-center gap-2 text-xs text-content-secondary py-1">
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
<span>{t('update_verifying')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!errored && downloading && !verifying && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-content-secondary">
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
@@ -69,7 +106,7 @@ const UpdateBanner: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!downloading && !downloaded && (
|
||||
{!errored && !busy && !downloaded && (
|
||||
<button
|
||||
onClick={() => state.download()}
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
|
||||
@@ -79,7 +116,7 @@ const UpdateBanner: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloaded && (
|
||||
{!errored && downloaded && (
|
||||
<button
|
||||
onClick={() => state.install()}
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
|
||||
@@ -89,7 +126,6 @@ const UpdateBanner: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
16
desktop/src/renderer/src/components/icons.tsx
Normal file
16
desktop/src/renderer/src/components/icons.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
|
||||
// Solid paper-plane icon (Font Awesome's fa-paper-plane path) so the send
|
||||
// button and the feishu/telegram channels match the web console exactly.
|
||||
export const PaperPlaneIcon: React.FC<{ size?: number; className?: string }> = ({ size = 16, className }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 512 512"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
<path d="M498.1 5.6c10.1 7 15.4 19.1 13.5 31.2l-64 416c-1.5 9.7-7.4 18.2-16 23s-18.9 5.4-28 1.6L284 427.7l-68.5 74.1c-8.9 9.7-22.9 12.9-35.2 8.1S160 493.2 160 480l0-83.6c0-4 1.5-7.8 4.2-10.8L331.8 202.8c5.8-6.3 5.6-16-.4-22s-15.7-6.4-22-.7L106 360.8 17.7 316.6C7.1 311.3 .3 300.7 0 288.9s5.9-22.8 16.1-28.7l448-256c10.7-6.1 23.9-5.5 34 1.4z" />
|
||||
</svg>
|
||||
)
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
// Fixed default port — MUST match DESKTOP_BACKEND_PORT in main/python-manager.ts.
|
||||
// The backend is launched on exactly this port (the main process frees it first
|
||||
// and passes it via COW_WEB_PORT), so probing it works even before the
|
||||
// getBackendPort IPC resolves. Keeping both sides on one constant means the
|
||||
// renderer can never end up talking to the wrong port.
|
||||
const BACKEND_PORT = 9876
|
||||
|
||||
interface BackendState {
|
||||
status: 'connecting' | 'ready' | 'error'
|
||||
port: number
|
||||
@@ -9,13 +16,16 @@ interface BackendState {
|
||||
export function useBackend() {
|
||||
const [state, setState] = useState<BackendState>({
|
||||
status: 'connecting',
|
||||
port: 9899,
|
||||
port: BACKEND_PORT,
|
||||
})
|
||||
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/config`, {
|
||||
// Probe the unauthenticated health endpoint, NOT /config: once a
|
||||
// web_password is set, /config returns 401 and we'd wrongly treat the
|
||||
// (healthy) backend as unreachable, hanging on "connecting".
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return res.ok
|
||||
@@ -31,7 +41,7 @@ export function useBackend() {
|
||||
const readyRef = useRef(false)
|
||||
// Holds the latest resolved port so the visibility handler (registered once)
|
||||
// always probes the correct port without re-running the effect.
|
||||
const portRef = useRef(9899)
|
||||
const portRef = useRef(BACKEND_PORT)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -74,12 +84,21 @@ export function useBackend() {
|
||||
}
|
||||
|
||||
if (api) {
|
||||
api.getBackendPort().then((port) => {
|
||||
const p = port || 9899
|
||||
// Always start polling, even if getBackendPort rejects or the ready event
|
||||
// was already emitted before we subscribed: polling /config is the
|
||||
// self-sufficient path to "ready" and must never depend on the IPC round
|
||||
// trip succeeding (otherwise the app can hang forever on "connecting").
|
||||
api
|
||||
.getBackendPort()
|
||||
.then((port) => {
|
||||
const p = port || BACKEND_PORT
|
||||
portRef.current = p
|
||||
setState((prev) => ({ ...prev, port: p }))
|
||||
startPolling(p)
|
||||
})
|
||||
.catch(() => {
|
||||
startPolling(BACKEND_PORT)
|
||||
})
|
||||
|
||||
offStatus = api.onBackendStatus((data) => {
|
||||
if (data.status === 'ready' && data.port) {
|
||||
@@ -98,7 +117,7 @@ export function useBackend() {
|
||||
}
|
||||
})
|
||||
} else {
|
||||
startPolling(9899)
|
||||
startPolling(BACKEND_PORT)
|
||||
}
|
||||
|
||||
// When the window comes back to the foreground, re-probe immediately so a
|
||||
|
||||
@@ -12,8 +12,8 @@ function getSystemTheme(): ResolvedTheme {
|
||||
function readStored(): ThemePref {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
|
||||
// Default to dark to match the app's flagship look
|
||||
return 'dark'
|
||||
// First run: follow the OS appearance rather than forcing a fixed theme.
|
||||
return 'system'
|
||||
}
|
||||
|
||||
function applyTheme(resolved: ResolvedTheme) {
|
||||
|
||||
@@ -29,14 +29,67 @@ const translations: Record<string, Record<string, string>> = {
|
||||
knowledge_graph_empty: '暂无关联图谱',
|
||||
knowledge_disabled: '知识库未启用',
|
||||
knowledge_doc_load_error: '文档加载失败',
|
||||
// knowledge management
|
||||
knowledge_new: '新建',
|
||||
knowledge_new_category: '新建分类',
|
||||
knowledge_new_document: '新建文档',
|
||||
knowledge_import_documents: '导入文档',
|
||||
knowledge_working: '处理中...',
|
||||
knowledge_importing: '正在导入...',
|
||||
knowledge_request_failed: '请求失败,请稍后重试',
|
||||
knowledge_import_failed: '导入失败',
|
||||
knowledge_category_created: '分类已创建',
|
||||
knowledge_document_created: '文档已创建',
|
||||
knowledge_dialog_confirm: '确定',
|
||||
knowledge_dialog_cancel: '取消',
|
||||
knowledge_field_required: '此项不能为空',
|
||||
knowledge_category_label: '分类路径',
|
||||
knowledge_category_hint: '支持嵌套路径,例如 research/ai',
|
||||
knowledge_category_subtitle: '分类会创建为 knowledge/ 下的目录',
|
||||
knowledge_need_category: '请先创建分类',
|
||||
knowledge_destination: '目标分类',
|
||||
knowledge_doc_choose_category: '先选择分类,然后输入文件名',
|
||||
knowledge_doc_save_to: '保存到 {category}',
|
||||
knowledge_doc_filename: '文件名',
|
||||
knowledge_doc_filename_required: '文件名不能为空',
|
||||
knowledge_doc_must_md: '新建文档仅支持 .md 文件名',
|
||||
knowledge_doc_content: 'Markdown 内容',
|
||||
knowledge_doc_content_required: '内容不能为空',
|
||||
knowledge_doc_content_too_large: '内容不能超过 10MB',
|
||||
knowledge_doc_insert_template: '插入模板',
|
||||
knowledge_import_selected: '已选择 {count} 个文件',
|
||||
knowledge_import_hint: '支持 Markdown 和 TXT,TXT 会转成 Markdown 文档',
|
||||
knowledge_import_need_category: '请先创建一个分类',
|
||||
knowledge_import_choose_files: '请选择 .md 或 .txt 文件',
|
||||
knowledge_import_too_many: '一次最多导入 {max} 个文件',
|
||||
knowledge_import_file_too_large: '{name} 超过 10MB',
|
||||
knowledge_import_total_too_large: '单次导入总大小不能超过 200MB',
|
||||
knowledge_import_result: '导入 {imported} 个,跳过 {skipped} 个,失败 {failed} 个',
|
||||
knowledge_drop_hint: '拖放 .md / .txt 文件到此导入',
|
||||
nav_expand: '展开侧栏',
|
||||
nav_collapse: '收起侧栏',
|
||||
session_history: '历史会话',
|
||||
update_available: '发现新版本',
|
||||
update_download: '下载更新',
|
||||
update_downloading: '正在下载',
|
||||
update_preparing: '正在准备…',
|
||||
update_verifying: '正在校验,即将完成…',
|
||||
update_installing: '正在安装并重启,请稍候…',
|
||||
update_restart: '重启以更新',
|
||||
update_later: '稍后',
|
||||
update_latest: '已是最新版本',
|
||||
update_check: '检查更新',
|
||||
update_checking: '正在检查…',
|
||||
update_failed: '更新失败',
|
||||
update_retry: '重试',
|
||||
menu_more: '更多',
|
||||
menu_theme_light: '浅色模式',
|
||||
menu_theme_dark: '深色模式',
|
||||
menu_language: '语言',
|
||||
menu_website: '官网',
|
||||
menu_docs: '文档中心',
|
||||
menu_skill_hub: '技能广场',
|
||||
menu_feedback: '反馈',
|
||||
// onboarding
|
||||
onboarding_welcome_title: '欢迎使用 CowAgent',
|
||||
onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
|
||||
@@ -76,20 +129,29 @@ const translations: Record<string, Record<string, string>> = {
|
||||
msg_cancelled: '已中止',
|
||||
msg_self_learned: '自主学习',
|
||||
msg_stop: '停止',
|
||||
thinking_in_progress: '思考中…',
|
||||
thinking_done: '已深度思考',
|
||||
chat_clear_context: '清除上下文',
|
||||
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||
chat_load_earlier: '加载更早的消息',
|
||||
chat_send: '发送',
|
||||
chat_attach: '添加附件',
|
||||
slash_hint: '输入 / 查看命令',
|
||||
chat_welcome: '有什么可以帮你的?',
|
||||
chat_empty_hint: '发送一条消息开始对话',
|
||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||
welcome_subtitle: '我可以帮你解决问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||
example_sys_title: '系统管理',
|
||||
example_sys_text: '帮我查看工作空间里有哪些文件',
|
||||
example_task_title: '技能系统',
|
||||
example_task_text: '查看所有支持的工具和技能',
|
||||
example_sys_text: '查看工作空间里有哪些文件',
|
||||
example_task_title: '定时任务',
|
||||
example_task_text: '1分钟后提醒我检查服务器',
|
||||
example_code_title: '编程助手',
|
||||
example_code_text: '帮我编写一个Python爬虫脚本',
|
||||
example_code_text: '搜索AI资讯生成可视化网页报告',
|
||||
example_knowledge_title: '知识库',
|
||||
example_knowledge_text: '查看知识库当前文档情况',
|
||||
example_skill_title: '技能系统',
|
||||
example_skill_text: '查看所有支持的工具和技能',
|
||||
example_web_title: '指令中心',
|
||||
example_web_text: '查看全部命令',
|
||||
input_placeholder: '输入消息...',
|
||||
config_title: '配置管理',
|
||||
config_desc: '管理模型和 Agent 配置',
|
||||
@@ -216,7 +278,10 @@ const translations: Record<string, Record<string, string>> = {
|
||||
memory_next: '下一页',
|
||||
channels_title: '通道管理',
|
||||
channels_desc: '查看和管理消息通道',
|
||||
channels_add: '添加通道',
|
||||
channels_add: '接入通道',
|
||||
channels_select_label: '选择要接入的通道',
|
||||
channels_select_placeholder: '请选择通道...',
|
||||
channels_add_close: '关闭',
|
||||
channels_connected: '已连接',
|
||||
channels_disconnected: '未连接',
|
||||
channels_connect: '连接',
|
||||
@@ -226,6 +291,8 @@ const translations: Record<string, Record<string, string>> = {
|
||||
channels_connected_section: '已连接',
|
||||
channels_available_section: '可添加',
|
||||
channels_empty_connected: '暂无已连接的通道',
|
||||
channels_empty: '暂未接入任何通道',
|
||||
channels_empty_desc: '点击右上角「接入通道」按钮,即可将 CowAgent 接入微信、飞书、钉钉等消息通道',
|
||||
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
||||
channels_save_ok: '已保存',
|
||||
channels_save_error: '保存失败',
|
||||
@@ -285,6 +352,10 @@ const translations: Record<string, Record<string, string>> = {
|
||||
task_delete: '删除',
|
||||
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
|
||||
task_save_error: '保存失败',
|
||||
task_run_now: '立即执行',
|
||||
task_run_confirm: '该任务会立即向已配置的通道和接收者发送内容。是否继续?',
|
||||
task_run_started: '任务已开始执行',
|
||||
task_run_error: '执行失败',
|
||||
logs_title: '日志',
|
||||
logs_desc: '实时日志输出 (run.log)',
|
||||
logs_live: '实时',
|
||||
@@ -294,6 +365,31 @@ const translations: Record<string, Record<string, string>> = {
|
||||
status_error: '初始化失败',
|
||||
status_error_desc: '客户端初始化失败,请重试',
|
||||
status_retry: '重试',
|
||||
// login (web_password)
|
||||
login_title: '请输入访问密码',
|
||||
login_desc: '此客户端已设置访问密码,请输入以继续',
|
||||
login_placeholder: '访问密码',
|
||||
login_submit: '进入',
|
||||
login_error: '密码错误,请重试',
|
||||
login_checking: '验证中...',
|
||||
// slash command descriptions
|
||||
slash_menu_title: '命令',
|
||||
slash_new: '新建对话',
|
||||
slash_clear: '清除对话上下文',
|
||||
slash_help: '显示命令帮助',
|
||||
slash_status: '查看运行状态',
|
||||
slash_context: '查看对话上下文',
|
||||
slash_skill_list: '查看已安装技能',
|
||||
slash_skill_search: '搜索技能',
|
||||
slash_skill_install: '安装技能 (名称或 GitHub URL)',
|
||||
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
|
||||
slash_knowledge: '查看知识库统计',
|
||||
slash_knowledge_list: '查看知识库文件树',
|
||||
slash_install_browser: '安装浏览器工具',
|
||||
slash_config: '查看当前配置',
|
||||
slash_cancel: '中止当前正在运行的 Agent 任务',
|
||||
slash_logs: '查看最近日志',
|
||||
slash_version: '查看版本',
|
||||
},
|
||||
en: {
|
||||
console: 'Console',
|
||||
@@ -324,15 +420,68 @@ const translations: Record<string, Record<string, string>> = {
|
||||
knowledge_graph_empty: 'No graph available',
|
||||
knowledge_disabled: 'Knowledge base is disabled',
|
||||
knowledge_doc_load_error: 'Failed to load document',
|
||||
// knowledge management
|
||||
knowledge_new: 'New',
|
||||
knowledge_new_category: 'New category',
|
||||
knowledge_new_document: 'New document',
|
||||
knowledge_import_documents: 'Import documents',
|
||||
knowledge_working: 'Working...',
|
||||
knowledge_importing: 'Importing...',
|
||||
knowledge_request_failed: 'Request failed, please try again',
|
||||
knowledge_import_failed: 'Import failed',
|
||||
knowledge_category_created: 'Category created',
|
||||
knowledge_document_created: 'Document created',
|
||||
knowledge_dialog_confirm: 'Confirm',
|
||||
knowledge_dialog_cancel: 'Cancel',
|
||||
knowledge_field_required: 'This field is required',
|
||||
knowledge_category_label: 'Category path',
|
||||
knowledge_category_hint: 'Nested paths are supported, e.g. research/ai',
|
||||
knowledge_category_subtitle: 'Creates a directory under knowledge/',
|
||||
knowledge_need_category: 'Create a category first',
|
||||
knowledge_destination: 'Destination category',
|
||||
knowledge_doc_choose_category: 'Choose a category, then enter a filename',
|
||||
knowledge_doc_save_to: 'Save to {category}',
|
||||
knowledge_doc_filename: 'Filename',
|
||||
knowledge_doc_filename_required: 'Filename is required',
|
||||
knowledge_doc_must_md: 'New documents must be .md files',
|
||||
knowledge_doc_content: 'Markdown content',
|
||||
knowledge_doc_content_required: 'Content is required',
|
||||
knowledge_doc_content_too_large: 'Content cannot exceed 10MB',
|
||||
knowledge_doc_insert_template: 'Insert template',
|
||||
knowledge_import_selected: '{count} file(s) selected',
|
||||
knowledge_import_hint: 'Markdown and TXT are supported. TXT is converted to Markdown.',
|
||||
knowledge_import_need_category: 'Create a category first',
|
||||
knowledge_import_choose_files: 'Choose .md or .txt files',
|
||||
knowledge_import_too_many: 'Import at most {max} files at a time',
|
||||
knowledge_import_file_too_large: '{name} exceeds 10MB',
|
||||
knowledge_import_total_too_large: 'Total import size cannot exceed 200MB',
|
||||
knowledge_import_result: '{imported} imported · {skipped} skipped · {failed} failed',
|
||||
knowledge_drop_hint: 'Drop .md / .txt files here to import',
|
||||
menu_settings: 'Settings',
|
||||
nav_expand: 'Expand sidebar',
|
||||
nav_collapse: 'Collapse sidebar',
|
||||
session_history: 'Chat history',
|
||||
update_available: 'New version available',
|
||||
update_download: 'Download update',
|
||||
update_downloading: 'Downloading',
|
||||
update_preparing: 'Preparing…',
|
||||
update_verifying: 'Verifying, almost done…',
|
||||
update_installing: 'Installing and restarting, please wait…',
|
||||
update_restart: 'Restart to update',
|
||||
update_later: 'Later',
|
||||
update_latest: 'You are up to date',
|
||||
update_check: 'Check for updates',
|
||||
update_checking: 'Checking…',
|
||||
update_failed: 'Update failed',
|
||||
update_retry: 'Retry',
|
||||
menu_more: 'More',
|
||||
menu_theme_light: 'Light mode',
|
||||
menu_theme_dark: 'Dark mode',
|
||||
menu_language: 'Language',
|
||||
menu_website: 'Website',
|
||||
menu_docs: 'Documentation',
|
||||
menu_skill_hub: 'Skill Hub',
|
||||
menu_feedback: 'Feedback',
|
||||
// onboarding
|
||||
onboarding_welcome_title: 'Welcome to CowAgent',
|
||||
onboarding_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.',
|
||||
@@ -372,7 +521,10 @@ const translations: Record<string, Record<string, string>> = {
|
||||
msg_cancelled: 'Cancelled',
|
||||
msg_self_learned: 'Self-learned',
|
||||
msg_stop: 'Stop',
|
||||
thinking_in_progress: 'Thinking…',
|
||||
thinking_done: 'Thought',
|
||||
chat_clear_context: 'Clear context',
|
||||
context_cleared: '— Context above has been cleared —',
|
||||
chat_load_earlier: 'Load earlier messages',
|
||||
chat_send: 'Send',
|
||||
chat_attach: 'Attach file',
|
||||
@@ -382,10 +534,16 @@ const translations: Record<string, Record<string, string>> = {
|
||||
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills,\nand keep growing through long-term memory.',
|
||||
example_sys_title: 'System',
|
||||
example_sys_text: 'Show me the files in the workspace',
|
||||
example_task_title: 'Skills',
|
||||
example_task_text: 'Show current tools and skills',
|
||||
example_task_title: 'Scheduled Task',
|
||||
example_task_text: 'Remind me to check the server in 1 minute',
|
||||
example_code_title: 'Coding',
|
||||
example_code_text: 'Write a Python web scraper script',
|
||||
example_code_text: 'Search AI news and build a visual web report',
|
||||
example_knowledge_title: 'Knowledge Base',
|
||||
example_knowledge_text: 'Show the current documents in the knowledge base',
|
||||
example_skill_title: 'Skills',
|
||||
example_skill_text: 'Show all supported tools and skills',
|
||||
example_web_title: 'Commands',
|
||||
example_web_text: 'Show all commands',
|
||||
input_placeholder: 'Type a message...',
|
||||
config_title: 'Configuration',
|
||||
config_desc: 'Manage model and agent settings',
|
||||
@@ -513,6 +671,9 @@ const translations: Record<string, Record<string, string>> = {
|
||||
channels_title: 'Channels',
|
||||
channels_desc: 'View and manage messaging channels',
|
||||
channels_add: 'Add channel',
|
||||
channels_select_label: 'Select a channel to add',
|
||||
channels_select_placeholder: 'Select a channel...',
|
||||
channels_add_close: 'Close',
|
||||
channels_connected: 'Connected',
|
||||
channels_disconnected: 'Disconnected',
|
||||
channels_connect: 'Connect',
|
||||
@@ -522,6 +683,8 @@ const translations: Record<string, Record<string, string>> = {
|
||||
channels_connected_section: 'Connected',
|
||||
channels_available_section: 'Available',
|
||||
channels_empty_connected: 'No connected channels yet',
|
||||
channels_empty: 'No channels connected',
|
||||
channels_empty_desc: 'Click "Add channel" above to connect CowAgent to WeChat, Feishu, DingTalk and more',
|
||||
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
||||
channels_save_ok: 'Saved',
|
||||
channels_save_error: 'Failed to save',
|
||||
@@ -581,6 +744,10 @@ const translations: Record<string, Record<string, string>> = {
|
||||
task_delete: 'Delete',
|
||||
task_delete_confirm: 'Delete this task? This cannot be undone.',
|
||||
task_save_error: 'Failed to save',
|
||||
task_run_now: 'Run now',
|
||||
task_run_confirm: 'This task will immediately send to its configured channel and receiver. Continue?',
|
||||
task_run_started: 'Task run started',
|
||||
task_run_error: 'Failed to run task',
|
||||
logs_title: 'Logs',
|
||||
logs_desc: 'Real-time log output (run.log)',
|
||||
logs_live: 'Live',
|
||||
@@ -590,6 +757,31 @@ const translations: Record<string, Record<string, string>> = {
|
||||
status_error: 'Initialization Failed',
|
||||
status_error_desc: 'Failed to initialize the client, please retry',
|
||||
status_retry: 'Retry',
|
||||
// login (web_password)
|
||||
login_title: 'Enter access password',
|
||||
login_desc: 'This client is password-protected. Enter the password to continue.',
|
||||
login_placeholder: 'Access password',
|
||||
login_submit: 'Enter',
|
||||
login_error: 'Wrong password, please try again',
|
||||
login_checking: 'Verifying...',
|
||||
// slash command descriptions
|
||||
slash_menu_title: 'Commands',
|
||||
slash_new: 'New chat',
|
||||
slash_clear: 'Clear conversation context',
|
||||
slash_help: 'Show command help',
|
||||
slash_status: 'Show running status',
|
||||
slash_context: 'Show conversation context',
|
||||
slash_skill_list: 'List installed skills',
|
||||
slash_skill_search: 'Search skills',
|
||||
slash_skill_install: 'Install a skill (name or GitHub URL)',
|
||||
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
|
||||
slash_knowledge: 'Show knowledge base stats',
|
||||
slash_knowledge_list: 'Show knowledge base file tree',
|
||||
slash_install_browser: 'Install browser tool',
|
||||
slash_config: 'Show current config',
|
||||
slash_cancel: 'Abort the running agent task',
|
||||
slash_logs: 'Show recent logs',
|
||||
slash_version: 'Show version',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -27,21 +27,21 @@
|
||||
--danger-border: rgba(239, 68, 68, 0.3);
|
||||
--info: #3b82f6;
|
||||
|
||||
/* Light theme — layered neutral surfaces */
|
||||
--bg-base: #fafafa; /* app background */
|
||||
/* Light theme — aligned with the web console: gray surfaces/borders + slate text */
|
||||
--bg-base: #f9fafb; /* app background (gray-50) */
|
||||
--bg-surface: #ffffff; /* panels, cards */
|
||||
--bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */
|
||||
--bg-surface-2: #f3f4f6; /* nested surfaces, hover fills (gray-100) */
|
||||
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
||||
--bg-inset: #f4f4f5; /* inputs, code blocks */
|
||||
--bg-inset: #f3f4f6; /* inputs, code blocks (gray-100) */
|
||||
|
||||
--text-primary: #18181b; /* headings, primary text (contrast > 4.5:1) */
|
||||
--text-secondary: #52525b; /* body, labels */
|
||||
--text-tertiary: #71717a; /* hints, captions */
|
||||
--text-disabled: #a1a1aa;
|
||||
--text-primary: #1e293b; /* headings, primary text (slate-800) */
|
||||
--text-secondary: #475569; /* body, labels (slate-600) */
|
||||
--text-tertiary: #64748b; /* hints, captions (slate-500) */
|
||||
--text-disabled: #94a3b8; /* slate-400 */
|
||||
|
||||
--border-default: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--border-subtle: #f0f0f1;
|
||||
--border-default: #e5e7eb; /* gray-200 (web console border) */
|
||||
--border-strong: #d1d5db; /* gray-300 */
|
||||
--border-subtle: #f3f4f6; /* gray-100 */
|
||||
|
||||
--overlay: rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
@@ -49,7 +49,6 @@
|
||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* Chat-specific tokens (AI-Native UI) */
|
||||
--user-bubble-bg: var(--accent-soft);
|
||||
--ai-bubble-bg: transparent;
|
||||
--message-gap: 16px;
|
||||
|
||||
@@ -80,8 +79,6 @@
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--user-bubble-bg: rgba(74, 190, 110, 0.16);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
MessageSquare,
|
||||
@@ -13,13 +13,45 @@ import {
|
||||
Sun,
|
||||
Moon,
|
||||
ScrollText,
|
||||
MoreHorizontal,
|
||||
Languages,
|
||||
Download,
|
||||
Loader2,
|
||||
Globe,
|
||||
FileText,
|
||||
Store,
|
||||
MessageSquareWarning,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
// The desktop app's own brand icon (transparent PNG), bundled by Vite.
|
||||
import brandLogo from '../assets/logo.png'
|
||||
import { t, getLang, setLang, Lang } from '../i18n'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import { usePlatform } from '../hooks/usePlatform'
|
||||
import { useUpdateStore, hasPendingUpdate, hasAvailableUpdate } from '../store/updateStore'
|
||||
import UpdateBanner from '../components/UpdateBanner'
|
||||
|
||||
// Fallback shown when app.getVersion() is unavailable (dev/web preview). Keep
|
||||
// in sync with desktop/package.json "version"; the packaged app overrides this
|
||||
// with the real value via IPC, so it only matters outside a packaged build.
|
||||
const FALLBACK_VERSION = '2.1.3'
|
||||
|
||||
// External links opened in the user's default browser. The window-open handler
|
||||
// in the main process routes window.open() through shell.openExternal.
|
||||
// English is the default (no suffix); Chinese gets a /zh suffix. Skill hub is
|
||||
// language-agnostic.
|
||||
const SKILL_HUB_URL = 'https://skills.cowagent.ai/'
|
||||
// GitHub issues — where users report bugs / request features.
|
||||
const FEEDBACK_URL = 'https://github.com/zhayujie/CowAgent/issues'
|
||||
|
||||
const websiteUrl = () => (getLang() === 'zh' ? 'https://cowagent.ai/zh' : 'https://cowagent.ai')
|
||||
const docsUrl = () => (getLang() === 'zh' ? 'https://docs.cowagent.ai/zh' : 'https://docs.cowagent.ai')
|
||||
|
||||
const openExternal = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
labelKey: string
|
||||
@@ -45,21 +77,112 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
||||
const navigate = useNavigate()
|
||||
const { navCollapsed, toggleNav } = useUIStore()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
// On macOS the top-left is occupied by the native traffic lights, so the
|
||||
// brand mark is only shown on Windows/Linux where that corner is otherwise
|
||||
// empty (mirrors the web console's sidebar logo).
|
||||
const { isMac } = usePlatform()
|
||||
|
||||
const collapsed = navCollapsed
|
||||
const width = collapsed ? 'w-[56px]' : 'w-[208px]'
|
||||
|
||||
const updateState = useUpdateStore()
|
||||
// Footer dot: hidden once dismissed for this version (user asked for this).
|
||||
const pendingUpdate = hasPendingUpdate(updateState)
|
||||
// Menu "check for update" dot: stays as long as an update actually exists,
|
||||
// even after dismissing the footer badge.
|
||||
const availableUpdate = hasAvailableUpdate(updateState)
|
||||
const checking = updateState.status?.state === 'checking'
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// Local fallback so a version always shows even if the main-process IPC is
|
||||
// unavailable (e.g. dev/web preview). The real value comes from
|
||||
// app.getVersion() (packaged package.json), never from a remote service.
|
||||
const [version, setVersion] = useState(FALLBACK_VERSION)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI
|
||||
?.getAppVersion?.()
|
||||
.then((v) => v && setVersion(v))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Close the popover on any outside click / Escape.
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMenuOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [menuOpen])
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
|
||||
setLang(next)
|
||||
onLangChange()
|
||||
}
|
||||
|
||||
// Track a user-initiated check so we can show "up to date" feedback in the
|
||||
// menu when the result comes back as not-available (the auto poll stays
|
||||
// silent). Cleared shortly after, and whenever the menu closes.
|
||||
const [checkedManually, setCheckedManually] = useState(false)
|
||||
const updateStatusState = updateState.status?.state
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkedManually) return
|
||||
if (updateStatusState === 'not-available') {
|
||||
const id = setTimeout(() => setCheckedManually(false), 4000)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
// A pending update opens its own panel; no need for the inline hint.
|
||||
if (updateStatusState === 'available' || updateStatusState === 'downloaded') {
|
||||
setCheckedManually(false)
|
||||
}
|
||||
return
|
||||
}, [checkedManually, updateStatusState])
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) setCheckedManually(false)
|
||||
}, [menuOpen])
|
||||
|
||||
const checkUpdate = () => {
|
||||
setCheckedManually(true)
|
||||
// If an update is already known, recheck() re-opens its panel, so close the
|
||||
// menu to reveal it. Otherwise keep the menu OPEN: the "up to date" result
|
||||
// shows inline as the menu label — closing it (which resets checkedManually)
|
||||
// is exactly what made the box flash and never show "up to date".
|
||||
if (availableUpdate) setMenuOpen(false)
|
||||
updateState.recheck()
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}>
|
||||
{/* Top: full-width drag strip; reserve space for macOS traffic lights.
|
||||
No right border here so the divider doesn't cut across the traffic lights. */}
|
||||
<div className="titlebar-drag h-[44px] flex-shrink-0" />
|
||||
{/* Top: full-width drag strip; bottom border continues the header divider
|
||||
across the whole window. No right border so it doesn't cut the lights.
|
||||
On Windows/Linux the top-left corner is empty (no traffic lights), so
|
||||
we surface the brand mark here like the web console's sidebar. */}
|
||||
<div
|
||||
className={`titlebar-drag h-[44px] flex-shrink-0 border-b border-default flex items-center ${
|
||||
collapsed ? 'justify-center px-0' : 'px-3'
|
||||
}`}
|
||||
>
|
||||
{!isMac && (
|
||||
<div className="flex items-center gap-2 min-w-0 select-none">
|
||||
<BrandLogo />
|
||||
{!collapsed && (
|
||||
<span className="text-[14px] font-semibold text-content truncate">CowAgent</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content area carries the right divider, starting below the titlebar */}
|
||||
<div className="flex-1 flex flex-col min-h-0 border-r border-default">
|
||||
@@ -93,32 +216,74 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
||||
{!collapsed && <UpdateBanner />}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className={`flex-shrink-0 px-2 py-2 border-t border-subtle ${collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}`}>
|
||||
<FooterBtn
|
||||
collapsed={collapsed}
|
||||
onClick={() => navigate('/logs')}
|
||||
title={t('menu_logs')}
|
||||
active={location.pathname === '/logs'}
|
||||
{/* Footer actions: a single "more" entry (with version + update dot) that
|
||||
opens an upward popover, plus the always-visible collapse toggle. */}
|
||||
<div className="flex-shrink-0 px-2 py-2 border-t border-subtle relative" ref={menuRef}>
|
||||
{menuOpen && (
|
||||
<FooterMenu
|
||||
theme={theme}
|
||||
checking={checking}
|
||||
pendingUpdate={availableUpdate}
|
||||
upToDate={checkedManually && updateStatusState === 'not-available' && !availableUpdate}
|
||||
onLogs={() => {
|
||||
setMenuOpen(false)
|
||||
navigate('/logs')
|
||||
}}
|
||||
onTheme={toggleTheme}
|
||||
onLanguage={toggleLanguage}
|
||||
onCheckUpdate={checkUpdate}
|
||||
onOpenLink={(url) => {
|
||||
setMenuOpen(false)
|
||||
openExternal(url)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}>
|
||||
{/* Single clickable entry: version label (left) + the three dots
|
||||
(right) form one button; the whole block opens the popover. The
|
||||
version is the packaged app version, also what auto-update
|
||||
compares against. Collapsed: dots only, version hidden. */}
|
||||
<button
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
title={t('menu_more')}
|
||||
className={`relative inline-flex items-center rounded-btn cursor-pointer transition-colors ${
|
||||
menuOpen ? 'bg-surface-2 text-content' : 'text-content-tertiary hover:text-content hover:bg-surface-2'
|
||||
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2 gap-1.5'}`}
|
||||
>
|
||||
<ScrollText size={17} />
|
||||
</FooterBtn>
|
||||
<FooterBtn collapsed={collapsed} onClick={toggleTheme} title={theme === 'dark' ? 'Light' : 'Dark'}>
|
||||
{theme === 'dark' ? <Sun size={17} /> : <Moon size={17} />}
|
||||
</FooterBtn>
|
||||
<FooterBtn collapsed={collapsed} onClick={toggleLanguage} title="Language">
|
||||
<span className="text-[13px] font-medium w-[18px] text-center">{getLang() === 'zh' ? 'EN' : '中'}</span>
|
||||
</FooterBtn>
|
||||
<div className={collapsed ? '' : 'flex-1'} />
|
||||
{!collapsed && version && (
|
||||
<span className="text-[12px] truncate">{`v${version}`}</span>
|
||||
)}
|
||||
<MoreHorizontal size={17} className="flex-shrink-0" />
|
||||
{pendingUpdate && (
|
||||
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-danger" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!collapsed && <div className="flex-1" />}
|
||||
|
||||
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
|
||||
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
|
||||
</FooterBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// Brand mark for the top-left corner (Windows/Linux). Uses the desktop app's
|
||||
// own icon (transparent PNG with its own rounded shape), so it sits cleanly on
|
||||
// both light and dark backgrounds without extra styling.
|
||||
const BrandLogo: React.FC = () => (
|
||||
<img
|
||||
src={brandLogo}
|
||||
alt="CowAgent"
|
||||
draggable={false}
|
||||
className="flex-shrink-0 w-7 h-7 object-contain"
|
||||
/>
|
||||
)
|
||||
|
||||
const FooterBtn: React.FC<{
|
||||
collapsed: boolean
|
||||
onClick: () => void
|
||||
@@ -139,4 +304,83 @@ const FooterBtn: React.FC<{
|
||||
</button>
|
||||
)
|
||||
|
||||
// Upward popover holding the secondary actions previously crammed into the
|
||||
// footer (theme, language, logs, update check). Keeps the footer to a single
|
||||
// entry so new items can be added here without cluttering the rail.
|
||||
const FooterMenu: React.FC<{
|
||||
theme: string
|
||||
checking: boolean
|
||||
pendingUpdate: boolean
|
||||
upToDate: boolean
|
||||
onLogs: () => void
|
||||
onTheme: () => void
|
||||
onLanguage: () => void
|
||||
onCheckUpdate: () => void
|
||||
onOpenLink: (url: string) => void
|
||||
}> = ({ theme, checking, pendingUpdate, upToDate, onLogs, onTheme, onLanguage, onCheckUpdate, onOpenLink }) => {
|
||||
const updateLabel = checking
|
||||
? t('update_checking')
|
||||
: upToDate
|
||||
? t('update_latest')
|
||||
: t('update_check')
|
||||
return (
|
||||
<div className="absolute bottom-full left-2 right-2 mb-2 z-50 rounded-lg border border-default bg-elevated shadow-lg py-1">
|
||||
{/* External destinations first (skill hub, docs, website) */}
|
||||
<MenuItem icon={<Store size={16} />} label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} />
|
||||
<MenuItem icon={<FileText size={16} />} label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} />
|
||||
<MenuItem icon={<Globe size={16} />} label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} />
|
||||
<MenuItem
|
||||
icon={<MessageSquareWarning size={16} />}
|
||||
label={t('menu_feedback')}
|
||||
onClick={() => onOpenLink(FEEDBACK_URL)}
|
||||
/>
|
||||
|
||||
<div className="my-1 border-t border-subtle" />
|
||||
|
||||
{/* App actions below: update, theme, language, logs */}
|
||||
<MenuItem
|
||||
icon={checking ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
|
||||
label={updateLabel}
|
||||
onClick={onCheckUpdate}
|
||||
dot={pendingUpdate}
|
||||
disabled={checking || upToDate}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
label={theme === 'dark' ? t('menu_theme_light') : t('menu_theme_dark')}
|
||||
onClick={onTheme}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Languages size={16} />}
|
||||
label={t('menu_language')}
|
||||
trailing={getLang() === 'zh' ? 'EN' : '中'}
|
||||
onClick={onLanguage}
|
||||
/>
|
||||
<MenuItem icon={<ScrollText size={16} />} label={t('menu_logs')} onClick={onLogs} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MenuItem: React.FC<{
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
trailing?: string
|
||||
dot?: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}> = ({ icon, label, trailing, dot, disabled, onClick }) => (
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-2.5 px-3 h-9 text-[13px] text-content-secondary hover:bg-surface-2 hover:text-content cursor-pointer transition-colors disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-content-secondary"
|
||||
>
|
||||
<span className="flex-shrink-0 text-content-tertiary relative">
|
||||
{icon}
|
||||
{dot && <span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-danger" />}
|
||||
</span>
|
||||
<span className="flex-1 text-left truncate">{label}</span>
|
||||
{trailing && <span className="text-[11px] font-medium text-content-tertiary">{trailing}</span>}
|
||||
</button>
|
||||
)
|
||||
|
||||
export default NavRail
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Plus, MessageSquare, Pencil, Trash2, Check, X, PanelLeftClose } from 'lucide-react'
|
||||
import { Plus, MessageSquare, Pencil, Trash2, Check, X, History } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import { useSessionStore } from '../store/sessionStore'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
import { usePlatform } from '../hooks/usePlatform'
|
||||
import type { SessionItem } from '../types'
|
||||
|
||||
function groupByTime(sessions: SessionItem[]): { label: string; items: SessionItem[] }[] {
|
||||
@@ -32,6 +33,14 @@ const SessionList: React.FC = () => {
|
||||
const { sessions, activeId, loading, loadSessions, loadMore, hasMore, setActive, newSession, rename, remove } =
|
||||
useSessionStore()
|
||||
const toggleSessions = useUIStore((s) => s.toggleSessions)
|
||||
const navCollapsed = useUIStore((s) => s.navCollapsed)
|
||||
const { isMac } = usePlatform()
|
||||
// When the nav rail is collapsed on macOS, the native traffic lights spill
|
||||
// past it, so nudge the history button right to keep it (and its sibling in
|
||||
// the main header) clear of the lights and aligned across states.
|
||||
const trafficOffset = isMac && navCollapsed ? 'ml-2' : ''
|
||||
// Nudge header buttons down a touch to sit level with the macOS traffic lights.
|
||||
const trafficDrop = isMac ? 'mt-1' : ''
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
|
||||
@@ -56,18 +65,18 @@ const SessionList: React.FC = () => {
|
||||
return (
|
||||
<div className="w-[240px] flex-shrink-0 flex flex-col h-full bg-surface border-r border-default">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-2 h-[44px] flex-shrink-0 titlebar-drag">
|
||||
<div className="flex items-center justify-between px-2 h-[44px] flex-shrink-0 titlebar-drag border-b border-default">
|
||||
<button
|
||||
onClick={toggleSessions}
|
||||
title={t('nav_collapse')}
|
||||
className="titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors"
|
||||
title={t('session_history')}
|
||||
className={`titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors ${trafficDrop} ${trafficOffset}`}
|
||||
>
|
||||
<PanelLeftClose size={16} />
|
||||
<History size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => newSession()}
|
||||
title={t('session_new')}
|
||||
className="titlebar-no-drag inline-flex items-center gap-1.5 px-2.5 h-7 rounded-btn text-[12px] font-medium text-accent hover:bg-accent-soft cursor-pointer transition-colors"
|
||||
className={`titlebar-no-drag inline-flex items-center gap-1.5 px-2.5 h-7 rounded-btn text-[12px] font-medium text-accent hover:bg-accent-soft cursor-pointer transition-colors ${trafficDrop}`}
|
||||
>
|
||||
<Plus size={15} />
|
||||
{t('session_new')}
|
||||
|
||||
@@ -1,14 +1,53 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, Plug, QrCode } from 'lucide-react'
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Loader2,
|
||||
Plug,
|
||||
Plus,
|
||||
X,
|
||||
ChevronDown,
|
||||
Check,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Bot,
|
||||
Building2,
|
||||
Headset,
|
||||
Hash,
|
||||
AtSign,
|
||||
RadioTower,
|
||||
} from 'lucide-react'
|
||||
import { t, localizedLabel } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
import type { ChannelInfo, ChannelField } from '../types'
|
||||
import { Toggle, Btn } from './settings/primitives'
|
||||
import QrLoginModal from '../components/QrLoginModal'
|
||||
import { PaperPlaneIcon } from '../components/icons'
|
||||
|
||||
// Channels that connect via QR scanning rather than credential fields.
|
||||
const QR_PROVIDERS: Record<string, 'weixin' | 'feishu'> = { weixin: 'weixin', feishu: 'feishu' }
|
||||
|
||||
// An icon component that takes a `size` prop (lucide icons and our PaperPlaneIcon).
|
||||
type IconComponent = React.FC<{ size?: number }>
|
||||
|
||||
// Per-channel icon + accent color, mirroring the web console's FontAwesome
|
||||
// icon + Tailwind color palette (we use lucide here, with hex colors so the
|
||||
// tinted icon background isn't purged by Tailwind's JIT). Feishu/Telegram use
|
||||
// the same paper-plane as the web console.
|
||||
const CHANNEL_STYLE: Record<string, { Icon: IconComponent; color: string }> = {
|
||||
weixin: { Icon: MessageCircle, color: '#10b981' },
|
||||
feishu: { Icon: PaperPlaneIcon, color: '#3b82f6' },
|
||||
dingtalk: { Icon: MessageSquare, color: '#3b82f6' },
|
||||
wecom_bot: { Icon: Bot, color: '#10b981' },
|
||||
qq: { Icon: MessageCircle, color: '#3b82f6' },
|
||||
wechatcom_app: { Icon: Building2, color: '#10b981' },
|
||||
wechat_kf: { Icon: Headset, color: '#10b981' },
|
||||
wechatmp: { Icon: MessageCircle, color: '#10b981' },
|
||||
telegram: { Icon: PaperPlaneIcon, color: '#0ea5e9' },
|
||||
slack: { Icon: Hash, color: '#a855f7' },
|
||||
discord: { Icon: AtSign, color: '#6366f1' },
|
||||
}
|
||||
|
||||
const channelStyle = (name: string) => CHANNEL_STYLE[name] ?? { Icon: Plug, color: '#94a3b8' }
|
||||
|
||||
interface ChannelsPageProps {
|
||||
baseUrl: string
|
||||
}
|
||||
@@ -19,6 +58,12 @@ const MASK_RE = /\*{2,}/
|
||||
const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||
const [channels, setChannels] = useState<ChannelInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Whether the "add channel" panel is open, and the channel chosen in it.
|
||||
// `selected` starts empty so the user must pick a channel themselves.
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<string>('')
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadChannels = async () => {
|
||||
try {
|
||||
@@ -45,14 +90,55 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||
return { connected, available }
|
||||
}, [channels])
|
||||
|
||||
// If the selected channel got connected (or vanished), clear the selection.
|
||||
useEffect(() => {
|
||||
if (selected && !available.some((c) => c.name === selected)) setSelected('')
|
||||
}, [available, selected])
|
||||
|
||||
const openAdd = () => {
|
||||
setSelected('')
|
||||
setAddOpen(true)
|
||||
// Scroll the new panel into view at the bottom of the list.
|
||||
requestAnimationFrame(() => {
|
||||
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
})
|
||||
}
|
||||
|
||||
const addingChannel = available.find((c) => c.name === selected)
|
||||
|
||||
const onAdded = () => {
|
||||
setAddOpen(false)
|
||||
setSelected('')
|
||||
void loadChannels()
|
||||
}
|
||||
|
||||
// Keep the config form in view as it grows after picking a channel.
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
requestAnimationFrame(() => {
|
||||
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
})
|
||||
}
|
||||
}, [selected])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-6 pt-5 pb-3 flex-shrink-0">
|
||||
<div className="px-6 pt-5 pb-3 flex-shrink-0 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-content">{t('channels_title')}</h2>
|
||||
<p className="text-xs text-content-tertiary mt-1">{t('channels_desc')}</p>
|
||||
</div>
|
||||
{!loading && available.length > 0 && !addOpen && (
|
||||
<Btn variant="primary" onClick={openAdd}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Plus size={15} />
|
||||
{t('channels_add')}
|
||||
</span>
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border-t border-default">
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto border-t border-default">
|
||||
<div className="max-w-3xl mx-auto px-6 py-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-content-tertiary">
|
||||
@@ -60,21 +146,55 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||
{t('channels_loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Section title={t('channels_connected_section')}>
|
||||
{connected.length === 0 ? (
|
||||
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
|
||||
<div className="space-y-3">
|
||||
{connected.length === 0 && !addOpen ? (
|
||||
<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} />)
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{available.length > 0 && (
|
||||
<Section title={t('channels_available_section')}>
|
||||
{available.map((ch) => (
|
||||
<ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />
|
||||
))}
|
||||
</Section>
|
||||
{/* Add-channel panel lives at the bottom of the list: pick a
|
||||
channel from the dropdown, then configure/connect it inline. */}
|
||||
{addOpen && available.length > 0 && (
|
||||
<div ref={panelRef} className="rounded-card border border-accent/40 bg-surface p-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-sm font-medium text-content">{t('channels_select_label')}</label>
|
||||
<button
|
||||
onClick={() => setAddOpen(false)}
|
||||
className="text-content-tertiary hover:text-content cursor-pointer"
|
||||
title={t('channels_add_close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<ChannelDropdown
|
||||
channels={available}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
placeholder={t('channels_select_placeholder')}
|
||||
/>
|
||||
{addingChannel && (
|
||||
<ChannelCard key={addingChannel.name} channel={addingChannel} onChanged={onAdded} defaultExpanded />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -84,20 +204,101 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-content-tertiary mb-2">{title}</h3>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</div>
|
||||
)
|
||||
// Custom dropdown styled like the web console's `.cfg-dropdown` (rounded,
|
||||
// green focus ring, hover/active states) instead of a native <select>.
|
||||
const ChannelDropdown: React.FC<{
|
||||
channels: ChannelInfo[]
|
||||
value: string
|
||||
onChange: (name: string) => void
|
||||
placeholder: string
|
||||
}> = ({ channels, value, onChange, placeholder }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = ({ channel, onChanged }) => {
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [open])
|
||||
|
||||
const current = channels.find((c) => c.name === value)
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`w-full flex items-center justify-between gap-2 h-10 px-3 rounded-btn border bg-inset text-sm cursor-pointer transition-colors ${
|
||||
open ? 'border-accent ring-2 ring-accent/15' : 'border-strong hover:border-content-tertiary'
|
||||
} ${current ? 'text-content' : 'text-content-tertiary'}`}
|
||||
>
|
||||
{current ? (
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<ChannelIcon name={current.name} size={26} />
|
||||
<span className="truncate">{localizedLabel(current.label)}</span>
|
||||
<span className="text-content-tertiary font-mono text-xs">({current.name})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
)}
|
||||
<ChevronDown size={14} className={`flex-shrink-0 text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-[calc(100%+4px)] left-0 right-0 z-50 max-h-60 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg p-1">
|
||||
{channels.map((ch) => {
|
||||
const active = ch.name === value
|
||||
return (
|
||||
<button
|
||||
key={ch.name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(ch.name)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md text-sm cursor-pointer transition-colors ${
|
||||
active ? 'bg-accent-soft text-accent font-medium' : 'text-content-secondary hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<ChannelIcon name={ch.name} size={26} />
|
||||
<span className="truncate">{localizedLabel(ch.label)}</span>
|
||||
<span className="text-content-tertiary font-mono text-xs">({ch.name})</span>
|
||||
{active && <Check size={14} className="ml-auto flex-shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A tinted square with the channel's icon (web-console style).
|
||||
const ChannelIcon: React.FC<{ name: string; size?: number }> = ({ name, size = 36 }) => {
|
||||
const { Icon, color } = channelStyle(name)
|
||||
return (
|
||||
<span
|
||||
className="rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ width: size, height: size, backgroundColor: `${color}1a`, color }}
|
||||
>
|
||||
<Icon size={Math.round(size * 0.45)} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void; defaultExpanded?: boolean }> = ({
|
||||
channel,
|
||||
onChanged,
|
||||
defaultExpanded = false,
|
||||
}) => {
|
||||
// Channels with no fields connect purely via QR (e.g. weixin).
|
||||
const isQrLogin = channel.fields.length === 0
|
||||
// QR provider supported by the desktop scan modal (weixin / feishu).
|
||||
const qrProvider = QR_PROVIDERS[channel.name]
|
||||
const [showQr, setShowQr] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(channel.fields.map((f) => [f.key, f.value != null ? String(f.value) : '']))
|
||||
)
|
||||
@@ -148,15 +349,14 @@ const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = (
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-default bg-surface p-4">
|
||||
<div className={defaultExpanded ? '' : 'rounded-card border border-default bg-surface p-4'}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-inset flex items-center justify-center flex-shrink-0">
|
||||
{isQrLogin ? <QrCode size={16} className="text-content-secondary" /> : <Plug size={16} className="text-content-secondary" />}
|
||||
</div>
|
||||
<ChannelIcon name={channel.name} size={40} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-content">{localizedLabel(channel.label)}</span>
|
||||
<span className={`w-2 h-2 rounded-full ${channel.active ? 'bg-accent' : 'bg-content-tertiary'}`} />
|
||||
{channel.active && <span className="text-xs text-accent">{t('channels_connected')}</span>}
|
||||
</div>
|
||||
<p className="text-xs text-content-tertiary font-mono mt-0.5">{channel.name}</p>
|
||||
</div>
|
||||
@@ -169,7 +369,7 @@ const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = (
|
||||
<Btn variant="primary" onClick={() => setShowQr(true)}>
|
||||
{qrProvider === 'weixin' ? t('channels_scan_login') : t('channels_scan_register')}
|
||||
</Btn>
|
||||
) : isQrLogin ? null : (
|
||||
) : isQrLogin || defaultExpanded ? null : (
|
||||
<Btn variant="ghost" onClick={() => setExpanded((v) => !v)}>
|
||||
{t('channels_add')}
|
||||
</Btn>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { ChevronUp, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
ChevronUp,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
Clock,
|
||||
Code2,
|
||||
BookOpen,
|
||||
Puzzle,
|
||||
Terminal,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import MessageBubble from '../components/MessageBubble'
|
||||
import ChatInput, { type ChatInputHandle } from '../components/ChatInput'
|
||||
import { t } from '../i18n'
|
||||
@@ -7,12 +17,30 @@ import apiClient from '../api/client'
|
||||
import type { Attachment, ChatMessage } from '../types'
|
||||
import { useChatStore } from '../store/chatStore'
|
||||
import { useSessionStore } from '../store/sessionStore'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
|
||||
interface ChatPageProps {
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
const SUGGESTIONS = ['example_sys', 'example_task', 'example_code'] as const
|
||||
// Welcome-screen suggestion cards (aligned with the web console: 6 cards).
|
||||
// `send` overrides the text dropped into the input (e.g. show "查看全部命令"
|
||||
// but fill "/help"); otherwise the card's *_text is used.
|
||||
// Icon + accent color per card, aligned with the web console palette.
|
||||
const SUGGESTIONS: {
|
||||
key: string
|
||||
send?: string
|
||||
icon: LucideIcon
|
||||
iconClass: string
|
||||
bgClass: string
|
||||
}[] = [
|
||||
{ key: 'example_sys', icon: FolderOpen, iconClass: 'text-blue-500', bgClass: 'bg-blue-500/10' },
|
||||
{ key: 'example_task', icon: Clock, iconClass: 'text-amber-500', bgClass: 'bg-amber-500/10' },
|
||||
{ key: 'example_code', icon: Code2, iconClass: 'text-emerald-500', bgClass: 'bg-emerald-500/10' },
|
||||
{ key: 'example_knowledge', icon: BookOpen, iconClass: 'text-violet-500', bgClass: 'bg-violet-500/10' },
|
||||
{ key: 'example_skill', icon: Puzzle, iconClass: 'text-rose-500', bgClass: 'bg-rose-500/10' },
|
||||
{ key: 'example_web', send: '/help', icon: Terminal, iconClass: 'text-content-tertiary', bgClass: 'bg-content-tertiary/10' },
|
||||
]
|
||||
|
||||
const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const activeId = useSessionStore((s) => s.activeId)
|
||||
@@ -27,6 +55,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
||||
const loadHistory = useChatStore((s) => s.loadHistory)
|
||||
const ensureSession = useChatStore((s) => s.ensureSession)
|
||||
const clearContext = useChatStore((s) => s.clearContext)
|
||||
const setSessionsCollapsed = useUIStore((s) => s.setSessionsCollapsed)
|
||||
|
||||
const messages = session?.messages ?? []
|
||||
const isStreaming = session?.isStreaming ?? false
|
||||
@@ -51,14 +81,19 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
}, [activeId, ensureSession, loadHistory])
|
||||
|
||||
const scrollToBottom = useCallback((smooth = true) => {
|
||||
// Defer to the next frame so we read the height *after* the new content has
|
||||
// been laid out (markdown/streaming renders a frame later than the effect).
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
// Jump straight to the bottom; instant for session switches, smooth for streaming.
|
||||
// Smooth animations get interrupted by high-frequency streaming updates
|
||||
// and never catch up, so jump instantly while following the stream.
|
||||
if (smooth) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
} else {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Snap to the bottom instantly when switching sessions (no top-to-bottom animation).
|
||||
@@ -66,6 +101,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const lastSessionRef = useRef('')
|
||||
const lastLenRef = useRef(0)
|
||||
const pendingSnapRef = useRef(false)
|
||||
// True while we should keep the view pinned to the bottom (e.g. during
|
||||
// streaming). Cleared when the user scrolls up to read earlier messages.
|
||||
const followBottomRef = useRef(true)
|
||||
// Tracks the previous streaming state so we can do one final snap to the
|
||||
// bottom right when streaming ends (the last chunk of a long command output
|
||||
// often lands together with isStreaming flipping to false).
|
||||
const wasStreamingRef = useRef(false)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
@@ -74,12 +116,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
lastSessionRef.current = activeId
|
||||
lastLenRef.current = messages.length
|
||||
pendingSnapRef.current = true
|
||||
followBottomRef.current = true
|
||||
}
|
||||
|
||||
if (pendingSnapRef.current) {
|
||||
// Instant snap on switch and on the first content that lands afterwards.
|
||||
lastLenRef.current = messages.length
|
||||
requestAnimationFrame(() => scrollToBottom(false))
|
||||
scrollToBottom(false)
|
||||
if (messages.length > 0) pendingSnapRef.current = false
|
||||
return
|
||||
}
|
||||
@@ -87,8 +130,24 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
||||
const grew = messages.length !== lastLenRef.current
|
||||
lastLenRef.current = messages.length
|
||||
if (nearBottom || grew) scrollToBottom(true)
|
||||
}, [messages, activeId, scrollToBottom])
|
||||
// Follow the bottom when: a new message arrived, the user is already near
|
||||
// the bottom, or we're streaming and the user hasn't scrolled up. This
|
||||
// keeps long command/streaming output (where length is unchanged but the
|
||||
// content keeps growing) glued to the latest line.
|
||||
// One final snap right when streaming ends, so the tail of a long command
|
||||
// output isn't left scrolled off-screen.
|
||||
const justFinished = wasStreamingRef.current && !isStreaming
|
||||
wasStreamingRef.current = isStreaming
|
||||
|
||||
const following = isStreaming && followBottomRef.current
|
||||
if (grew || nearBottom || following || (justFinished && followBottomRef.current)) {
|
||||
// Instant jump while streaming/new content (smooth animations get
|
||||
// interrupted by rapid updates and never reach the bottom); smooth only
|
||||
// for a lone increment when the user is already sitting near the bottom.
|
||||
const smooth = nearBottom && !following && !grew && !justFinished
|
||||
scrollToBottom(smooth)
|
||||
}
|
||||
}, [messages, activeId, isStreaming, scrollToBottom])
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string, attachments: Attachment[]) => {
|
||||
@@ -114,15 +173,14 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const id = newSession()
|
||||
ensureSession(id)
|
||||
loadHistory(id, 1)
|
||||
}, [newSession, ensureSession, loadHistory])
|
||||
// Auto-expand the session list so the user sees the new/switched session.
|
||||
setSessionsCollapsed(false)
|
||||
}, [newSession, ensureSession, loadHistory, setSessionsCollapsed])
|
||||
|
||||
const handleClearContext = useCallback(async () => {
|
||||
try {
|
||||
await apiClient.clearContext(activeId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [activeId])
|
||||
await clearContext(activeId)
|
||||
scrollToBottom(true)
|
||||
}, [clearContext, activeId, scrollToBottom])
|
||||
|
||||
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
||||
|
||||
@@ -143,9 +201,19 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
[deleteMessage, activeId]
|
||||
)
|
||||
|
||||
// Inline images/videos load asynchronously and grow the bubble after mount,
|
||||
// so a scroll triggered on message change fires before the final height is
|
||||
// known. Re-scroll once media loads, but only while following the bottom.
|
||||
const handleMediaLoad = useCallback(() => {
|
||||
if (followBottomRef.current) scrollToBottom(false)
|
||||
}, [scrollToBottom])
|
||||
|
||||
const handleScroll = useCallback(
|
||||
async (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget
|
||||
// Track whether the user wants to stay pinned to the bottom: scrolling up
|
||||
// pauses auto-follow; returning near the bottom resumes it.
|
||||
followBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
||||
const s = useChatStore.getState().sessions[activeId]
|
||||
if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) {
|
||||
setLoadingMore(true)
|
||||
@@ -176,17 +244,30 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
||||
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
|
||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8 leading-relaxed whitespace-pre-line">
|
||||
{t('welcome_subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||
{SUGGESTIONS.map((key) => (
|
||||
<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 }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handleSend(t(`${key}_text` as Parameters<typeof t>[0]), [])}
|
||||
className="text-left bg-surface border border-default rounded-xl p-3.5 cursor-pointer hover:border-accent hover:shadow-sm transition-all"
|
||||
onClick={() => {
|
||||
// Fill the input (don't auto-send) so the user can tweak it first.
|
||||
const draft = send ?? t(`${key}_text` as Parameters<typeof t>[0])
|
||||
inputResetRef.current?.(draft, [])
|
||||
}}
|
||||
className="group text-left bg-surface border border-default rounded-xl p-3.5 cursor-pointer hover:border-accent hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="font-medium text-sm text-content mb-1">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${bgClass}`}
|
||||
>
|
||||
<Icon size={15} className={iconClass} />
|
||||
</span>
|
||||
<span className="font-medium text-sm text-content">
|
||||
{t(`${key}_title` as Parameters<typeof t>[0])}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">
|
||||
{t(`${key}_text` as Parameters<typeof t>[0])}
|
||||
@@ -197,15 +278,30 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-3 max-w-3xl mx-auto">
|
||||
{messages.map((msg) => (
|
||||
{messages.map((msg) =>
|
||||
msg.kind === 'divider' ? (
|
||||
<div key={msg.id} className="flex items-center gap-3 px-6 py-3 text-content-tertiary">
|
||||
<span
|
||||
className="flex-1 h-px"
|
||||
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||
/>
|
||||
<span className="text-xs whitespace-nowrap">{t('context_cleared')}</span>
|
||||
<span
|
||||
className="flex-1 h-px"
|
||||
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onRegenerate={handleRegenerate}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onMediaLoad={handleMediaLoad}
|
||||
/>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Loader2,
|
||||
Search,
|
||||
@@ -8,12 +8,21 @@ import {
|
||||
MessageSquarePlus,
|
||||
Network,
|
||||
Files,
|
||||
Plus,
|
||||
FolderPlus,
|
||||
FilePlus2,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { t } from '../i18n'
|
||||
import { t, getLang } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
import type { KnowledgeDir, KnowledgeFile, KnowledgeList, KnowledgeGraph as KnowledgeGraphData } from '../types'
|
||||
import type {
|
||||
KnowledgeDir,
|
||||
KnowledgeFile,
|
||||
KnowledgeList,
|
||||
KnowledgeGraph as KnowledgeGraphData,
|
||||
} from '../types'
|
||||
import Markdown from '../components/Markdown'
|
||||
import KnowledgeGraph from '../components/KnowledgeGraph'
|
||||
|
||||
@@ -23,12 +32,71 @@ interface KnowledgePageProps {
|
||||
|
||||
type Tab = 'docs' | 'graph'
|
||||
|
||||
const KNOWLEDGE_IMPORT_MAX_FILES = 100
|
||||
const KNOWLEDGE_IMPORT_MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
const KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE = 200 * 1024 * 1024
|
||||
|
||||
// t() with simple {placeholder} interpolation.
|
||||
const tf = (key: string, vars: Record<string, string | number>): string => {
|
||||
let out = t(key)
|
||||
for (const [k, v] of Object.entries(vars)) out = out.replace(`{${k}}`, String(v))
|
||||
return out
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
// The viewer already shows the doc title above the body, so a leading `# H1`
|
||||
// that repeats it looks duplicated. Drop that first H1 (and any blank lines
|
||||
// right after it) when it matches the title; leave the body untouched otherwise.
|
||||
function stripDuplicateH1(content: string, title: string): string {
|
||||
if (!content) return content
|
||||
const norm = (s: string) => s.trim().toLowerCase()
|
||||
// Skip a leading blank/whitespace region, then match the first `# heading`.
|
||||
const m = content.match(/^\s*#\s+(.+?)\s*(?:\r?\n|$)/)
|
||||
if (!m) return content
|
||||
if (norm(m[1]) !== norm(title)) return content
|
||||
return content.slice(m[0].length).replace(/^\s*\r?\n/, '')
|
||||
}
|
||||
|
||||
// Flatten the tree into category paths (for destination selectors).
|
||||
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
||||
const paths: string[] = []
|
||||
for (const dir of dirs || []) {
|
||||
const path = parent ? `${parent}/${dir.dir}` : dir.dir
|
||||
paths.push(path, ...categoryPaths(dir.children || [], path))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// Validate a batch of files chosen for import. Returns an error message or ''.
|
||||
function validateImportFiles(files: File[]): string {
|
||||
if (!files.length) return t('knowledge_import_choose_files')
|
||||
if (files.length > KNOWLEDGE_IMPORT_MAX_FILES) {
|
||||
return tf('knowledge_import_too_many', { max: KNOWLEDGE_IMPORT_MAX_FILES })
|
||||
}
|
||||
let total = 0
|
||||
for (const file of files) {
|
||||
total += file.size || 0
|
||||
if ((file.size || 0) > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
|
||||
return tf('knowledge_import_file_too_large', { name: file.name })
|
||||
}
|
||||
}
|
||||
if (total > KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE) return t('knowledge_import_total_too_large')
|
||||
return ''
|
||||
}
|
||||
|
||||
// ---- Dialog model ----------------------------------------------------------
|
||||
|
||||
interface DialogState {
|
||||
kind: 'category' | 'doc-pick-category' | 'document' | 'import'
|
||||
category?: string
|
||||
files?: File[]
|
||||
}
|
||||
|
||||
// Find the first document (root files first, then a DFS over the tree).
|
||||
function firstFile(list: KnowledgeList): { path: string; title: string } | null {
|
||||
const root = list.root_files?.[0]
|
||||
@@ -50,6 +118,72 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
|
||||
return null
|
||||
}
|
||||
|
||||
// Find a document by its bare filename anywhere in the tree (root files first,
|
||||
// then a DFS). Used to resolve relative `../foo.md` links from index docs.
|
||||
function findFileByName(list: KnowledgeList, filename: string): { path: string; title: string } | null {
|
||||
for (const f of list.root_files || []) {
|
||||
if (f.name === filename) return { path: f.name, title: f.title || f.name }
|
||||
}
|
||||
const walk = (dir: KnowledgeDir, prefix: string): { path: string; title: string } | null => {
|
||||
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||
for (const f of dir.files) {
|
||||
if (f.name === filename) return { path: `${dirPath}/${f.name}`, title: f.title || f.name }
|
||||
}
|
||||
for (const c of dir.children) {
|
||||
const hit = walk(c, dirPath)
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
for (const d of list.tree || []) {
|
||||
const hit = walk(d, '')
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve a relative `.md` link (from a document body) into a knowledge path.
|
||||
// Mirrors the web console's bindChatKnowledgeLinks logic: supports
|
||||
// `knowledge/…/x.md`, `category/x.md`, and bare/relative `../x.md` (by name).
|
||||
function resolveKnowledgeLink(list: KnowledgeList, href: string): { path: string; title: string } | null {
|
||||
const clean = href.split('#')[0].split('?')[0]
|
||||
if (!clean.endsWith('.md')) return null
|
||||
if (clean.startsWith('knowledge/')) {
|
||||
const path = clean.replace(/^knowledge\//, '')
|
||||
return { path, title: findTitle(list, path) }
|
||||
}
|
||||
if (/^[a-z0-9_-]+\/[a-z0-9_.-]+\.md$/i.test(clean) && !clean.startsWith('/') && !clean.startsWith('.')) {
|
||||
return { path: clean, title: findTitle(list, clean) }
|
||||
}
|
||||
// Relative/other path: fall back to matching by filename.
|
||||
const filename = clean.split('/').pop() || clean
|
||||
return findFileByName(list, filename)
|
||||
}
|
||||
|
||||
// Resolve a document's display title from its path, falling back to the stem.
|
||||
function findTitle(list: KnowledgeList, path: string): string {
|
||||
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
||||
for (const f of list.root_files || []) {
|
||||
if (f.name === path) return f.title || fallback
|
||||
}
|
||||
const walk = (dir: KnowledgeDir, prefix: string): string | null => {
|
||||
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||
for (const f of dir.files) {
|
||||
if (`${dirPath}/${f.name}` === path) return f.title || fallback
|
||||
}
|
||||
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 fallback
|
||||
}
|
||||
|
||||
const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
const navigate = useNavigate()
|
||||
const [tab, setTab] = useState<Tab>('docs')
|
||||
@@ -65,6 +199,22 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
const [graph, setGraph] = useState<KnowledgeGraphData | null>(null)
|
||||
const [graphLoading, setGraphLoading] = useState(false)
|
||||
|
||||
// Management UI state.
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [dialog, setDialog] = useState<DialogState | null>(null)
|
||||
const [status, setStatus] = useState<{ text: string; error: boolean } | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const statusTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const showStatus = useCallback((text: string, error = false, sticky = false) => {
|
||||
if (statusTimer.current) clearTimeout(statusTimer.current)
|
||||
setStatus({ text, error })
|
||||
if (!sticky) {
|
||||
statusTimer.current = setTimeout(() => setStatus(null), 4000)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const openDoc = useCallback(async (path: string, title: string) => {
|
||||
setActivePath(path)
|
||||
setDocTitle(title)
|
||||
@@ -72,7 +222,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
setContent('')
|
||||
try {
|
||||
const res = await apiClient.readKnowledge(path)
|
||||
setContent(res.content || '')
|
||||
setContent(stripDuplicateH1(res.content || '', title))
|
||||
} catch {
|
||||
setContent(`> ${t('knowledge_doc_load_error')}`)
|
||||
} finally {
|
||||
@@ -80,16 +230,48 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Open an internal knowledge link (relative `.md`) from within a doc body.
|
||||
// Falls back silently when the target can't be resolved in the current tree.
|
||||
const openInternalLink = useCallback(
|
||||
(href: string) => {
|
||||
if (!data) return
|
||||
const hit = resolveKnowledgeLink(data, href)
|
||||
if (hit) void openDoc(hit.path, hit.title)
|
||||
},
|
||||
[data, openDoc]
|
||||
)
|
||||
|
||||
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
||||
// currently open doc (or open the first one on the initial load).
|
||||
const refresh = useCallback(
|
||||
async (targetPath?: string) => {
|
||||
try {
|
||||
const fresh = await apiClient.getKnowledgeList()
|
||||
setData(fresh)
|
||||
if (targetPath) {
|
||||
void openDoc(targetPath, findTitle(fresh, targetPath))
|
||||
} else if (!activePath) {
|
||||
const first = firstFile(fresh)
|
||||
if (first) void openDoc(first.path, first.title)
|
||||
}
|
||||
return fresh
|
||||
} catch (e) {
|
||||
console.error('Failed to load knowledge:', e)
|
||||
return null
|
||||
}
|
||||
},
|
||||
[openDoc, activePath]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.setBaseUrl(baseUrl)
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
try {
|
||||
const fresh = await apiClient.getKnowledgeList()
|
||||
if (cancelled) return
|
||||
setData(fresh)
|
||||
// Auto-open the first document so the viewer isn't empty on entry.
|
||||
const first = firstFile(fresh)
|
||||
if (first) void openDoc(first.path, first.title)
|
||||
} catch (e) {
|
||||
@@ -101,7 +283,9 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [baseUrl, openDoc])
|
||||
// Only run on baseUrl change (initial mount). refresh() handles later reloads.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [baseUrl])
|
||||
|
||||
const loadGraph = useCallback(async () => {
|
||||
if (graph) return
|
||||
@@ -130,6 +314,115 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
[openDoc]
|
||||
)
|
||||
|
||||
// ---- Management actions --------------------------------------------------
|
||||
|
||||
const categories = useMemo(() => categoryPaths(data?.tree || []), [data])
|
||||
|
||||
const createCategory = useCallback(
|
||||
async (path: string): Promise<string | null> => {
|
||||
showStatus(t('knowledge_working'), false, true)
|
||||
try {
|
||||
const res = await apiClient.knowledgeAction({ action: 'create_category', payload: { path } })
|
||||
if (res.status !== 'success') {
|
||||
showStatus((res.message as string) || t('knowledge_request_failed'), true)
|
||||
return null
|
||||
}
|
||||
showStatus(t('knowledge_category_created'))
|
||||
await refresh()
|
||||
return path
|
||||
} catch {
|
||||
showStatus(t('knowledge_request_failed'), true)
|
||||
return null
|
||||
}
|
||||
},
|
||||
[refresh, showStatus]
|
||||
)
|
||||
|
||||
const createDocument = useCallback(
|
||||
async (path: string, content: string): Promise<string | null> => {
|
||||
showStatus(t('knowledge_working'), false, true)
|
||||
try {
|
||||
const res = await apiClient.knowledgeAction({
|
||||
action: 'create_document',
|
||||
payload: { path, content, overwrite: false },
|
||||
})
|
||||
if (res.status !== 'success') {
|
||||
showStatus((res.message as string) || t('knowledge_request_failed'), true)
|
||||
return null
|
||||
}
|
||||
const created = ((res.payload as { path?: string })?.path) || path
|
||||
showStatus(t('knowledge_document_created'))
|
||||
await refresh(created)
|
||||
return created
|
||||
} catch {
|
||||
showStatus(t('knowledge_request_failed'), true)
|
||||
return null
|
||||
}
|
||||
},
|
||||
[refresh, showStatus]
|
||||
)
|
||||
|
||||
const importDocuments = useCallback(
|
||||
async (files: File[], targetCategory: string): Promise<boolean> => {
|
||||
const err = validateImportFiles(files)
|
||||
if (err) {
|
||||
showStatus(err, true)
|
||||
return false
|
||||
}
|
||||
const supported = files.filter((f) => /\.(md|txt)$/i.test(f.name || ''))
|
||||
if (!supported.length) {
|
||||
showStatus(t('knowledge_import_choose_files'), true)
|
||||
return false
|
||||
}
|
||||
showStatus(t('knowledge_importing'), false, true)
|
||||
try {
|
||||
const res = await apiClient.importKnowledge(supported, targetCategory)
|
||||
if (res.status !== 'success') {
|
||||
showStatus(res.message || t('knowledge_import_failed'), true)
|
||||
await refresh()
|
||||
return false
|
||||
}
|
||||
const p = res.payload
|
||||
showStatus(
|
||||
tf('knowledge_import_result', {
|
||||
imported: p?.imported ?? 0,
|
||||
skipped: p?.skipped ?? 0,
|
||||
failed: p?.failed ?? 0,
|
||||
})
|
||||
)
|
||||
const first = (p?.results || []).find((r) => r.status === 'imported')
|
||||
await refresh(first?.path)
|
||||
return true
|
||||
} catch {
|
||||
showStatus(t('knowledge_import_failed'), true)
|
||||
return false
|
||||
}
|
||||
},
|
||||
[refresh, showStatus]
|
||||
)
|
||||
|
||||
// Open the import dialog after validating the chosen files.
|
||||
const startImport = useCallback(
|
||||
(files: File[]) => {
|
||||
const err = validateImportFiles(files)
|
||||
if (err) {
|
||||
showStatus(err, true)
|
||||
return
|
||||
}
|
||||
setDialog({ kind: 'import', files })
|
||||
},
|
||||
[showStatus]
|
||||
)
|
||||
|
||||
const onFilesPicked = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
e.target.value = ''
|
||||
if (files.length) startImport(files)
|
||||
},
|
||||
[startImport]
|
||||
)
|
||||
|
||||
const totalPages = data?.stats?.pages ?? 0
|
||||
const statsLabel = useMemo(() => {
|
||||
if (!data) return ''
|
||||
@@ -178,6 +471,17 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
<h2 className="text-xl font-bold text-content">{t('knowledge_title')}</h2>
|
||||
<p className="text-xs text-content-tertiary mt-1">{statsLabel}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{status && (
|
||||
<span
|
||||
className={`text-xs max-w-[260px] truncate ${
|
||||
status.error ? 'text-danger' : 'text-content-tertiary'
|
||||
}`}
|
||||
title={status.text}
|
||||
>
|
||||
{status.text}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1 bg-inset rounded-btn p-0.5">
|
||||
<TabBtn icon={Files} label={t('knowledge_tab_docs')} active={tab === 'docs'} onClick={() => switchTab('docs')} />
|
||||
<TabBtn
|
||||
@@ -187,10 +491,61 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
onClick={() => switchTab('graph')}
|
||||
/>
|
||||
</div>
|
||||
<NewMenu
|
||||
open={menuOpen}
|
||||
setOpen={setMenuOpen}
|
||||
onCreateCategory={() => setDialog({ kind: 'category' })}
|
||||
onCreateDocument={() => {
|
||||
if (!categories.length) {
|
||||
showStatus(t('knowledge_need_category'), true)
|
||||
return
|
||||
}
|
||||
setDialog({ kind: 'doc-pick-category' })
|
||||
}}
|
||||
onImport={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".md,.txt,text/markdown,text/plain"
|
||||
className="hidden"
|
||||
onChange={onFilesPicked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === 'docs' ? (
|
||||
<div className="flex-1 flex min-h-0 border-t border-default">
|
||||
<div
|
||||
className="flex-1 flex min-h-0 border-t border-default relative"
|
||||
onDragEnter={(e) => {
|
||||
if (e.dataTransfer?.types?.includes('Files')) {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer?.types?.includes('Files')) e.preventDefault()
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
// Only clear when leaving the panel, not its children.
|
||||
if (e.currentTarget === e.target) setDragOver(false)
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const files = Array.from(e.dataTransfer?.files || [])
|
||||
if (files.length) startImport(files)
|
||||
}}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center bg-accent-soft/80 border-2 border-dashed border-accent rounded-lg m-2 pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-2 text-accent">
|
||||
<Upload size={28} />
|
||||
<p className="text-sm font-medium">{t('knowledge_drop_hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Tree sidebar */}
|
||||
<div className="w-72 flex-shrink-0 flex flex-col border-r border-default min-h-0">
|
||||
<div className="p-3 flex-shrink-0">
|
||||
@@ -230,7 +585,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
<Loader2 size={16} className="animate-spin mr-2" />
|
||||
</div>
|
||||
) : (
|
||||
<Markdown content={content} />
|
||||
<Markdown content={content} onInternalLink={openInternalLink} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -251,6 +606,313 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<KnowledgeDialog
|
||||
state={dialog}
|
||||
categories={categories}
|
||||
onClose={() => setDialog(null)}
|
||||
onCreateCategory={createCategory}
|
||||
onPickDocCategory={(category) => setDialog({ kind: 'document', category })}
|
||||
onCreateDocument={createDocument}
|
||||
onImport={importDocuments}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- New menu --------------------------------------------------------------
|
||||
|
||||
const NewMenu: React.FC<{
|
||||
open: boolean
|
||||
setOpen: (v: boolean) => void
|
||||
onCreateCategory: () => void
|
||||
onCreateDocument: () => void
|
||||
onImport: () => void
|
||||
}> = ({ open, setOpen, onCreateCategory, onCreateDocument, onImport }) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open, setOpen])
|
||||
|
||||
const pick = (fn: () => void) => {
|
||||
setOpen(false)
|
||||
fn()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('knowledge_new')}
|
||||
<ChevronDown size={12} className="opacity-80" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1.5 w-44 z-50 bg-surface border border-default rounded-lg shadow-lg py-1">
|
||||
<MenuItem icon={FolderPlus} label={t('knowledge_new_category')} onClick={() => pick(onCreateCategory)} />
|
||||
<MenuItem icon={FilePlus2} label={t('knowledge_new_document')} onClick={() => pick(onCreateDocument)} />
|
||||
<MenuItem icon={Upload} label={t('knowledge_import_documents')} onClick={() => pick(onImport)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MenuItem: React.FC<{ icon: LucideIcon; label: string; onClick: () => void }> = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-content-secondary hover:bg-surface-2 cursor-pointer transition-colors text-left"
|
||||
>
|
||||
<Icon size={14} className="opacity-70 flex-shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
// ---- Dialog ----------------------------------------------------------------
|
||||
|
||||
function templateFor(filename: string): string {
|
||||
const title = (filename || 'untitled').replace(/\.md$/i, '')
|
||||
return getLang() === 'zh'
|
||||
? `# ${title}\n\n## 摘要\n\n\n## 关键点\n\n- \n\n## 参考\n\n`
|
||||
: `# ${title}\n\n## Summary\n\n\n## Key points\n\n- \n\n## References\n\n`
|
||||
}
|
||||
|
||||
const KnowledgeDialog: React.FC<{
|
||||
state: DialogState
|
||||
categories: string[]
|
||||
onClose: () => void
|
||||
onCreateCategory: (path: string) => Promise<string | null>
|
||||
onPickDocCategory: (category: string) => void
|
||||
onCreateDocument: (path: string, content: string) => Promise<string | null>
|
||||
onImport: (files: File[], target: string) => Promise<boolean>
|
||||
}> = ({ state, categories, onClose, onCreateCategory, onPickDocCategory, onCreateDocument, onImport }) => {
|
||||
const [categoryInput, setCategoryInput] = useState('')
|
||||
const [selected, setSelected] = useState(categories[0] || '')
|
||||
const [filename, setFilename] = useState('')
|
||||
const [contentInput, setContentInput] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
setError('')
|
||||
if (state.kind === 'category') {
|
||||
const path = categoryInput.trim()
|
||||
if (!path) return setError(t('knowledge_field_required'))
|
||||
setBusy(true)
|
||||
const ok = await onCreateCategory(path)
|
||||
setBusy(false)
|
||||
if (ok !== null) onClose()
|
||||
return
|
||||
}
|
||||
if (state.kind === 'doc-pick-category') {
|
||||
if (!selected) return setError(t('knowledge_field_required'))
|
||||
onPickDocCategory(selected)
|
||||
return
|
||||
}
|
||||
if (state.kind === 'document') {
|
||||
const name = filename.trim()
|
||||
if (!name) return setError(t('knowledge_doc_filename_required'))
|
||||
if (/\.[^.]+$/i.test(name) && !/\.md$/i.test(name)) return setError(t('knowledge_doc_must_md'))
|
||||
if (!contentInput.trim()) return setError(t('knowledge_doc_content_required'))
|
||||
if (new Blob([contentInput]).size > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
|
||||
return setError(t('knowledge_doc_content_too_large'))
|
||||
}
|
||||
const safeName = name.endsWith('.md') ? name : `${name}.md`
|
||||
setBusy(true)
|
||||
const ok = await onCreateDocument(`${state.category}/${safeName}`, contentInput)
|
||||
setBusy(false)
|
||||
if (ok !== null) onClose()
|
||||
return
|
||||
}
|
||||
if (state.kind === 'import') {
|
||||
if (!selected) return setError(t('knowledge_field_required'))
|
||||
setBusy(true)
|
||||
const ok = await onImport(state.files || [], selected)
|
||||
setBusy(false)
|
||||
if (ok) onClose()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const titleMap: Record<DialogState['kind'], string> = {
|
||||
category: t('knowledge_new_category'),
|
||||
'doc-pick-category': t('knowledge_new_document'),
|
||||
document: t('knowledge_new_document'),
|
||||
import: t('knowledge_import_documents'),
|
||||
}
|
||||
|
||||
const noCategory = (state.kind === 'doc-pick-category' || state.kind === 'import') && !categories.length
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 p-4" onMouseDown={onClose}>
|
||||
<div
|
||||
className="w-full max-w-lg bg-surface border border-default rounded-xl shadow-xl p-5"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-base font-semibold text-content mb-1">{titleMap[state.kind]}</h3>
|
||||
|
||||
{state.kind === 'category' && (
|
||||
<>
|
||||
<p className="text-xs text-content-tertiary mb-4">{t('knowledge_category_subtitle')}</p>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_category_label')}</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={categoryInput}
|
||||
onChange={(e) => setCategoryInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
placeholder="research/ai"
|
||||
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors"
|
||||
/>
|
||||
<p className="text-xs text-content-tertiary mt-1.5">{t('knowledge_category_hint')}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === 'doc-pick-category' && (
|
||||
<>
|
||||
<p className="text-xs text-content-tertiary mb-4">{t('knowledge_doc_choose_category')}</p>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_destination')}</label>
|
||||
<CategorySelect value={selected} options={categories} onChange={setSelected} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === 'document' && (
|
||||
<>
|
||||
<p className="text-xs text-content-tertiary mb-4">
|
||||
{tf('knowledge_doc_save_to', { category: state.category || '' })}
|
||||
</p>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_doc_filename')}</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
placeholder="my-note.md"
|
||||
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors mb-3"
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm text-content-secondary">{t('knowledge_doc_content')}</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!contentInput.trim()) setContentInput(templateFor(filename))
|
||||
}}
|
||||
className="text-xs text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
{t('knowledge_doc_insert_template')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={contentInput}
|
||||
onChange={(e) => setContentInput(e.target.value)}
|
||||
rows={10}
|
||||
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors font-mono resize-y"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === 'import' && (
|
||||
<>
|
||||
<p className="text-xs text-content-tertiary mb-4">
|
||||
{tf('knowledge_import_selected', { count: state.files?.length ?? 0 })}
|
||||
</p>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_destination')}</label>
|
||||
<CategorySelect value={selected} options={categories} onChange={setSelected} />
|
||||
<p className="text-xs text-content-tertiary mt-1.5">
|
||||
{categories.length ? t('knowledge_import_hint') : t('knowledge_import_need_category')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-danger mt-3">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-btn border border-strong text-content-secondary hover:bg-surface-2 text-sm font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
{t('knowledge_dialog_cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || noCategory}
|
||||
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
|
||||
>
|
||||
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('knowledge_dialog_confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Custom dropdown: keeps the arrow / menu styling consistent with the rest of
|
||||
// the desktop UI (a native <select> renders an OS arrow we can't space out).
|
||||
const CategorySelect: React.FC<{ value: string; options: string[]; onChange: (v: string) => void }> = ({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open])
|
||||
|
||||
const disabled = !options.length
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-2 rounded-btn border text-sm text-content transition-colors cursor-pointer ${
|
||||
open ? 'border-accent' : 'border-strong'
|
||||
} bg-inset hover:border-accent/70 disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<span className="truncate">{value || '--'}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`flex-shrink-0 text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-60 overflow-y-auto bg-surface border border-default rounded-lg shadow-lg p-1">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`w-full text-left px-2.5 py-1.5 rounded-md text-sm cursor-pointer transition-colors truncate ${
|
||||
opt === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Loader2, Clock, CalendarClock } from 'lucide-react'
|
||||
import { Loader2, Clock, CalendarClock, Play } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { t } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
@@ -165,6 +165,8 @@ const TaskEditModal: React.FC<{
|
||||
const [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message')
|
||||
const [content, setContent] = useState(task.action.content || task.action.task_description || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [running, setRunning] = useState(false)
|
||||
const [runStatus, setRunStatus] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const buildSchedule = (): TaskSchedule => {
|
||||
@@ -209,6 +211,22 @@ const TaskEditModal: React.FC<{
|
||||
}
|
||||
}
|
||||
|
||||
const runNow = async () => {
|
||||
if (!window.confirm(t('task_run_confirm'))) return
|
||||
setRunning(true)
|
||||
setRunStatus('')
|
||||
setError('')
|
||||
try {
|
||||
const result = await apiClient.runTask(task.id)
|
||||
if (result.status !== 'success') throw new Error(result.message || t('task_run_error'))
|
||||
setRunStatus(t('task_run_started'))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : t('task_run_error'))
|
||||
} finally {
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
@@ -219,6 +237,10 @@ const TaskEditModal: React.FC<{
|
||||
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
|
||||
{t('task_delete')}
|
||||
</Btn>
|
||||
<Btn variant="ghost" onClick={runNow} disabled={saving || running}>
|
||||
{running ? <Loader2 size={14} className="inline animate-spin mr-1" /> : <Play size={14} className="inline mr-1" />}
|
||||
{t('task_run_now')}
|
||||
</Btn>
|
||||
<Btn variant="ghost" onClick={onClose} disabled={saving}>
|
||||
{t('task_cancel')}
|
||||
</Btn>
|
||||
@@ -298,6 +320,7 @@ const TaskEditModal: React.FC<{
|
||||
)}
|
||||
<p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p>
|
||||
|
||||
{runStatus && <p className="text-xs text-success">{runStatus}</p>}
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -55,7 +55,9 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
setMaxSteps(data.agent_max_steps ?? 20)
|
||||
setThinking(!!data.enable_thinking)
|
||||
setEvolution(!!data.self_evolution_enabled)
|
||||
setPassword(data.web_password_masked || '')
|
||||
// Prefer the real password (desktop only) so it can be edited in place;
|
||||
// fall back to the masked value for browser access.
|
||||
setPassword(data.web_password ?? data.web_password_masked ?? '')
|
||||
setPwDirty(false)
|
||||
|
||||
const ids = data.providers ? Object.keys(data.providers) : []
|
||||
@@ -130,8 +132,14 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
setTimeout(() => setAgentStatus(''), 2000)
|
||||
}
|
||||
|
||||
// Desktop returns the real password, so the field holds plaintext and can be
|
||||
// saved (including cleared) directly. Browser access only has the masked
|
||||
// value, where a masked string must never be saved as the real password.
|
||||
const hasRealPassword = config?.web_password !== undefined
|
||||
|
||||
const savePassword = async () => {
|
||||
if (!pwDirty || MASK_RE.test(password)) return
|
||||
if (!pwDirty) return
|
||||
if (!hasRealPassword && MASK_RE.test(password)) return
|
||||
try {
|
||||
await apiClient.updateConfig({ web_password: password })
|
||||
setPwStatus(password ? t('config_password_saved') : t('config_password_cleared'))
|
||||
@@ -170,11 +178,15 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
return !!config?.api_keys?.[f]
|
||||
}
|
||||
|
||||
// Only list configured providers (built-in or custom). Unconfigured vendors
|
||||
// have no usable credentials, so showing them — flagged "unconfigured" — is
|
||||
// just noise. Keep the current selection so a saved value never disappears.
|
||||
const providerIds = config?.providers ? Object.keys(config.providers) : []
|
||||
const providerOptions = providerIds.map((id) => ({
|
||||
const providerOptions = providerIds
|
||||
.filter((id) => isConfigured(id) || id === provider)
|
||||
.map((id) => ({
|
||||
value: id,
|
||||
label: localizedLabel(providerMeta(id)?.label) || id,
|
||||
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
|
||||
}))
|
||||
const currentMeta = providerMeta(provider)
|
||||
const currentUnconfigured = !!provider && !isConfigured(provider)
|
||||
@@ -288,10 +300,13 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
value={password}
|
||||
placeholder={t('config_password_placeholder')}
|
||||
onFocus={() => {
|
||||
if (!pwDirty && MASK_RE.test(password)) setPassword('')
|
||||
// Browser access shows a mask; clear it on focus so the user
|
||||
// types a fresh password. Desktop holds the real password and
|
||||
// must stay editable in place (cursor at the end).
|
||||
if (!hasRealPassword && !pwDirty && MASK_RE.test(password)) setPassword('')
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!pwDirty) setPassword(config?.web_password_masked || '')
|
||||
if (!hasRealPassword && !pwDirty) setPassword(config?.web_password_masked || '')
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
|
||||
@@ -46,24 +46,26 @@ const CapabilityCard: React.FC<CapabilityCardProps> = ({
|
||||
const [customModel, setCustomModel] = useState('')
|
||||
const [showCustom, setShowCustom] = useState(false)
|
||||
|
||||
// A provider is configured when it has credentials (custom providers always
|
||||
// carry their own). Unconfigured ones stay selectable but are flagged so the
|
||||
// user is guided to set up the API key.
|
||||
// A provider is configured when it has credentials (a custom provider counts
|
||||
// only once it actually carries a name/key, not as an empty placeholder).
|
||||
const isConfigured = (id: string): boolean => {
|
||||
const p = data?.providers?.find((x) => x.id === id)
|
||||
if (!p) return true
|
||||
return p.configured || (p.is_custom && !!p.custom_name)
|
||||
}
|
||||
|
||||
// Only surface providers that are actually configured (built-in or custom).
|
||||
// An unconfigured vendor has no usable credentials, so listing it — and
|
||||
// flagging it "unconfigured" — only adds noise. The currently-selected
|
||||
// provider is always kept so a saved value never silently disappears.
|
||||
const providerOptions: DropdownOption[] = useMemo(() => {
|
||||
const opts = (state.providers || []).map((id) => ({
|
||||
value: id,
|
||||
label: providerLabel(data, id),
|
||||
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
|
||||
}))
|
||||
const opts = (state.providers || [])
|
||||
.filter((id) => isConfigured(id) || id === provider)
|
||||
.map((id) => ({ value: id, label: providerLabel(data, id) }))
|
||||
if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts]
|
||||
return opts
|
||||
}, [state.providers, data, allowAuto, autoLabel])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.providers, data, allowAuto, autoLabel, provider])
|
||||
|
||||
const currentUnconfigured = !!provider && !isConfigured(provider)
|
||||
|
||||
|
||||
@@ -294,8 +294,11 @@ const VendorModal: React.FC<{
|
||||
const open = !!provider || addMode
|
||||
|
||||
// In add-mode the user first picks a built-in provider; that selection
|
||||
// becomes the effective provider whose key/base fields we edit.
|
||||
const builtins = useMemo(() => data.providers.filter((p) => !(p.is_custom && p.custom_name)), [data.providers])
|
||||
// becomes the effective provider whose key/base fields we edit. Exclude ALL
|
||||
// custom providers (named or empty placeholder): custom vendors are added via
|
||||
// the single "custom vendor" option below, so an empty custom placeholder must
|
||||
// not show up here as a second, duplicate custom entry.
|
||||
const builtins = useMemo(() => data.providers.filter((p) => !p.is_custom), [data.providers])
|
||||
const firstUnconfigured = builtins.find((p) => !p.configured) || builtins[0]
|
||||
const [pickId, setPickId] = useState('')
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useLayoutEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { t } from '../../i18n'
|
||||
|
||||
@@ -50,13 +51,42 @@ export const Dropdown: React.FC<{
|
||||
}> = ({ value, display, placeholder, options, disabled, onChange }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const h = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
// The menu is rendered in a portal with fixed positioning so it's never
|
||||
// clipped by an ancestor's `overflow` (e.g. a modal's scroll container).
|
||||
const [rect, setRect] = useState<{ left: number; top: number; width: number } | null>(null)
|
||||
|
||||
const place = () => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
setRect({ left: r.left, top: r.bottom + 4, width: r.width })
|
||||
}
|
||||
document.addEventListener('mousedown', h)
|
||||
return () => document.removeEventListener('mousedown', h)
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open) place()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (ref.current?.contains(target) || menuRef.current?.contains(target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
// Keep the fixed menu anchored to the trigger on scroll/resize by
|
||||
// re-measuring — NOT closing. Closing on scroll makes the dropdown vanish
|
||||
// the moment the user scrolls the settings page.
|
||||
document.addEventListener('mousedown', onDown)
|
||||
window.addEventListener('resize', place, true)
|
||||
window.addEventListener('scroll', place, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
window.removeEventListener('resize', place, true)
|
||||
window.removeEventListener('scroll', place, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const current = display ?? options.find((o) => o.value === value)?.label ?? ''
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
@@ -73,8 +103,14 @@ export const Dropdown: React.FC<{
|
||||
<span className={`truncate ${current ? '' : 'text-content-tertiary'}`}>{current || placeholder || '--'}</span>
|
||||
<ChevronDown size={15} className={`text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute z-30 mt-1 w-full max-h-64 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg py-1">
|
||||
{open &&
|
||||
rect &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{ position: 'fixed', left: rect.left, top: rect.top, width: rect.width }}
|
||||
className="z-[100] max-h-64 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg py-1"
|
||||
>
|
||||
{options.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
|
||||
)}
|
||||
@@ -93,7 +129,8 @@ export const Dropdown: React.FC<{
|
||||
{o.hint && <div className="text-xs text-content-tertiary mt-0.5 truncate">{o.hint}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -31,6 +31,7 @@ interface ChatState {
|
||||
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
||||
|
||||
loadHistory: (sid: string, page?: number) => Promise<void>
|
||||
clearContext: (sid: string) => Promise<boolean>
|
||||
clearLocal: (sid: string) => void
|
||||
}
|
||||
|
||||
@@ -62,6 +63,47 @@ function stripCancelMarker(text: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild attachments from `send`-tool results persisted in the message steps.
|
||||
* SSE `file_to_send` events aren't stored, so on history reload the only record
|
||||
* of a sent image/file is the tool result JSON. Mirrors the web console's
|
||||
* `_renderSentFileFromToolResult` so media survives an app restart.
|
||||
*/
|
||||
function attachmentsFromSteps(steps: MessageStep[]): Attachment[] {
|
||||
const out: Attachment[] = []
|
||||
for (const s of steps) {
|
||||
if (s.type !== 'tool' || !s.result) continue
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = typeof s.result === 'string' ? JSON.parse(s.result) : (s.result as unknown as Record<string, unknown>)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!payload || payload.type !== 'file_to_send') continue
|
||||
const rawPath = (payload.path as string) || ''
|
||||
const url = (payload.url as string) || ''
|
||||
if (!rawPath && !url) continue
|
||||
const isRemote = url.toLowerCase().startsWith('http://') || url.toLowerCase().startsWith('https://')
|
||||
// Local files are served via /api/file; remote URLs are used directly.
|
||||
const previewUrl = isRemote
|
||||
? url
|
||||
: rawPath.toLowerCase().startsWith('http')
|
||||
? rawPath
|
||||
: apiClient.getServeFileUrl(rawPath)
|
||||
const kind = (payload.file_type as string) || 'file'
|
||||
const fileType: Attachment['file_type'] =
|
||||
kind === 'image' ? 'image' : kind === 'video' ? 'video' : 'file'
|
||||
out.push({
|
||||
file_path: previewUrl,
|
||||
file_name: (payload.file_name as string) || 'file',
|
||||
file_type: fileType,
|
||||
preview_url: previewUrl,
|
||||
abs_path: isRemote ? undefined : rawPath,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Convert a backend history message into a UI ChatMessage. */
|
||||
function historyToMessage(m: HistoryMessage): ChatMessage {
|
||||
if (m.role === 'user') {
|
||||
@@ -89,6 +131,7 @@ function historyToMessage(m: HistoryMessage): ChatMessage {
|
||||
.filter((_, i) => i !== lastContentIdx)
|
||||
.map((s) => ({ ...s }))
|
||||
const finalContent = m.content || (lastContentIdx >= 0 ? raw[lastContentIdx].content || '' : '')
|
||||
const attachments = attachmentsFromSteps(raw)
|
||||
|
||||
return {
|
||||
id: uid('assistant'),
|
||||
@@ -100,6 +143,7 @@ function historyToMessage(m: HistoryMessage): ChatMessage {
|
||||
kind: m.kind,
|
||||
extras: m.extras,
|
||||
botSeq: m._seq,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +262,31 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
}))
|
||||
break
|
||||
|
||||
case 'image':
|
||||
case 'file': {
|
||||
// Media pushed by the `send` tool (file_to_send). `content` is either
|
||||
// a backend /api/file?path=... URL or a passed-through http(s) URL.
|
||||
const url = data.content || ''
|
||||
if (!url) break
|
||||
// Prefer the concrete media kind from the backend (image/video/...);
|
||||
// fall back to the coarse SSE event type.
|
||||
const kind = data.file_type || (data.type === 'image' ? 'image' : 'file')
|
||||
const attType: Attachment['file_type'] =
|
||||
kind === 'image' ? 'image' : kind === 'video' ? 'video' : 'file'
|
||||
const att: Attachment = {
|
||||
file_path: url,
|
||||
file_name: data.file_name || 'file',
|
||||
file_type: attType,
|
||||
preview_url: url,
|
||||
abs_path: data.abs_path,
|
||||
}
|
||||
updateMsg(sid, botId, (m) => ({
|
||||
...m,
|
||||
attachments: [...(m.attachments || []), att],
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
||||
case 'cancelled':
|
||||
updateMsg(sid, botId, (m) => ({ ...m, isCancelled: true }))
|
||||
break
|
||||
@@ -329,6 +398,25 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
cancel: async (sid) => {
|
||||
const s = get().sessions[sid]
|
||||
if (!s?.requestId) return
|
||||
// Optimistically stop the UI right away: mark the last assistant bubble
|
||||
// cancelled, free the input, and tear down the local SSE stream so no
|
||||
// further deltas render after the user hit stop. The backend still gets
|
||||
// the cancel request to abort the running agent task.
|
||||
patchMessages(sid, (msgs) => {
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].role === 'assistant') {
|
||||
msgs[i] = { ...msgs[i], isCancelled: true, isStreaming: false }
|
||||
break
|
||||
}
|
||||
}
|
||||
return [...msgs]
|
||||
})
|
||||
patchSession(sid, { isStreaming: false, requestId: null })
|
||||
const es = streams[sid]
|
||||
if (es) {
|
||||
es.close()
|
||||
delete streams[sid]
|
||||
}
|
||||
try {
|
||||
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
||||
} catch {
|
||||
@@ -408,6 +496,28 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
clearContext: async (sid) => {
|
||||
try {
|
||||
const res = await apiClient.clearContext(sid)
|
||||
if (res.status !== 'success') return false
|
||||
// Append a visual divider so the user sees the context was cleared
|
||||
// (mirrors the web console's context-divider).
|
||||
patchMessages(sid, (msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
id: uid('divider'),
|
||||
role: 'system',
|
||||
kind: 'divider',
|
||||
content: '',
|
||||
timestamp: Date.now() / 1000,
|
||||
},
|
||||
])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
clearLocal: (sid) => {
|
||||
const es = streams[sid]
|
||||
if (es) {
|
||||
|
||||
@@ -12,6 +12,7 @@ interface UIState {
|
||||
/** Session list panel collapsed (hidden) vs expanded. */
|
||||
sessionsCollapsed: boolean
|
||||
toggleSessions: () => void
|
||||
setSessionsCollapsed: (v: boolean) => void
|
||||
|
||||
/** Currently active session id (Chat page). */
|
||||
activeSessionId: string | null
|
||||
@@ -42,6 +43,10 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
||||
return { sessionsCollapsed: next }
|
||||
}),
|
||||
setSessionsCollapsed: (v) => {
|
||||
localStorage.setItem(SESSIONS_KEY, v ? '1' : '0')
|
||||
set({ sessionsCollapsed: v })
|
||||
},
|
||||
|
||||
activeSessionId: null,
|
||||
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user