mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Compare commits
12 Commits
feat-deskt
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ded3d2c929 | ||
|
|
b55c592714 | ||
|
|
1d226979d1 | ||
|
|
6fad628551 | ||
|
|
8b426ed71d | ||
|
|
c7060c147d | ||
|
|
db49532211 | ||
|
|
1a52de241d | ||
|
|
b35501e6ad | ||
|
|
7f8f690497 | ||
|
|
356b02ac79 | ||
|
|
d5fdd644cf |
6
.github/scripts/register-releases.mjs
vendored
6
.github/scripts/register-releases.mjs
vendored
@@ -74,6 +74,12 @@ for (const base of fs.readdirSync(dir)) {
|
|||||||
} else if (/x64\.zip$/.test(base)) {
|
} else if (/x64\.zip$/.test(base)) {
|
||||||
platform = 'mac-x64'
|
platform = 'mac-x64'
|
||||||
slot = 'upd'
|
slot = 'upd'
|
||||||
|
} else if (/win7.*\.exe$/i.test(base)) {
|
||||||
|
// Legacy Win7/8 build (Electron 22). Its artifactName carries a "win7"
|
||||||
|
// segment so it never collides with the standard win exe in the same
|
||||||
|
// v<version>/ folder — just like arm64/x64 distinguish the two mac builds.
|
||||||
|
platform = 'win-legacy'
|
||||||
|
slot = 'main'
|
||||||
} else if (/\.exe$/.test(base)) {
|
} else if (/\.exe$/.test(base)) {
|
||||||
platform = 'win'
|
platform = 'win'
|
||||||
slot = 'main'
|
slot = 'main'
|
||||||
|
|||||||
154
.github/workflows/publish-desktop.yml
vendored
154
.github/workflows/publish-desktop.yml
vendored
@@ -1,154 +0,0 @@
|
|||||||
name: Publish Desktop
|
|
||||||
|
|
||||||
# STAGE 3 of the decoupled release pipeline: PROMOTE a built + notarized version
|
|
||||||
# to "live". By this point:
|
|
||||||
# - stage 1 (Release Desktop) built the installers, mirrored them to R2, and
|
|
||||||
# registered them in D1 as unpublished (is_latest=0);
|
|
||||||
# - stage 2 (local desktop/build/notarize-dmg.sh) notarized + stapled the mac
|
|
||||||
# dmgs and re-uploaded the stapled bytes to R2.
|
|
||||||
#
|
|
||||||
# This workflow, triggered manually with the version to publish:
|
|
||||||
# 1. pulls every artifact for that version back from R2,
|
|
||||||
# 2. recomputes sha512 from the real (stapled) bytes and updates D1,
|
|
||||||
# 3. flips is_latest=1 for that version (clearing the previous latest per
|
|
||||||
# platform) UNLESS it's a pre-release, which is recorded but never latest,
|
|
||||||
# 4. creates/updates the GitHub Release and attaches the installers.
|
|
||||||
#
|
|
||||||
# Run only after the mac dmgs for this version are notarized + re-uploaded.
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
version:
|
|
||||||
description: "Version to publish (e.g. 1.2.0). Must already be built + notarized."
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
make_latest:
|
|
||||||
description: "Mark this version as latest on the site (uncheck for a dry re-hash only)."
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
github_release:
|
|
||||||
description: "Create/update the GitHub Release and attach installers."
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
name: Publish ${{ github.event.inputs.version }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Guard on Cloudflare secrets
|
|
||||||
id: guard
|
|
||||||
env:
|
|
||||||
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$CF_TOKEN" ]; then
|
|
||||||
echo "::error::CLOUDFLARE_API_TOKEN not set — cannot publish."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Resolve version artifacts from D1
|
|
||||||
id: rows
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
VER: ${{ github.event.inputs.version }}
|
|
||||||
run: |
|
|
||||||
# The build stage inserted one row per artifact with filename = v<VER>/<base>.
|
|
||||||
# Read them back so we know exactly which objects to pull from R2.
|
|
||||||
out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \
|
|
||||||
--command "SELECT platform, filename, update_filename FROM releases WHERE version = '${VER}';")"
|
|
||||||
echo "$out"
|
|
||||||
echo "$out" | node -e '
|
|
||||||
const fs = require("fs");
|
|
||||||
const data = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
||||||
const rows = (Array.isArray(data) ? data : [data])
|
|
||||||
.flatMap(r => (r.results || []));
|
|
||||||
if (!rows.length) {
|
|
||||||
console.error("No D1 rows for this version — did stage 1 (build) run?");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(process.env.GITHUB_OUTPUT, "count=" + rows.length + "\n", { flag: "a" });
|
|
||||||
fs.writeFileSync("rows.json", JSON.stringify(rows));
|
|
||||||
'
|
|
||||||
|
|
||||||
- name: Download version artifacts from R2
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
|
||||||
run: |
|
|
||||||
mkdir -p dist
|
|
||||||
# Pull BOTH the manual-download file (filename: dmg/exe) and the mac
|
|
||||||
# auto-update file (update_filename: zip) for every row. Keys are
|
|
||||||
# "v<VER>/<base>"; the R2 key is "desktop/<key>".
|
|
||||||
for key in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => { if (r.filename) console.log(r.filename); if (r.update_filename) console.log(r.update_filename); })'); do
|
|
||||||
base="$(basename "$key")"
|
|
||||||
r2key="desktop/${key}"
|
|
||||||
echo "==> Downloading r2://${R2_BUCKET}/${r2key} -> dist/${base}"
|
|
||||||
npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${r2key}" \
|
|
||||||
--file "dist/${base}" --remote
|
|
||||||
done
|
|
||||||
echo "Downloaded:"; ls -la dist
|
|
||||||
|
|
||||||
- name: Reminder — mac dmgs must be notarized before publishing
|
|
||||||
run: |
|
|
||||||
# Stapling can only be validated on macOS (xcrun stapler validate),
|
|
||||||
# which this Linux runner doesn't have. The authoritative check runs in
|
|
||||||
# stage 2 (desktop/build/notarize-dmg.sh) before re-uploading to R2.
|
|
||||||
# This step is just a loud reminder in the log.
|
|
||||||
echo "::notice::Publishing assumes the mac dmgs pulled from R2 are already notarized + stapled (stage 2). If you skipped stage 2, users will hit Gatekeeper warnings."
|
|
||||||
ls -la dist/*.dmg 2>/dev/null || echo "(no dmg in this version — win-only publish)"
|
|
||||||
|
|
||||||
- name: Update D1 (recompute sha512 + set latest)
|
|
||||||
env:
|
|
||||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
||||||
VER: ${{ github.event.inputs.version }}
|
|
||||||
MAKE_LATEST: ${{ github.event.inputs.make_latest }}
|
|
||||||
run: |
|
|
||||||
# Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never
|
|
||||||
# become latest, so the site keeps serving the last stable build.
|
|
||||||
case "$VER" in
|
|
||||||
*-*) is_pre=1 ;;
|
|
||||||
*) is_pre=0 ;;
|
|
||||||
esac
|
|
||||||
if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then
|
|
||||||
latest_flag="--latest"; echo "==> Publishing $VER as latest."
|
|
||||||
else
|
|
||||||
latest_flag=""; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Re-hash the real (stapled) bytes and re-store every row with both the
|
|
||||||
# dmg (manual) and mac zip (auto-update) columns. Same script as the
|
|
||||||
# build stage; --latest also clears the previous latest per platform.
|
|
||||||
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag
|
|
||||||
echo "==> D1 statements:"; cat d1.sql
|
|
||||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
|
||||||
|
|
||||||
- name: Create/update GitHub Release and attach installers
|
|
||||||
if: github.event.inputs.github_release == 'true'
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
VER: ${{ github.event.inputs.version }}
|
|
||||||
run: |
|
|
||||||
tag="v${VER}"
|
|
||||||
case "$VER" in
|
|
||||||
*-*) prerelease="--prerelease" ;;
|
|
||||||
*) prerelease="" ;;
|
|
||||||
esac
|
|
||||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
|
||||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
|
||||||
--title "$tag" --generate-notes $prerelease
|
|
||||||
fi
|
|
||||||
# --clobber so re-runs overwrite the stapled/updated assets. The mac
|
|
||||||
# zip is the auto-update artifact; attach it too so the GitHub Release
|
|
||||||
# is a complete mirror (nullglob avoids errors when a type is absent).
|
|
||||||
shopt -s nullglob
|
|
||||||
gh release upload "$tag" dist/*.dmg dist/*.zip dist/*.exe \
|
|
||||||
--repo "$GITHUB_REPOSITORY" --clobber
|
|
||||||
230
.github/workflows/release-win7.yml
vendored
Normal file
230
.github/workflows/release-win7.yml
vendored
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
name: Release Desktop (Win7 legacy)
|
||||||
|
|
||||||
|
# One-off / on-demand build for legacy Windows 7 / 8 / 8.1 users.
|
||||||
|
#
|
||||||
|
# The main release pipeline (release.yml) ships Electron 33 (Chromium 130+) and
|
||||||
|
# a PyInstaller backend built with Python 3.11 — NEITHER runs on Windows 7,
|
||||||
|
# which is why those users hit "不是有效的 Win32 应用程序" when launching the exe.
|
||||||
|
#
|
||||||
|
# To support Win7 we must pin BOTH halves to the last versions that still
|
||||||
|
# target it:
|
||||||
|
# - Electron 22.3.27 (Chromium 108, last major to support Win7/8/8.1)
|
||||||
|
# - Python 3.8 (last CPython to support Win7)
|
||||||
|
#
|
||||||
|
# This is a SEPARATE, manually-triggered workflow so it never disturbs the main
|
||||||
|
# matrix. It produces a (signed, when SIGNTOOL_* secrets exist) NSIS installer,
|
||||||
|
# then — exactly like the main pipeline — uploads it to R2 and registers a
|
||||||
|
# release row in D1 as platform=win-legacy with is_latest=0 (UNPUBLISHED: it
|
||||||
|
# stays invisible until promoted, so it can't accidentally get served to Win10
|
||||||
|
# users). Because it's stamped with the SAME version as the standard release,
|
||||||
|
# the download page shows both Windows builds under one version row, and the
|
||||||
|
# /update feed serves each build to its own clients. Delete this file whenever
|
||||||
|
# legacy Windows support is no longer worth maintaining — the main pipeline is
|
||||||
|
# unaffected.
|
||||||
|
#
|
||||||
|
# IMPORTANT for end users: Win7 must have SP1 + update KB2533623 (or the rollup
|
||||||
|
# KB4457144) installed, otherwise the Python 3.8 backend still fails to start.
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to stamp — MUST match the standard release (e.g. 2.1.3), so the download page merges both Windows builds into one version row."
|
||||||
|
type: string
|
||||||
|
default: "0.0.0"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Windows x64 (Win7 legacy)
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Python 3.8 is the last CPython that supports Windows 7. A backend built
|
||||||
|
# with it (via PyInstaller) still runs on Win7 even though the CI host is
|
||||||
|
# Server 2022 — PyInstaller's bootloader targets the interpreter's minimum
|
||||||
|
# OS, not the build machine's.
|
||||||
|
- name: Set up Python 3.8
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.8"
|
||||||
|
|
||||||
|
- name: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Build Python backend (PyInstaller, Python 3.8)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
# Most deps are unpinned, so pip auto-picks the newest Python-3.8 wheel.
|
||||||
|
# But a few are pinned to versions with NO 3.8 wheel and must be relaxed
|
||||||
|
# for this legacy build. We rewrite them into a throwaway requirements
|
||||||
|
# file so the repo's source stays untouched (main pipeline keeps its
|
||||||
|
# pins). playwright 1.48.0 is the last release with a cp38 wheel.
|
||||||
|
sed 's/^playwright==.*/playwright==1.48.0/' \
|
||||||
|
desktop/build/requirements-desktop.txt > /tmp/requirements-win7.txt
|
||||||
|
pip install -r /tmp/requirements-win7.txt
|
||||||
|
pip install pyinstaller
|
||||||
|
# Run from repo root so the spec's relative datas resolve correctly.
|
||||||
|
pyinstaller desktop/build/cowagent-backend.spec \
|
||||||
|
--noconfirm \
|
||||||
|
--distpath desktop/build/dist \
|
||||||
|
--workpath desktop/build/build-work
|
||||||
|
|
||||||
|
- name: Install desktop deps
|
||||||
|
working-directory: desktop
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Write version into package.json
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
run: npm version "${{ github.event.inputs.version }}" --no-git-tag-version --allow-same-version
|
||||||
|
|
||||||
|
# Downgrade Electron to the last Win7-capable major (22). --no-save keeps
|
||||||
|
# this out of package.json so the repo's committed deps stay on Electron 33
|
||||||
|
# for the main pipeline. electron-builder reads the installed Electron
|
||||||
|
# version from node_modules, so this is all that's needed to package v22.
|
||||||
|
- name: Pin Electron to 22 (last Win7-capable)
|
||||||
|
working-directory: desktop
|
||||||
|
run: npm install --no-save electron@22.3.27
|
||||||
|
|
||||||
|
- name: Compile (vite + tsc)
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Same signing setup as the main pipeline: download the signtool CLI (URL
|
||||||
|
# from a repo variable so nothing is hardcoded in a public workflow). Only
|
||||||
|
# runs when a URL is configured; otherwise the build stays unsigned but
|
||||||
|
# still succeeds. SIGNTOOL_PATH is consumed by electron-builder.win.js.
|
||||||
|
- name: Download Windows signing CLI
|
||||||
|
if: vars.SIGNTOOL_CLI_URL != ''
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_CLI_URL: ${{ vars.SIGNTOOL_CLI_URL }}
|
||||||
|
run: |
|
||||||
|
mkdir -p "$RUNNER_TEMP/signtool"
|
||||||
|
curl -fsSL "$SIGNTOOL_CLI_URL" -o "$RUNNER_TEMP/signtool/cli.zip"
|
||||||
|
unzip -o "$RUNNER_TEMP/signtool/cli.zip" -d "$RUNNER_TEMP/signtool" >/dev/null
|
||||||
|
exe="$(find "$RUNNER_TEMP/signtool" -type f -iname 'signtool*.exe' | head -n1)"
|
||||||
|
if [ -z "$exe" ]; then
|
||||||
|
echo "signtool.exe not found in downloaded archive" >&2
|
||||||
|
find "$RUNNER_TEMP/signtool" -type f >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SIGNTOOL_PATH=$(cygpath -w "$exe")" >> "$GITHUB_ENV"
|
||||||
|
echo "resolved signtool: $exe"
|
||||||
|
|
||||||
|
# NSIS x64 build. --config electron-builder.win.js wires the SAME signing
|
||||||
|
# hook the main pipeline uses (signs app + backend + installer via the
|
||||||
|
# signtool CLI). When SIGNTOOL_* aren't set the hook just skips and the
|
||||||
|
# installer is still produced (unsigned). --publish never emits the exe
|
||||||
|
# without touching any feed. Invoke via node (not npx) to avoid the
|
||||||
|
# Windows npx.cmd wrapper returning early (see release.yml).
|
||||||
|
#
|
||||||
|
# -c.win.artifactName injects a "win7" segment into the file name
|
||||||
|
# (CowAgent-Setup-<ver>-win7-x64.exe). That's exactly how the two mac
|
||||||
|
# builds differ by ${arch}: it keeps the legacy exe from colliding with
|
||||||
|
# the standard win exe in the same v<version>/ folder, and lets
|
||||||
|
# register-releases.mjs map it to the win-legacy platform by name.
|
||||||
|
- name: Build installer (electron-builder, Electron 22)
|
||||||
|
working-directory: desktop
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
SIGNTOOL_ACCESS_KEY: ${{ secrets.SIGNTOOL_ACCESS_KEY }}
|
||||||
|
SIGNTOOL_ACCESS_SECRET: ${{ secrets.SIGNTOOL_ACCESS_SECRET }}
|
||||||
|
SIGNTOOL_CERT_CODE: ${{ secrets.SIGNTOOL_CERT_CODE }}
|
||||||
|
COW_SIGN_DRY_RUN: ${{ vars.COW_SIGN_DRY_RUN }}
|
||||||
|
run: |
|
||||||
|
node node_modules/electron-builder/cli.js --win --x64 \
|
||||||
|
--config electron-builder.win.js \
|
||||||
|
-c.win.artifactName='${productName}-Setup-${version}-win7-${arch}.${ext}' \
|
||||||
|
--publish never
|
||||||
|
|
||||||
|
# Collect the installer + its blockmap (differential updates). The .yml
|
||||||
|
# feed is NOT uploaded: the /update Function generates it dynamically from
|
||||||
|
# D1 (same as the main pipeline), so it isn't needed here.
|
||||||
|
- name: Upload installer artifact
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cowagent-win7-x64
|
||||||
|
path: |
|
||||||
|
desktop/release/*.exe
|
||||||
|
desktop/release/*.blockmap
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
# Publish to R2 + D1, exactly like the main pipeline's publish job: a SEPARATE
|
||||||
|
# ubuntu-latest job (NO setup-node) so it uses the runner's Node 22+ and
|
||||||
|
# wrangler@latest works (the build job pins Node 20 for Electron 22).
|
||||||
|
#
|
||||||
|
# The legacy exe lands in the SAME desktop/v<version>/ folder as the standard
|
||||||
|
# build — its "win7" name segment keeps them distinct — and register-releases
|
||||||
|
# writes a win-legacy row (is_latest=0, unpublished; promote it later via the
|
||||||
|
# publish workflow). Because the version matches the standard release, the
|
||||||
|
# download page merges both Windows builds into one version row.
|
||||||
|
publish:
|
||||||
|
name: Publish to R2 + D1
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Guard on Cloudflare secrets
|
||||||
|
id: guard
|
||||||
|
env:
|
||||||
|
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$CF_TOKEN" ]; then
|
||||||
|
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish (use the artifact instead)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Download build artifact
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cowagent-win7-x64
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
- name: Upload installer to R2
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in dist/*.exe dist/*.blockmap; do
|
||||||
|
base="$(basename "$f")"
|
||||||
|
key="desktop/v${VER}/${base}"
|
||||||
|
echo "==> Uploading $base -> r2://${R2_BUCKET}/${key}"
|
||||||
|
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
|
||||||
|
--file "$f" --remote
|
||||||
|
echo "==> Download URL: https://cdn.cowagent.ai/${key}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Register the win-legacy row in D1 (is_latest=0). register-releases.mjs
|
||||||
|
# maps the win7-named exe to platform=win-legacy; filename is v<ver>/<exe>
|
||||||
|
# relative to R2_PUBLIC_BASE (=.../desktop), matching the upload key.
|
||||||
|
- name: Register release row in D1
|
||||||
|
if: steps.guard.outputs.enabled == 'true'
|
||||||
|
env:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
|
VER: ${{ github.event.inputs.version }}
|
||||||
|
run: |
|
||||||
|
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
|
||||||
|
echo "==> D1 statements:"; cat d1.sql
|
||||||
|
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||||
@@ -90,20 +90,14 @@ FileSave = _optional_tools.get('FileSave')
|
|||||||
Terminal = _optional_tools.get('Terminal')
|
Terminal = _optional_tools.get('Terminal')
|
||||||
|
|
||||||
|
|
||||||
# BrowserTool (requires playwright)
|
# BrowserTool: playwright is soft-imported inside browser_service, so this
|
||||||
|
# import always succeeds even without playwright. Readiness (playwright pkg /
|
||||||
|
# system Chrome / downloaded Chromium) is checked at call time in BrowserTool.
|
||||||
def _import_browser_tool():
|
def _import_browser_tool():
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
try:
|
try:
|
||||||
from agent.tools.browser.browser_tool import BrowserTool
|
from agent.tools.browser.browser_tool import BrowserTool
|
||||||
return BrowserTool
|
return BrowserTool
|
||||||
except ImportError as e:
|
|
||||||
logger.info(
|
|
||||||
f"[Tools] BrowserTool not loaded - missing dependency: {e}\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
logger.error(f"[Tools] BrowserTool failed to load: {e}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -463,7 +463,18 @@ class BrowserService:
|
|||||||
headless_cfg = self._config.get("headless")
|
headless_cfg = self._config.get("headless")
|
||||||
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
||||||
|
|
||||||
launch_args = ["--disable-dev-shm-usage"]
|
launch_args = [
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
# Trim first-launch overhead: skip the first-run wizard, the default
|
||||||
|
# browser prompt, and Chrome's background/component network chatter.
|
||||||
|
# These have no effect on page interaction but noticeably speed up
|
||||||
|
# cold starts and each navigation.
|
||||||
|
"--no-first-run",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--disable-component-update",
|
||||||
|
"--disable-features=Translate,OptimizationHints",
|
||||||
|
]
|
||||||
if self._headless:
|
if self._headless:
|
||||||
launch_args.append("--no-sandbox")
|
launch_args.append("--no-sandbox")
|
||||||
|
|
||||||
@@ -733,11 +744,15 @@ class BrowserService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": f"Navigation failed: {e}"}
|
return {"error": f"Navigation failed: {e}"}
|
||||||
|
|
||||||
|
# SPAs keep long-lived connections (websockets, polling, analytics) and
|
||||||
|
# rarely reach true "networkidle", so waiting the full timeout is wasted
|
||||||
|
# time. domcontentloaded already gives a usable DOM; give the page a
|
||||||
|
# short grace period for initial render/XHR, then proceed.
|
||||||
try:
|
try:
|
||||||
page.wait_for_load_state("networkidle", timeout=8000)
|
page.wait_for_load_state("networkidle", timeout=1500)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
page.wait_for_timeout(500)
|
page.wait_for_timeout(300)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
title = page.title()
|
title = page.title()
|
||||||
|
|||||||
@@ -255,6 +255,12 @@ def _execute_agent_task(task: dict, agent_bridge) -> bool:
|
|||||||
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
logger.error(f"[Scheduler] Task {task['id']}: No result from agent execution")
|
||||||
return True # agent ran but produced nothing; don't loop
|
return True # agent ran but produced nothing; don't loop
|
||||||
|
|
||||||
|
if action.get("silent", False):
|
||||||
|
logger.info(
|
||||||
|
f"[Scheduler] Task {task['id']} executed successfully in silent mode"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
from channel.channel_factory import create_channel
|
from channel.channel_factory import create_channel
|
||||||
channel = create_channel(channel_type)
|
channel = create_channel(channel_type)
|
||||||
if not channel:
|
if not channel:
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
|
|||||||
"schedule_value": {
|
"schedule_value": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
|
||||||
|
},
|
||||||
|
"silent": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": False,
|
||||||
|
"description": "Silent mode (default false): when true, the task runs normally but its result is not pushed. Set true only when the user explicitly says they don't need the result; reminder, notification and broadcast tasks must keep it false"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["action"]
|
"required": ["action"]
|
||||||
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
|
|||||||
"channel_type": self.config.get("channel_type", "unknown"),
|
"channel_type": self.config.get("channel_type", "unknown"),
|
||||||
"notify_session_id": notify_session_id,
|
"notify_session_id": notify_session_id,
|
||||||
}
|
}
|
||||||
|
# silent only applies to ai_task; fixed messages always deliver
|
||||||
|
if kwargs.get("silent", False):
|
||||||
|
action["silent"] = True
|
||||||
|
|
||||||
# 针对钉钉单聊,额外存储 sender_staff_id
|
# 针对钉钉单聊,额外存储 sender_staff_id
|
||||||
msg = context.kwargs.get("msg")
|
msg = context.kwargs.get("msg")
|
||||||
@@ -216,14 +224,17 @@ class SchedulerTool(BaseTool):
|
|||||||
content_desc = f"💬 固定消息: {message}"
|
content_desc = f"💬 固定消息: {message}"
|
||||||
else:
|
else:
|
||||||
content_desc = f"🤖 AI任务: {ai_task}"
|
content_desc = f"🤖 AI任务: {ai_task}"
|
||||||
|
|
||||||
|
# Warn the user at creation time so a mistaken silent flag is easy to spot
|
||||||
|
silent_desc = "\n🔇 静默模式: 执行后不会推送结果" if action.get("silent") else ""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"✅ 定时任务创建成功\n\n"
|
f"✅ 定时任务创建成功\n\n"
|
||||||
f"📋 任务ID: {task_id}\n"
|
f"📋 任务ID: {task_id}\n"
|
||||||
f"📝 名称: {name}\n"
|
f"📝 名称: {name}\n"
|
||||||
f"⏰ 调度: {schedule_desc}\n"
|
f"⏰ 调度: {schedule_desc}\n"
|
||||||
f"👤 接收者: {receiver_desc}\n"
|
f"👤 接收者: {receiver_desc}\n"
|
||||||
f"{content_desc}\n"
|
f"{content_desc}{silent_desc}\n"
|
||||||
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -152,14 +152,7 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "playwright" in error_msg:
|
if "markdownify" in error_msg:
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif "markdownify" in error_msg:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||||
f" Install with: pip install markdownify"
|
f" Install with: pip install markdownify"
|
||||||
@@ -222,14 +215,7 @@ class ToolManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Handle missing dependencies with helpful messages
|
# Handle missing dependencies with helpful messages
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
if "playwright" in error_msg:
|
if "markdownify" in error_msg:
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif "markdownify" in error_msg:
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||||
f" Install with: pip install markdownify"
|
f" Install with: pip install markdownify"
|
||||||
@@ -261,14 +247,7 @@ class ToolManager:
|
|||||||
# If there are missing tools, record warnings
|
# If there are missing tools, record warnings
|
||||||
if missing_tools:
|
if missing_tools:
|
||||||
for tool_name in missing_tools:
|
for tool_name in missing_tools:
|
||||||
if tool_name == "browser":
|
if tool_name == "google_search":
|
||||||
logger.warning(
|
|
||||||
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
|
||||||
f" To enable browser tool, run:\n"
|
|
||||||
f" pip install playwright\n"
|
|
||||||
f" playwright install chromium"
|
|
||||||
)
|
|
||||||
elif tool_name == "google_search":
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
||||||
f" Get API key from: https://serper.dev\n"
|
f" Get API key from: https://serper.dev\n"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
import { app, BrowserWindow } from 'electron'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
// electron-updater is CommonJS: its members live on module.exports, with no
|
// electron-updater is CommonJS: its members live on module.exports, with no
|
||||||
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
||||||
@@ -19,12 +20,26 @@ export type UpdateStatus =
|
|||||||
|
|
||||||
let getWindow: () => BrowserWindow | null = () => null
|
let getWindow: () => BrowserWindow | null = () => null
|
||||||
|
|
||||||
|
// Legacy Windows (7/8/8.1) runs the separate Electron-22 build, which must
|
||||||
|
// update to OTHER legacy builds — never the standard build (Electron 33 won't
|
||||||
|
// launch on Win7). The update Function serves that build under /update/legacy/.
|
||||||
|
// We detect the old OS at runtime (os.release() reports the Windows NT version:
|
||||||
|
// 6.1 = Win7, 6.2/6.3 = Win8/8.1, 10.x = Win10/11) rather than via a build
|
||||||
|
// flag, so the same source serves the right feed on whatever it runs on.
|
||||||
|
function isLegacyWindows(): boolean {
|
||||||
|
if (process.platform !== 'win32') return false
|
||||||
|
const major = Number((os.release() || '').split('.')[0])
|
||||||
|
// NT 6.x = Win7/8/8.1; NT 10.x = Win10/11. Old = major < 10.
|
||||||
|
return Number.isFinite(major) && major < 10
|
||||||
|
}
|
||||||
|
|
||||||
// The update feed. Both entries hit the same Pages Function
|
// The update feed. Both entries hit the same Pages Function
|
||||||
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
||||||
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
||||||
// identical either way, so we can freely switch the feed URL between attempts
|
// identical either way, so we can freely switch the feed URL between attempts
|
||||||
// to fall back from one download origin to the other.
|
// to fall back from one download origin to the other. Legacy Windows appends a
|
||||||
const FEED_BASE = 'https://cowagent.ai/update/'
|
// /legacy/ segment so it gets the win-legacy release instead of the standard.
|
||||||
|
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
|
||||||
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
||||||
|
|
||||||
// Which origin the current session prefers, derived from the app UI language
|
// Which origin the current session prefers, derived from the app UI language
|
||||||
|
|||||||
@@ -100,29 +100,53 @@ md.renderer.rules.fence = function (tokens, idx, options, env, self) {
|
|||||||
|
|
||||||
interface MarkdownProps {
|
interface MarkdownProps {
|
||||||
content: string
|
content: string
|
||||||
|
/**
|
||||||
|
* Intercept clicks on internal document links (relative `.md` hrefs). When
|
||||||
|
* provided, such links open in-app instead of being handed to the OS. Used by
|
||||||
|
* the knowledge viewer so index links open the target doc rather than firing
|
||||||
|
* an "application cannot be opened (-120)" error in Electron.
|
||||||
|
*/
|
||||||
|
onInternalLink?: (href: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
const Markdown: React.FC<MarkdownProps> = ({ content, onInternalLink }) => {
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const html = useMemo(() => md.render(content || ''), [content])
|
const html = useMemo(() => md.render(content || ''), [content])
|
||||||
|
|
||||||
// Delegate copy clicks on code blocks (buttons are injected as raw HTML).
|
// Delegate clicks: copy buttons on code blocks, and internal doc links.
|
||||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
const handleClick = useCallback(
|
||||||
const target = e.target as HTMLElement
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
const target = e.target as HTMLElement
|
||||||
if (!btn) return
|
|
||||||
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
// Internal knowledge links (relative *.md), when a handler is provided.
|
||||||
if (!pre) return
|
if (onInternalLink) {
|
||||||
navigator.clipboard.writeText(pre.textContent || '')
|
const a = target.closest('a') as HTMLAnchorElement | null
|
||||||
const original = btn.textContent
|
if (a) {
|
||||||
btn.textContent = t('msg_copied')
|
const href = a.getAttribute('href') || ''
|
||||||
btn.classList.add('copied')
|
if (href.endsWith('.md') && !/^https?:\/\//i.test(href)) {
|
||||||
setTimeout(() => {
|
e.preventDefault()
|
||||||
btn.textContent = original
|
onInternalLink(href)
|
||||||
btn.classList.remove('copied')
|
return
|
||||||
}, 1600)
|
}
|
||||||
}, [])
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = target.closest('.code-copy-btn') as HTMLElement | null
|
||||||
|
if (!btn) return
|
||||||
|
const pre = btn.closest('.code-block-wrapper')?.querySelector('pre')
|
||||||
|
if (!pre) return
|
||||||
|
navigator.clipboard.writeText(pre.textContent || '')
|
||||||
|
const original = btn.textContent
|
||||||
|
btn.textContent = t('msg_copied')
|
||||||
|
btn.classList.add('copied')
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = original
|
||||||
|
btn.classList.remove('copied')
|
||||||
|
}, 1600)
|
||||||
|
},
|
||||||
|
[onInternalLink]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -121,8 +121,15 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
|||||||
muted, separated from the final answer by a dashed divider. */}
|
muted, separated from the final answer by a dashed divider. */}
|
||||||
{(hasSteps || hasLiveReasoning) && (
|
{(hasSteps || hasLiveReasoning) && (
|
||||||
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
||||||
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
|
|
||||||
{hasSteps && <MessageSteps steps={message.steps!} />}
|
{hasSteps && <MessageSteps steps={message.steps!} />}
|
||||||
|
{/* Live reasoning is the current, not-yet-committed thinking, so it
|
||||||
|
must render after all committed steps (tools/thinking), not at
|
||||||
|
the very top of the bubble. */}
|
||||||
|
{hasLiveReasoning && (
|
||||||
|
<div className={hasSteps ? 'mt-1' : ''}>
|
||||||
|
<ThinkingStep content={message.reasoning!} streaming />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ChevronRight, Loader2, Check, X, Brain } from 'lucide-react'
|
import { ChevronRight, Loader2, Check, X, Lightbulb } from 'lucide-react'
|
||||||
import type { MessageStep } from '../types'
|
import type { MessageStep } from '../types'
|
||||||
|
import { t } from '../i18n'
|
||||||
import Markdown from './Markdown'
|
import Markdown from './Markdown'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,8 +17,8 @@ const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ cont
|
|||||||
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
>
|
>
|
||||||
<Brain size={12} className="flex-shrink-0" />
|
<Lightbulb size={13} className={`flex-shrink-0 text-amber-400 ${streaming ? 'animate-pulse' : ''}`} />
|
||||||
<span className="flex-1">{streaming ? 'Thinking…' : 'Thought for a moment'}</span>
|
<span className="flex-1">{streaming ? t('thinking_in_progress') : t('thinking_done')}</span>
|
||||||
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
|
|||||||
@@ -129,14 +129,17 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
msg_cancelled: '已中止',
|
msg_cancelled: '已中止',
|
||||||
msg_self_learned: '自主学习',
|
msg_self_learned: '自主学习',
|
||||||
msg_stop: '停止',
|
msg_stop: '停止',
|
||||||
|
thinking_in_progress: '思考中…',
|
||||||
|
thinking_done: '已深度思考',
|
||||||
chat_clear_context: '清除上下文',
|
chat_clear_context: '清除上下文',
|
||||||
|
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||||
chat_load_earlier: '加载更早的消息',
|
chat_load_earlier: '加载更早的消息',
|
||||||
chat_send: '发送',
|
chat_send: '发送',
|
||||||
chat_attach: '添加附件',
|
chat_attach: '添加附件',
|
||||||
slash_hint: '输入 / 查看命令',
|
slash_hint: '输入 / 查看命令',
|
||||||
chat_welcome: '有什么可以帮你的?',
|
chat_welcome: '有什么可以帮你的?',
|
||||||
chat_empty_hint: '发送一条消息开始对话',
|
chat_empty_hint: '发送一条消息开始对话',
|
||||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
welcome_subtitle: '我可以帮你解决问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||||
example_sys_title: '系统管理',
|
example_sys_title: '系统管理',
|
||||||
example_sys_text: '查看工作空间里有哪些文件',
|
example_sys_text: '查看工作空间里有哪些文件',
|
||||||
example_task_title: '定时任务',
|
example_task_title: '定时任务',
|
||||||
@@ -288,6 +291,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
channels_connected_section: '已连接',
|
channels_connected_section: '已连接',
|
||||||
channels_available_section: '可添加',
|
channels_available_section: '可添加',
|
||||||
channels_empty_connected: '暂无已连接的通道',
|
channels_empty_connected: '暂无已连接的通道',
|
||||||
|
channels_empty: '暂未接入任何通道',
|
||||||
|
channels_empty_desc: '点击右上角「接入通道」按钮,即可将 CowAgent 接入微信、飞书、钉钉等消息通道',
|
||||||
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
||||||
channels_save_ok: '已保存',
|
channels_save_ok: '已保存',
|
||||||
channels_save_error: '保存失败',
|
channels_save_error: '保存失败',
|
||||||
@@ -512,7 +517,10 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
msg_cancelled: 'Cancelled',
|
msg_cancelled: 'Cancelled',
|
||||||
msg_self_learned: 'Self-learned',
|
msg_self_learned: 'Self-learned',
|
||||||
msg_stop: 'Stop',
|
msg_stop: 'Stop',
|
||||||
|
thinking_in_progress: 'Thinking…',
|
||||||
|
thinking_done: 'Thought',
|
||||||
chat_clear_context: 'Clear context',
|
chat_clear_context: 'Clear context',
|
||||||
|
context_cleared: '— Context above has been cleared —',
|
||||||
chat_load_earlier: 'Load earlier messages',
|
chat_load_earlier: 'Load earlier messages',
|
||||||
chat_send: 'Send',
|
chat_send: 'Send',
|
||||||
chat_attach: 'Attach file',
|
chat_attach: 'Attach file',
|
||||||
@@ -671,6 +679,8 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
channels_connected_section: 'Connected',
|
channels_connected_section: 'Connected',
|
||||||
channels_available_section: 'Available',
|
channels_available_section: 'Available',
|
||||||
channels_empty_connected: 'No connected channels yet',
|
channels_empty_connected: 'No connected channels yet',
|
||||||
|
channels_empty: 'No channels connected',
|
||||||
|
channels_empty_desc: 'Click "Add channel" above to connect CowAgent to WeChat, Feishu, DingTalk and more',
|
||||||
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
channels_qr_hint: 'This channel uses QR login — please connect it from the Web console',
|
||||||
channels_save_ok: 'Saved',
|
channels_save_ok: 'Saved',
|
||||||
channels_save_error: 'Failed to save',
|
channels_save_error: 'Failed to save',
|
||||||
|
|||||||
@@ -27,21 +27,21 @@
|
|||||||
--danger-border: rgba(239, 68, 68, 0.3);
|
--danger-border: rgba(239, 68, 68, 0.3);
|
||||||
--info: #3b82f6;
|
--info: #3b82f6;
|
||||||
|
|
||||||
/* Light theme — layered neutral surfaces */
|
/* Light theme — aligned with the web console: gray surfaces/borders + slate text */
|
||||||
--bg-base: #fafafa; /* app background */
|
--bg-base: #f9fafb; /* app background (gray-50) */
|
||||||
--bg-surface: #ffffff; /* panels, cards */
|
--bg-surface: #ffffff; /* panels, cards */
|
||||||
--bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */
|
--bg-surface-2: #f3f4f6; /* nested surfaces, hover fills (gray-100) */
|
||||||
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
||||||
--bg-inset: #f4f4f5; /* inputs, code blocks */
|
--bg-inset: #f3f4f6; /* inputs, code blocks (gray-100) */
|
||||||
|
|
||||||
--text-primary: #18181b; /* headings, primary text (contrast > 4.5:1) */
|
--text-primary: #1e293b; /* headings, primary text (slate-800) */
|
||||||
--text-secondary: #52525b; /* body, labels */
|
--text-secondary: #475569; /* body, labels (slate-600) */
|
||||||
--text-tertiary: #71717a; /* hints, captions */
|
--text-tertiary: #64748b; /* hints, captions (slate-500) */
|
||||||
--text-disabled: #a1a1aa;
|
--text-disabled: #94a3b8; /* slate-400 */
|
||||||
|
|
||||||
--border-default: #e4e4e7;
|
--border-default: #e5e7eb; /* gray-200 (web console border) */
|
||||||
--border-strong: #d4d4d8;
|
--border-strong: #d1d5db; /* gray-300 */
|
||||||
--border-subtle: #f0f0f1;
|
--border-subtle: #f3f4f6; /* gray-100 */
|
||||||
|
|
||||||
--overlay: rgba(0, 0, 0, 0.4);
|
--overlay: rgba(0, 0, 0, 0.4);
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Headset,
|
Headset,
|
||||||
Hash,
|
Hash,
|
||||||
AtSign,
|
AtSign,
|
||||||
|
RadioTower,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { t, localizedLabel } from '../i18n'
|
import { t, localizedLabel } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
@@ -147,7 +148,25 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{connected.length === 0 && !addOpen ? (
|
{connected.length === 0 && !addOpen ? (
|
||||||
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
|
<div className="flex flex-col items-center justify-center text-center py-16 px-6">
|
||||||
|
<span className="w-16 h-16 rounded-2xl bg-info/10 flex items-center justify-center mb-4">
|
||||||
|
<RadioTower size={26} className="text-info" />
|
||||||
|
</span>
|
||||||
|
<p className="text-content-secondary font-medium">{t('channels_empty')}</p>
|
||||||
|
<p className="text-sm text-content-tertiary mt-1.5 max-w-sm leading-relaxed">
|
||||||
|
{t('channels_empty_desc')}
|
||||||
|
</p>
|
||||||
|
{available.length > 0 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<Btn variant="primary" onClick={openAdd}>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Plus size={15} />
|
||||||
|
{t('channels_add')}
|
||||||
|
</span>
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import apiClient from '../api/client'
|
|||||||
import type { Attachment, ChatMessage } from '../types'
|
import type { Attachment, ChatMessage } from '../types'
|
||||||
import { useChatStore } from '../store/chatStore'
|
import { useChatStore } from '../store/chatStore'
|
||||||
import { useSessionStore } from '../store/sessionStore'
|
import { useSessionStore } from '../store/sessionStore'
|
||||||
|
import { useUIStore } from '../store/uiStore'
|
||||||
|
|
||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
@@ -54,6 +55,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
||||||
const loadHistory = useChatStore((s) => s.loadHistory)
|
const loadHistory = useChatStore((s) => s.loadHistory)
|
||||||
const ensureSession = useChatStore((s) => s.ensureSession)
|
const ensureSession = useChatStore((s) => s.ensureSession)
|
||||||
|
const clearContext = useChatStore((s) => s.clearContext)
|
||||||
|
const setSessionsCollapsed = useUIStore((s) => s.setSessionsCollapsed)
|
||||||
|
|
||||||
const messages = session?.messages ?? []
|
const messages = session?.messages ?? []
|
||||||
const isStreaming = session?.isStreaming ?? false
|
const isStreaming = session?.isStreaming ?? false
|
||||||
@@ -170,15 +173,14 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const id = newSession()
|
const id = newSession()
|
||||||
ensureSession(id)
|
ensureSession(id)
|
||||||
loadHistory(id, 1)
|
loadHistory(id, 1)
|
||||||
}, [newSession, ensureSession, loadHistory])
|
// Auto-expand the session list so the user sees the new/switched session.
|
||||||
|
setSessionsCollapsed(false)
|
||||||
|
}, [newSession, ensureSession, loadHistory, setSessionsCollapsed])
|
||||||
|
|
||||||
const handleClearContext = useCallback(async () => {
|
const handleClearContext = useCallback(async () => {
|
||||||
try {
|
await clearContext(activeId)
|
||||||
await apiClient.clearContext(activeId)
|
scrollToBottom(true)
|
||||||
} catch {
|
}, [clearContext, activeId, scrollToBottom])
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}, [activeId])
|
|
||||||
|
|
||||||
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
||||||
|
|
||||||
@@ -242,7 +244,9 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
||||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
||||||
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
||||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
|
<p className="text-content-tertiary text-sm text-center max-w-md mb-8 leading-relaxed whitespace-pre-line">
|
||||||
|
{t('welcome_subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||||
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
||||||
@@ -274,16 +278,30 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-3 max-w-3xl mx-auto">
|
<div className="py-3 max-w-3xl mx-auto">
|
||||||
{messages.map((msg) => (
|
{messages.map((msg) =>
|
||||||
<MessageBubble
|
msg.kind === 'divider' ? (
|
||||||
key={msg.id}
|
<div key={msg.id} className="flex items-center gap-3 px-6 py-3 text-content-tertiary">
|
||||||
message={msg}
|
<span
|
||||||
onRegenerate={handleRegenerate}
|
className="flex-1 h-px"
|
||||||
onEdit={handleEdit}
|
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||||
onDelete={handleDelete}
|
/>
|
||||||
onMediaLoad={handleMediaLoad}
|
<span className="text-xs whitespace-nowrap">{t('context_cleared')}</span>
|
||||||
/>
|
<span
|
||||||
))}
|
className="flex-1 h-px"
|
||||||
|
style={{ background: 'linear-gradient(to right, transparent, var(--border-strong), transparent)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<MessageBubble
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onMediaLoad={handleMediaLoad}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -49,6 +49,19 @@ const formatSize = (bytes: number): string => {
|
|||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The viewer already shows the doc title above the body, so a leading `# H1`
|
||||||
|
// that repeats it looks duplicated. Drop that first H1 (and any blank lines
|
||||||
|
// right after it) when it matches the title; leave the body untouched otherwise.
|
||||||
|
function stripDuplicateH1(content: string, title: string): string {
|
||||||
|
if (!content) return content
|
||||||
|
const norm = (s: string) => s.trim().toLowerCase()
|
||||||
|
// Skip a leading blank/whitespace region, then match the first `# heading`.
|
||||||
|
const m = content.match(/^\s*#\s+(.+?)\s*(?:\r?\n|$)/)
|
||||||
|
if (!m) return content
|
||||||
|
if (norm(m[1]) !== norm(title)) return content
|
||||||
|
return content.slice(m[0].length).replace(/^\s*\r?\n/, '')
|
||||||
|
}
|
||||||
|
|
||||||
// Flatten the tree into category paths (for destination selectors).
|
// Flatten the tree into category paths (for destination selectors).
|
||||||
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
@@ -105,6 +118,48 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find a document by its bare filename anywhere in the tree (root files first,
|
||||||
|
// then a DFS). Used to resolve relative `../foo.md` links from index docs.
|
||||||
|
function findFileByName(list: KnowledgeList, filename: string): { path: string; title: string } | null {
|
||||||
|
for (const f of list.root_files || []) {
|
||||||
|
if (f.name === filename) return { path: f.name, title: f.title || f.name }
|
||||||
|
}
|
||||||
|
const walk = (dir: KnowledgeDir, prefix: string): { path: string; title: string } | null => {
|
||||||
|
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||||
|
for (const f of dir.files) {
|
||||||
|
if (f.name === filename) return { path: `${dirPath}/${f.name}`, title: f.title || f.name }
|
||||||
|
}
|
||||||
|
for (const c of dir.children) {
|
||||||
|
const hit = walk(c, dirPath)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
for (const d of list.tree || []) {
|
||||||
|
const hit = walk(d, '')
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a relative `.md` link (from a document body) into a knowledge path.
|
||||||
|
// Mirrors the web console's bindChatKnowledgeLinks logic: supports
|
||||||
|
// `knowledge/…/x.md`, `category/x.md`, and bare/relative `../x.md` (by name).
|
||||||
|
function resolveKnowledgeLink(list: KnowledgeList, href: string): { path: string; title: string } | null {
|
||||||
|
const clean = href.split('#')[0].split('?')[0]
|
||||||
|
if (!clean.endsWith('.md')) return null
|
||||||
|
if (clean.startsWith('knowledge/')) {
|
||||||
|
const path = clean.replace(/^knowledge\//, '')
|
||||||
|
return { path, title: findTitle(list, path) }
|
||||||
|
}
|
||||||
|
if (/^[a-z0-9_-]+\/[a-z0-9_.-]+\.md$/i.test(clean) && !clean.startsWith('/') && !clean.startsWith('.')) {
|
||||||
|
return { path: clean, title: findTitle(list, clean) }
|
||||||
|
}
|
||||||
|
// Relative/other path: fall back to matching by filename.
|
||||||
|
const filename = clean.split('/').pop() || clean
|
||||||
|
return findFileByName(list, filename)
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve a document's display title from its path, falling back to the stem.
|
// Resolve a document's display title from its path, falling back to the stem.
|
||||||
function findTitle(list: KnowledgeList, path: string): string {
|
function findTitle(list: KnowledgeList, path: string): string {
|
||||||
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
||||||
@@ -167,7 +222,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
setContent('')
|
setContent('')
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.readKnowledge(path)
|
const res = await apiClient.readKnowledge(path)
|
||||||
setContent(res.content || '')
|
setContent(stripDuplicateH1(res.content || '', title))
|
||||||
} catch {
|
} catch {
|
||||||
setContent(`> ${t('knowledge_doc_load_error')}`)
|
setContent(`> ${t('knowledge_doc_load_error')}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -175,6 +230,17 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Open an internal knowledge link (relative `.md`) from within a doc body.
|
||||||
|
// Falls back silently when the target can't be resolved in the current tree.
|
||||||
|
const openInternalLink = useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
if (!data) return
|
||||||
|
const hit = resolveKnowledgeLink(data, href)
|
||||||
|
if (hit) void openDoc(hit.path, hit.title)
|
||||||
|
},
|
||||||
|
[data, openDoc]
|
||||||
|
)
|
||||||
|
|
||||||
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
||||||
// currently open doc (or open the first one on the initial load).
|
// currently open doc (or open the first one on the initial load).
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
@@ -519,7 +585,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
<Loader2 size={16} className="animate-spin mr-2" />
|
<Loader2 size={16} className="animate-spin mr-2" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Markdown content={content} />
|
<Markdown content={content} onInternalLink={openInternalLink} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface ChatState {
|
|||||||
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
||||||
|
|
||||||
loadHistory: (sid: string, page?: number) => Promise<void>
|
loadHistory: (sid: string, page?: number) => Promise<void>
|
||||||
|
clearContext: (sid: string) => Promise<boolean>
|
||||||
clearLocal: (sid: string) => void
|
clearLocal: (sid: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +398,25 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
cancel: async (sid) => {
|
cancel: async (sid) => {
|
||||||
const s = get().sessions[sid]
|
const s = get().sessions[sid]
|
||||||
if (!s?.requestId) return
|
if (!s?.requestId) return
|
||||||
|
// Optimistically stop the UI right away: mark the last assistant bubble
|
||||||
|
// cancelled, free the input, and tear down the local SSE stream so no
|
||||||
|
// further deltas render after the user hit stop. The backend still gets
|
||||||
|
// the cancel request to abort the running agent task.
|
||||||
|
patchMessages(sid, (msgs) => {
|
||||||
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||||
|
if (msgs[i].role === 'assistant') {
|
||||||
|
msgs[i] = { ...msgs[i], isCancelled: true, isStreaming: false }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...msgs]
|
||||||
|
})
|
||||||
|
patchSession(sid, { isStreaming: false, requestId: null })
|
||||||
|
const es = streams[sid]
|
||||||
|
if (es) {
|
||||||
|
es.close()
|
||||||
|
delete streams[sid]
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
||||||
} catch {
|
} catch {
|
||||||
@@ -476,6 +496,28 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearContext: async (sid) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.clearContext(sid)
|
||||||
|
if (res.status !== 'success') return false
|
||||||
|
// Append a visual divider so the user sees the context was cleared
|
||||||
|
// (mirrors the web console's context-divider).
|
||||||
|
patchMessages(sid, (msgs) => [
|
||||||
|
...msgs,
|
||||||
|
{
|
||||||
|
id: uid('divider'),
|
||||||
|
role: 'system',
|
||||||
|
kind: 'divider',
|
||||||
|
content: '',
|
||||||
|
timestamp: Date.now() / 1000,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
clearLocal: (sid) => {
|
clearLocal: (sid) => {
|
||||||
const es = streams[sid]
|
const es = streams[sid]
|
||||||
if (es) {
|
if (es) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface UIState {
|
|||||||
/** Session list panel collapsed (hidden) vs expanded. */
|
/** Session list panel collapsed (hidden) vs expanded. */
|
||||||
sessionsCollapsed: boolean
|
sessionsCollapsed: boolean
|
||||||
toggleSessions: () => void
|
toggleSessions: () => void
|
||||||
|
setSessionsCollapsed: (v: boolean) => void
|
||||||
|
|
||||||
/** Currently active session id (Chat page). */
|
/** Currently active session id (Chat page). */
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
@@ -42,6 +43,10 @@ export const useUIStore = create<UIState>((set) => ({
|
|||||||
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
localStorage.setItem(SESSIONS_KEY, next ? '1' : '0')
|
||||||
return { sessionsCollapsed: next }
|
return { sessionsCollapsed: next }
|
||||||
}),
|
}),
|
||||||
|
setSessionsCollapsed: (v) => {
|
||||||
|
localStorage.setItem(SESSIONS_KEY, v ? '1' : '0')
|
||||||
|
set({ sessionsCollapsed: v })
|
||||||
|
},
|
||||||
|
|
||||||
activeSessionId: null,
|
activeSessionId: null,
|
||||||
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
setActiveSessionId: (id) => set({ activeSessionId: id }),
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export interface BackendStatusEvent {
|
|||||||
// Chat / messages / streaming
|
// Chat / messages / streaming
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
export type Role = 'user' | 'assistant'
|
export type Role = 'user' | 'assistant' | 'system'
|
||||||
|
|
||||||
/** A single ordered step inside an assistant turn (matches backend history). */
|
/** A single ordered step inside an assistant turn (matches backend history). */
|
||||||
export interface MessageStep {
|
export interface MessageStep {
|
||||||
@@ -83,8 +83,8 @@ export interface ChatMessage {
|
|||||||
/** Sequence numbers from backend (for delete/regenerate). */
|
/** Sequence numbers from backend (for delete/regenerate). */
|
||||||
userSeq?: number
|
userSeq?: number
|
||||||
botSeq?: number
|
botSeq?: number
|
||||||
/** Self-evolution bubble flag. */
|
/** Self-evolution bubble flag; 'divider' renders a context-cleared separator. */
|
||||||
kind?: 'evolution'
|
kind?: 'evolution' | 'divider'
|
||||||
extras?: Record<string, unknown>
|
extras?: Record<string, unknown>
|
||||||
isStreaming?: boolean
|
isStreaming?: boolean
|
||||||
isCancelled?: boolean
|
isCancelled?: boolean
|
||||||
|
|||||||
92
tests/test_scheduler_silent.py
Normal file
92
tests/test_scheduler_silent.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""Regression tests for silent scheduled agent tasks."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
|
||||||
|
from agent.tools.scheduler.integration import _execute_agent_task
|
||||||
|
from agent.tools.scheduler.scheduler_tool import SchedulerTool
|
||||||
|
|
||||||
|
|
||||||
|
class _Context(dict):
|
||||||
|
kwargs = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _TaskStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.added = []
|
||||||
|
|
||||||
|
def add_task(self, task):
|
||||||
|
self.added.append(task)
|
||||||
|
|
||||||
|
|
||||||
|
class _AgentBridge:
|
||||||
|
def __init__(self, content="maintenance complete"):
|
||||||
|
self.content = content
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def agent_reply(self, task_description, **kwargs):
|
||||||
|
self.calls.append((task_description, kwargs))
|
||||||
|
return SimpleNamespace(content=self.content)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchedulerSilentMode(unittest.TestCase):
|
||||||
|
def test_schema_exposes_silent_for_agent_tasks(self):
|
||||||
|
silent = SchedulerTool.params["properties"]["silent"]
|
||||||
|
|
||||||
|
self.assertEqual(silent["type"], "boolean")
|
||||||
|
self.assertFalse(silent["default"])
|
||||||
|
|
||||||
|
def test_create_persists_silent_on_agent_task(self):
|
||||||
|
tool = SchedulerTool({"channel_type": "web"})
|
||||||
|
store = _TaskStore()
|
||||||
|
tool.task_store = store
|
||||||
|
tool.current_context = _Context(
|
||||||
|
receiver="user-1",
|
||||||
|
session_id="session-1",
|
||||||
|
isgroup=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = tool.execute(
|
||||||
|
{
|
||||||
|
"action": "create",
|
||||||
|
"name": "refresh token",
|
||||||
|
"ai_task": "refresh the token",
|
||||||
|
"schedule_type": "interval",
|
||||||
|
"schedule_value": "3000",
|
||||||
|
"silent": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result.status, "success")
|
||||||
|
self.assertEqual(len(store.added), 1)
|
||||||
|
self.assertIs(store.added[0]["action"]["silent"], True)
|
||||||
|
|
||||||
|
def test_silent_agent_task_executes_without_delivery(self):
|
||||||
|
bridge = _AgentBridge()
|
||||||
|
task = {
|
||||||
|
"id": "task-1",
|
||||||
|
"action": {
|
||||||
|
"type": "agent_task",
|
||||||
|
"task_description": "rotate logs",
|
||||||
|
"receiver": "user-1",
|
||||||
|
"is_group": False,
|
||||||
|
"channel_type": "web",
|
||||||
|
"silent": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("channel.channel_factory.create_channel") as create_channel:
|
||||||
|
result = _execute_agent_task(task, bridge)
|
||||||
|
|
||||||
|
self.assertTrue(result)
|
||||||
|
self.assertEqual(len(bridge.calls), 1)
|
||||||
|
create_channel.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user