Compare commits

...

12 Commits

Author SHA1 Message Date
zhayujie
ded3d2c929 Merge branch 'agent/scheduler-silent-mode' 2026-07-17 10:53:46 +08:00
zhayujie
b55c592714 feat: refine scheduler silent mode to avoid mislabeling delivery tasks 2026-07-17 10:53:29 +08:00
zhayujie
1d226979d1 perf(browser): speed up navigation + fix desktop knowledge viewer links 2026-07-16 23:32:06 +08:00
zhayujie
6fad628551 feat(desktop): support win-legacy desktop CI 2026-07-16 23:02:37 +08:00
zhayujie
8b426ed71d feat: optimize desktop client style
- light theme colors, fix chat cancel logic, add clear-context feedback, polish thinking step icon/order, and align channels empty state with the web console
2026-07-16 19:31:56 +08:00
zhayujie
c7060c147d fix: windows desktop workflow 2026-07-16 17:00:21 +08:00
zhayujie
db49532211 feat(desktop): publish win7 client in workflow 2026-07-16 16:41:27 +08:00
zhayujie
1a52de241d fix: playwright version in win7 2026-07-16 16:19:49 +08:00
zhayujie
b35501e6ad feat(desktop): add win7 workflow 2026-07-16 15:08:15 +08:00
zhayujie
7f8f690497 refactor(tools): remove dead playwright-missing branches 2026-07-16 14:56:23 +08:00
zhayujie
356b02ac79 Merge pull request #2955 from zhayujie/feat-desktop-browser
feat(browser): reuse system Chrome/Edge, bundle playwright for desktop
2026-07-14 18:03:22 +08:00
AaronZ345
d5fdd644cf feat: add silent scheduler agent tasks 2026-07-14 12:08:37 +08:00
21 changed files with 637 additions and 251 deletions

View File

@@ -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'

View File

@@ -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
View 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

View File

@@ -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

View File

@@ -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()

View File

@@ -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:

View File

@@ -64,6 +64,11 @@ class SchedulerTool(BaseTool):
"schedule_value": { "schedule_value": {
"type": "string", "type": "string",
"description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)" "description": "调度值: cron表达式/间隔秒数/时间(+5s,+10m,+1h或ISO格式)"
},
"silent": {
"type": "boolean",
"default": False,
"description": "Silent mode (default false): when true, the task runs normally but its result is not pushed. Set true only when the user explicitly says they don't need the result; reminder, notification and broadcast tasks must keep it false"
} }
}, },
"required": ["action"] "required": ["action"]
@@ -184,6 +189,9 @@ class SchedulerTool(BaseTool):
"channel_type": self.config.get("channel_type", "unknown"), "channel_type": self.config.get("channel_type", "unknown"),
"notify_session_id": notify_session_id, "notify_session_id": notify_session_id,
} }
# silent only applies to ai_task; fixed messages always deliver
if kwargs.get("silent", False):
action["silent"] = True
# 针对钉钉单聊,额外存储 sender_staff_id # 针对钉钉单聊,额外存储 sender_staff_id
msg = context.kwargs.get("msg") msg = context.kwargs.get("msg")
@@ -217,13 +225,16 @@ class SchedulerTool(BaseTool):
else: else:
content_desc = f"🤖 AI任务: {ai_task}" content_desc = f"🤖 AI任务: {ai_task}"
# Warn the user at creation time so a mistaken silent flag is easy to spot
silent_desc = "\n🔇 静默模式: 执行后不会推送结果" if action.get("silent") else ""
return ( return (
f"✅ 定时任务创建成功\n\n" f"✅ 定时任务创建成功\n\n"
f"📋 任务ID: {task_id}\n" f"📋 任务ID: {task_id}\n"
f"📝 名称: {name}\n" f"📝 名称: {name}\n"
f"⏰ 调度: {schedule_desc}\n" f"⏰ 调度: {schedule_desc}\n"
f"👤 接收者: {receiver_desc}\n" f"👤 接收者: {receiver_desc}\n"
f"{content_desc}\n" f"{content_desc}{silent_desc}\n"
f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}" f"🕐 下次执行: {next_run.strftime('%Y-%m-%d %H:%M:%S') if next_run else '未知'}"
) )

View File

@@ -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"

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)} )}

View File

@@ -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 && (

View File

@@ -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',

View File

@@ -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);

View File

@@ -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} />)
)} )}

View File

@@ -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>
)} )}

View File

@@ -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>
)} )}

View File

@@ -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) {

View File

@@ -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 }),

View File

@@ -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

View 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()