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)) {
|
||||
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'
|
||||
|
||||
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')
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
@@ -463,7 +463,18 @@ class BrowserService:
|
||||
headless_cfg = self._config.get("headless")
|
||||
self._headless = headless_cfg if headless_cfg is not None else _should_use_headless()
|
||||
|
||||
launch_args = ["--disable-dev-shm-usage"]
|
||||
launch_args = [
|
||||
"--disable-dev-shm-usage",
|
||||
# Trim first-launch overhead: skip the first-run wizard, the default
|
||||
# browser prompt, and Chrome's background/component network chatter.
|
||||
# These have no effect on page interaction but noticeably speed up
|
||||
# cold starts and each navigation.
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-features=Translate,OptimizationHints",
|
||||
]
|
||||
if self._headless:
|
||||
launch_args.append("--no-sandbox")
|
||||
|
||||
@@ -733,11 +744,15 @@ class BrowserService:
|
||||
except Exception as e:
|
||||
return {"error": f"Navigation failed: {e}"}
|
||||
|
||||
# SPAs keep long-lived connections (websockets, polling, analytics) and
|
||||
# rarely reach true "networkidle", so waiting the full timeout is wasted
|
||||
# time. domcontentloaded already gives a usable DOM; give the page a
|
||||
# short grace period for initial render/XHR, then proceed.
|
||||
try:
|
||||
page.wait_for_load_state("networkidle", timeout=8000)
|
||||
page.wait_for_load_state("networkidle", timeout=1500)
|
||||
except Exception:
|
||||
pass
|
||||
page.wait_for_timeout(500)
|
||||
page.wait_for_timeout(300)
|
||||
|
||||
try:
|
||||
title = page.title()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -216,14 +224,17 @@ class SchedulerTool(BaseTool):
|
||||
content_desc = f"💬 固定消息: {message}"
|
||||
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 '未知'}"
|
||||
)
|
||||
|
||||
|
||||
@@ -152,14 +152,7 @@ class ToolManager:
|
||||
except ImportError as e:
|
||||
# Handle missing dependencies with helpful messages
|
||||
error_msg = str(e)
|
||||
if "playwright" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
||||
f" To enable browser tool, run:\n"
|
||||
f" pip install playwright\n"
|
||||
f" playwright install chromium"
|
||||
)
|
||||
elif "markdownify" in error_msg:
|
||||
if "markdownify" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||
f" Install with: pip install markdownify"
|
||||
@@ -222,14 +215,7 @@ class ToolManager:
|
||||
except ImportError as e:
|
||||
# Handle missing dependencies with helpful messages
|
||||
error_msg = str(e)
|
||||
if "playwright" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] Browser tool not loaded - missing dependencies.\n"
|
||||
f" To enable browser tool, run:\n"
|
||||
f" pip install playwright\n"
|
||||
f" playwright install chromium"
|
||||
)
|
||||
elif "markdownify" in error_msg:
|
||||
if "markdownify" in error_msg:
|
||||
logger.warning(
|
||||
f"[ToolManager] {cls.__name__} not loaded - missing markdownify.\n"
|
||||
f" Install with: pip install markdownify"
|
||||
@@ -261,14 +247,7 @@ class ToolManager:
|
||||
# If there are missing tools, record warnings
|
||||
if missing_tools:
|
||||
for tool_name in missing_tools:
|
||||
if tool_name == "browser":
|
||||
logger.warning(
|
||||
f"[ToolManager] Browser tool is configured but not loaded.\n"
|
||||
f" To enable browser tool, run:\n"
|
||||
f" pip install playwright\n"
|
||||
f" playwright install chromium"
|
||||
)
|
||||
elif tool_name == "google_search":
|
||||
if tool_name == "google_search":
|
||||
logger.warning(
|
||||
f"[ToolManager] Google Search tool is configured but may need API key.\n"
|
||||
f" Get API key from: https://serper.dev\n"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import path from 'path'
|
||||
// electron-updater is CommonJS: its members live on module.exports, with no
|
||||
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
||||
@@ -19,12 +20,26 @@ export type UpdateStatus =
|
||||
|
||||
let getWindow: () => BrowserWindow | null = () => null
|
||||
|
||||
// Legacy Windows (7/8/8.1) runs the separate Electron-22 build, which must
|
||||
// update to OTHER legacy builds — never the standard build (Electron 33 won't
|
||||
// launch on Win7). The update Function serves that build under /update/legacy/.
|
||||
// We detect the old OS at runtime (os.release() reports the Windows NT version:
|
||||
// 6.1 = Win7, 6.2/6.3 = Win8/8.1, 10.x = Win10/11) rather than via a build
|
||||
// flag, so the same source serves the right feed on whatever it runs on.
|
||||
function isLegacyWindows(): boolean {
|
||||
if (process.platform !== 'win32') return false
|
||||
const major = Number((os.release() || '').split('.')[0])
|
||||
// NT 6.x = Win7/8/8.1; NT 10.x = Win10/11. Old = major < 10.
|
||||
return Number.isFinite(major) && major < 10
|
||||
}
|
||||
|
||||
// The update feed. Both entries hit the same Pages Function
|
||||
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
||||
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
||||
// identical either way, so we can freely switch the feed URL between attempts
|
||||
// to fall back from one download origin to the other.
|
||||
const FEED_BASE = 'https://cowagent.ai/update/'
|
||||
// to fall back from one download origin to the other. Legacy Windows appends a
|
||||
// /legacy/ segment so it gets the win-legacy release instead of the standard.
|
||||
const FEED_BASE = 'https://cowagent.ai/update/' + (isLegacyWindows() ? 'legacy/' : '')
|
||||
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
||||
|
||||
// Which origin the current session prefers, derived from the app UI language
|
||||
|
||||
@@ -100,29 +100,53 @@ 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>) => {
|
||||
const target = e.target as HTMLElement
|
||||
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)
|
||||
}, [])
|
||||
// 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')
|
||||
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 (
|
||||
<div
|
||||
|
||||
@@ -121,8 +121,15 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
||||
muted, separated from the final answer by a dashed divider. */}
|
||||
{(hasSteps || hasLiveReasoning) && (
|
||||
<div className="mb-2.5 pb-2 border-b border-dashed border-default">
|
||||
{hasLiveReasoning && <ThinkingStep content={message.reasoning!} streaming />}
|
||||
{hasSteps && <MessageSteps steps={message.steps!} />}
|
||||
{/* Live reasoning is the current, not-yet-committed thinking, so it
|
||||
must render after all committed steps (tools/thinking), not at
|
||||
the very top of the bubble. */}
|
||||
{hasLiveReasoning && (
|
||||
<div className={hasSteps ? 'mt-1' : ''}>
|
||||
<ThinkingStep content={message.reasoning!} streaming />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronRight, Loader2, Check, X, Brain } from 'lucide-react'
|
||||
import { ChevronRight, Loader2, Check, X, Lightbulb } from 'lucide-react'
|
||||
import type { MessageStep } from '../types'
|
||||
import { t } from '../i18n'
|
||||
import Markdown from './Markdown'
|
||||
|
||||
/**
|
||||
@@ -16,8 +17,8 @@ const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ cont
|
||||
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<Brain size={12} className="flex-shrink-0" />
|
||||
<span className="flex-1">{streaming ? 'Thinking…' : 'Thought for a moment'}</span>
|
||||
<Lightbulb size={13} className={`flex-shrink-0 text-amber-400 ${streaming ? 'animate-pulse' : ''}`} />
|
||||
<span className="flex-1">{streaming ? t('thinking_in_progress') : t('thinking_done')}</span>
|
||||
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||
</div>
|
||||
{expanded && (
|
||||
|
||||
@@ -129,14 +129,17 @@ const translations: Record<string, Record<string, string>> = {
|
||||
msg_cancelled: '已中止',
|
||||
msg_self_learned: '自主学习',
|
||||
msg_stop: '停止',
|
||||
thinking_in_progress: '思考中…',
|
||||
thinking_done: '已深度思考',
|
||||
chat_clear_context: '清除上下文',
|
||||
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||
chat_load_earlier: '加载更早的消息',
|
||||
chat_send: '发送',
|
||||
chat_attach: '添加附件',
|
||||
slash_hint: '输入 / 查看命令',
|
||||
chat_welcome: '有什么可以帮你的?',
|
||||
chat_empty_hint: '发送一条消息开始对话',
|
||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||
welcome_subtitle: '我可以帮你解决问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||
example_sys_title: '系统管理',
|
||||
example_sys_text: '查看工作空间里有哪些文件',
|
||||
example_task_title: '定时任务',
|
||||
@@ -288,6 +291,8 @@ const translations: Record<string, Record<string, string>> = {
|
||||
channels_connected_section: '已连接',
|
||||
channels_available_section: '可添加',
|
||||
channels_empty_connected: '暂无已连接的通道',
|
||||
channels_empty: '暂未接入任何通道',
|
||||
channels_empty_desc: '点击右上角「接入通道」按钮,即可将 CowAgent 接入微信、飞书、钉钉等消息通道',
|
||||
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
||||
channels_save_ok: '已保存',
|
||||
channels_save_error: '保存失败',
|
||||
@@ -512,7 +517,10 @@ const translations: Record<string, Record<string, string>> = {
|
||||
msg_cancelled: 'Cancelled',
|
||||
msg_self_learned: 'Self-learned',
|
||||
msg_stop: 'Stop',
|
||||
thinking_in_progress: 'Thinking…',
|
||||
thinking_done: 'Thought',
|
||||
chat_clear_context: 'Clear context',
|
||||
context_cleared: '— Context above has been cleared —',
|
||||
chat_load_earlier: 'Load earlier messages',
|
||||
chat_send: 'Send',
|
||||
chat_attach: 'Attach file',
|
||||
@@ -671,6 +679,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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Headset,
|
||||
Hash,
|
||||
AtSign,
|
||||
RadioTower,
|
||||
} from 'lucide-react'
|
||||
import { t, localizedLabel } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
@@ -147,7 +148,25 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{connected.length === 0 && !addOpen ? (
|
||||
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 px-6">
|
||||
<span className="w-16 h-16 rounded-2xl bg-info/10 flex items-center justify-center mb-4">
|
||||
<RadioTower size={26} className="text-info" />
|
||||
</span>
|
||||
<p className="text-content-secondary font-medium">{t('channels_empty')}</p>
|
||||
<p className="text-sm text-content-tertiary mt-1.5 max-w-sm leading-relaxed">
|
||||
{t('channels_empty_desc')}
|
||||
</p>
|
||||
{available.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<Btn variant="primary" onClick={openAdd}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Plus size={15} />
|
||||
{t('channels_add')}
|
||||
</span>
|
||||
</Btn>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
||||
)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import apiClient from '../api/client'
|
||||
import type { Attachment, ChatMessage } from '../types'
|
||||
import { useChatStore } from '../store/chatStore'
|
||||
import { useSessionStore } from '../store/sessionStore'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
|
||||
interface ChatPageProps {
|
||||
baseUrl: string
|
||||
@@ -54,6 +55,8 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
||||
const loadHistory = useChatStore((s) => s.loadHistory)
|
||||
const ensureSession = useChatStore((s) => s.ensureSession)
|
||||
const clearContext = useChatStore((s) => s.clearContext)
|
||||
const setSessionsCollapsed = useUIStore((s) => s.setSessionsCollapsed)
|
||||
|
||||
const messages = session?.messages ?? []
|
||||
const isStreaming = session?.isStreaming ?? false
|
||||
@@ -170,15 +173,14 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
const id = newSession()
|
||||
ensureSession(id)
|
||||
loadHistory(id, 1)
|
||||
}, [newSession, ensureSession, loadHistory])
|
||||
// Auto-expand the session list so the user sees the new/switched session.
|
||||
setSessionsCollapsed(false)
|
||||
}, [newSession, ensureSession, loadHistory, setSessionsCollapsed])
|
||||
|
||||
const handleClearContext = useCallback(async () => {
|
||||
try {
|
||||
await apiClient.clearContext(activeId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [activeId])
|
||||
await clearContext(activeId)
|
||||
scrollToBottom(true)
|
||||
}, [clearContext, activeId, scrollToBottom])
|
||||
|
||||
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
||||
|
||||
@@ -242,7 +244,9 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
||||
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
|
||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8 leading-relaxed whitespace-pre-line">
|
||||
{t('welcome_subtitle')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
||||
@@ -274,16 +278,30 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-3 max-w-3xl mx-auto">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
onRegenerate={handleRegenerate}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onMediaLoad={handleMediaLoad}
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -49,6 +49,19 @@ const formatSize = (bytes: number): string => {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
// The viewer already shows the doc title above the body, so a leading `# H1`
|
||||
// that repeats it looks duplicated. Drop that first H1 (and any blank lines
|
||||
// right after it) when it matches the title; leave the body untouched otherwise.
|
||||
function stripDuplicateH1(content: string, title: string): string {
|
||||
if (!content) return content
|
||||
const norm = (s: string) => s.trim().toLowerCase()
|
||||
// Skip a leading blank/whitespace region, then match the first `# heading`.
|
||||
const m = content.match(/^\s*#\s+(.+?)\s*(?:\r?\n|$)/)
|
||||
if (!m) return content
|
||||
if (norm(m[1]) !== norm(title)) return content
|
||||
return content.slice(m[0].length).replace(/^\s*\r?\n/, '')
|
||||
}
|
||||
|
||||
// Flatten the tree into category paths (for destination selectors).
|
||||
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
||||
const paths: string[] = []
|
||||
@@ -105,6 +118,48 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
|
||||
return null
|
||||
}
|
||||
|
||||
// Find a document by its bare filename anywhere in the tree (root files first,
|
||||
// then a DFS). Used to resolve relative `../foo.md` links from index docs.
|
||||
function findFileByName(list: KnowledgeList, filename: string): { path: string; title: string } | null {
|
||||
for (const f of list.root_files || []) {
|
||||
if (f.name === filename) return { path: f.name, title: f.title || f.name }
|
||||
}
|
||||
const walk = (dir: KnowledgeDir, prefix: string): { path: string; title: string } | null => {
|
||||
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||
for (const f of dir.files) {
|
||||
if (f.name === filename) return { path: `${dirPath}/${f.name}`, title: f.title || f.name }
|
||||
}
|
||||
for (const c of dir.children) {
|
||||
const hit = walk(c, dirPath)
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
for (const d of list.tree || []) {
|
||||
const hit = walk(d, '')
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve a relative `.md` link (from a document body) into a knowledge path.
|
||||
// Mirrors the web console's bindChatKnowledgeLinks logic: supports
|
||||
// `knowledge/…/x.md`, `category/x.md`, and bare/relative `../x.md` (by name).
|
||||
function resolveKnowledgeLink(list: KnowledgeList, href: string): { path: string; title: string } | null {
|
||||
const clean = href.split('#')[0].split('?')[0]
|
||||
if (!clean.endsWith('.md')) return null
|
||||
if (clean.startsWith('knowledge/')) {
|
||||
const path = clean.replace(/^knowledge\//, '')
|
||||
return { path, title: findTitle(list, path) }
|
||||
}
|
||||
if (/^[a-z0-9_-]+\/[a-z0-9_.-]+\.md$/i.test(clean) && !clean.startsWith('/') && !clean.startsWith('.')) {
|
||||
return { path: clean, title: findTitle(list, clean) }
|
||||
}
|
||||
// Relative/other path: fall back to matching by filename.
|
||||
const filename = clean.split('/').pop() || clean
|
||||
return findFileByName(list, filename)
|
||||
}
|
||||
|
||||
// Resolve a document's display title from its path, falling back to the stem.
|
||||
function findTitle(list: KnowledgeList, path: string): string {
|
||||
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
||||
@@ -167,7 +222,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
setContent('')
|
||||
try {
|
||||
const res = await apiClient.readKnowledge(path)
|
||||
setContent(res.content || '')
|
||||
setContent(stripDuplicateH1(res.content || '', title))
|
||||
} catch {
|
||||
setContent(`> ${t('knowledge_doc_load_error')}`)
|
||||
} finally {
|
||||
@@ -175,6 +230,17 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Open an internal knowledge link (relative `.md`) from within a doc body.
|
||||
// Falls back silently when the target can't be resolved in the current tree.
|
||||
const openInternalLink = useCallback(
|
||||
(href: string) => {
|
||||
if (!data) return
|
||||
const hit = resolveKnowledgeLink(data, href)
|
||||
if (hit) void openDoc(hit.path, hit.title)
|
||||
},
|
||||
[data, openDoc]
|
||||
)
|
||||
|
||||
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
||||
// currently open doc (or open the first one on the initial load).
|
||||
const refresh = useCallback(
|
||||
@@ -519,7 +585,7 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||
<Loader2 size={16} className="animate-spin mr-2" />
|
||||
</div>
|
||||
) : (
|
||||
<Markdown content={content} />
|
||||
<Markdown content={content} onInternalLink={openInternalLink} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,7 @@ interface ChatState {
|
||||
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
||||
|
||||
loadHistory: (sid: string, page?: number) => Promise<void>
|
||||
clearContext: (sid: string) => Promise<boolean>
|
||||
clearLocal: (sid: string) => void
|
||||
}
|
||||
|
||||
@@ -397,6 +398,25 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
cancel: async (sid) => {
|
||||
const s = get().sessions[sid]
|
||||
if (!s?.requestId) return
|
||||
// Optimistically stop the UI right away: mark the last assistant bubble
|
||||
// cancelled, free the input, and tear down the local SSE stream so no
|
||||
// further deltas render after the user hit stop. The backend still gets
|
||||
// the cancel request to abort the running agent task.
|
||||
patchMessages(sid, (msgs) => {
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].role === 'assistant') {
|
||||
msgs[i] = { ...msgs[i], isCancelled: true, isStreaming: false }
|
||||
break
|
||||
}
|
||||
}
|
||||
return [...msgs]
|
||||
})
|
||||
patchSession(sid, { isStreaming: false, requestId: null })
|
||||
const es = streams[sid]
|
||||
if (es) {
|
||||
es.close()
|
||||
delete streams[sid]
|
||||
}
|
||||
try {
|
||||
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
||||
} catch {
|
||||
@@ -476,6 +496,28 @@ export const useChatStore = create<ChatState>((set, get) => {
|
||||
}
|
||||
},
|
||||
|
||||
clearContext: async (sid) => {
|
||||
try {
|
||||
const res = await apiClient.clearContext(sid)
|
||||
if (res.status !== 'success') return false
|
||||
// Append a visual divider so the user sees the context was cleared
|
||||
// (mirrors the web console's context-divider).
|
||||
patchMessages(sid, (msgs) => [
|
||||
...msgs,
|
||||
{
|
||||
id: uid('divider'),
|
||||
role: 'system',
|
||||
kind: 'divider',
|
||||
content: '',
|
||||
timestamp: Date.now() / 1000,
|
||||
},
|
||||
])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
clearLocal: (sid) => {
|
||||
const es = streams[sid]
|
||||
if (es) {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -50,7 +50,7 @@ export interface BackendStatusEvent {
|
||||
// Chat / messages / streaming
|
||||
// ============================================================
|
||||
|
||||
export type Role = 'user' | 'assistant'
|
||||
export type Role = 'user' | 'assistant' | 'system'
|
||||
|
||||
/** A single ordered step inside an assistant turn (matches backend history). */
|
||||
export interface MessageStep {
|
||||
@@ -83,8 +83,8 @@ export interface ChatMessage {
|
||||
/** Sequence numbers from backend (for delete/regenerate). */
|
||||
userSeq?: number
|
||||
botSeq?: number
|
||||
/** Self-evolution bubble flag. */
|
||||
kind?: 'evolution'
|
||||
/** Self-evolution bubble flag; 'divider' renders a context-cleared separator. */
|
||||
kind?: 'evolution' | 'divider'
|
||||
extras?: Record<string, unknown>
|
||||
isStreaming?: boolean
|
||||
isCancelled?: boolean
|
||||
|
||||
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