mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Compare commits
142 Commits
2.1.0
...
v0.0.3-des
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6211e63f90 | ||
|
|
44b61684ed | ||
|
|
ab6f49a822 | ||
|
|
02517e4a01 | ||
|
|
2599966cf7 | ||
|
|
6c68931892 | ||
|
|
41855ed511 | ||
|
|
02bc91f4af | ||
|
|
e9352e6984 | ||
|
|
ec4c36f450 | ||
|
|
215ed24401 | ||
|
|
c432681b2b | ||
|
|
49452e035d | ||
|
|
d1336b872e | ||
|
|
e1e29b32e9 | ||
|
|
214dcaf141 | ||
|
|
77a196de8b | ||
|
|
108d04398b | ||
|
|
c9c16298ec | ||
|
|
2ef31d5d33 | ||
|
|
e9d9b566a4 | ||
|
|
3baa3252bc | ||
|
|
90d9db0f83 | ||
|
|
8bff4f1658 | ||
|
|
a0e20ef311 | ||
|
|
6996215d3b | ||
|
|
03ffa2db7d | ||
|
|
75e3110e8c | ||
|
|
033480eef1 | ||
|
|
8ddfcbb125 | ||
|
|
a5aaecc48d | ||
|
|
01373465b0 | ||
|
|
a1e733080d | ||
|
|
3b3ef715bb | ||
|
|
47b2bf9d46 | ||
|
|
ea47f3097e | ||
|
|
70c1c44d15 | ||
|
|
e3dce45b2a | ||
|
|
3bb8ec3bea | ||
|
|
35e42a3ad6 | ||
|
|
949575ad14 | ||
|
|
1c34f0f03d | ||
|
|
eed2eab014 | ||
|
|
381cbed4fd | ||
|
|
4cde25325d | ||
|
|
8d70af5e89 | ||
|
|
b3408d8e5f | ||
|
|
e74906fbec | ||
|
|
52209217fc | ||
|
|
018493a60b | ||
|
|
630373b1f0 | ||
|
|
dec31dfd75 | ||
|
|
2397ea019e | ||
|
|
77da90e316 | ||
|
|
18ce17d21a | ||
|
|
ce09b14836 | ||
|
|
e7a069b060 | ||
|
|
6e3933be30 | ||
|
|
e2cb9e11b0 | ||
|
|
d281a34c6f | ||
|
|
c97cf5610f | ||
|
|
ab674a3517 | ||
|
|
7d63e7d8fa | ||
|
|
6538843bdf | ||
|
|
bd5fede122 | ||
|
|
047fb57630 | ||
|
|
583c1de5ba | ||
|
|
c9c293f67c | ||
|
|
561631baba | ||
|
|
80d0a6aeb2 | ||
|
|
6b5ee245ae | ||
|
|
5c43c2f519 | ||
|
|
9387980e74 | ||
|
|
075d9fc608 | ||
|
|
63bfab03f6 | ||
|
|
1d7e6b3703 | ||
|
|
ad64e17a34 | ||
|
|
0092376c07 | ||
|
|
6fb19a68b5 | ||
|
|
d5427d967a | ||
|
|
7fd30b608c | ||
|
|
830b05f243 | ||
|
|
e85290cddc | ||
|
|
1940d628a8 | ||
|
|
cffa590d3e | ||
|
|
402e2bfee0 | ||
|
|
f5caba81d6 | ||
|
|
354350dec9 | ||
|
|
0513298f57 | ||
|
|
08e23e5bd8 | ||
|
|
e812c7d29a | ||
|
|
ef46199346 | ||
|
|
7c9ea62993 | ||
|
|
8cb53e6129 | ||
|
|
12c0383dc8 | ||
|
|
83b53039f3 | ||
|
|
7e6a309935 | ||
|
|
33c03e30d9 | ||
|
|
1f1abdd7b6 | ||
|
|
16134bd150 | ||
|
|
c887fc71ad | ||
|
|
9fc39f648f | ||
|
|
ec9557e3d8 | ||
|
|
7cf0f7d42d | ||
|
|
b7aa64279d | ||
|
|
26300a8d43 | ||
|
|
8dd21ddb83 | ||
|
|
ff584f8421 | ||
|
|
ca4a8253a1 | ||
|
|
157374401a | ||
|
|
ba777ed706 | ||
|
|
0e4da1d1c5 | ||
|
|
72847e0711 | ||
|
|
3c19614c74 | ||
|
|
a2e4955116 | ||
|
|
c62175c06b | ||
|
|
fde4b6f590 | ||
|
|
3d7c68bac6 | ||
|
|
72a477f10c | ||
|
|
2a16c562a8 | ||
|
|
2b670e73f3 | ||
|
|
3994594019 | ||
|
|
39c9386b54 | ||
|
|
4cc57cc08d | ||
|
|
639a3eac1e | ||
|
|
79323358e5 | ||
|
|
cdb093c74a | ||
|
|
f6f3ce5f05 | ||
|
|
4805f3d4d3 | ||
|
|
1d797cdaf5 | ||
|
|
4d8458669c | ||
|
|
92ec9653e5 | ||
|
|
e861d98007 | ||
|
|
a97eeb1fd9 | ||
|
|
cd88b23b5d | ||
|
|
33eabf937b | ||
|
|
beb5df16a3 | ||
|
|
7fa743f01a | ||
|
|
1f6859d78f | ||
|
|
2853735472 | ||
|
|
04d28f9d2d | ||
|
|
3bc6e89b74 |
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
3
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,6 +1,6 @@
|
||||
<!--
|
||||
Thanks for your contribution! Please write this PR in English.
|
||||
【中文开发者】请使用英文填写,感谢 ❤️
|
||||
推荐使用英文填写,感谢 ❤️
|
||||
-->
|
||||
|
||||
## What does this PR do?
|
||||
@@ -16,6 +16,7 @@ Thanks for your contribution! Please write this PR in English.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read the [Contributing Guide](https://github.com/zhayujie/CowAgent/blob/master/CONTRIBUTING.md)
|
||||
- [ ] I tested this change locally
|
||||
- [ ] Code comments and docs are in English
|
||||
- [ ] Linked related issue (if any): closes #
|
||||
|
||||
274
.github/workflows/release.yml
vendored
Normal file
274
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,274 @@
|
||||
name: Release Desktop
|
||||
|
||||
# Tag-driven release: push a tag like `v1.2.0` to build and publish the
|
||||
# desktop client for macOS (arm64 + x64) and Windows (x64). The tag is the
|
||||
# single source of truth for the version — it's written into package.json at
|
||||
# build time, so the maintainer never edits the version by hand.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
# Manual trigger for testing the full pipeline without cutting a real tag.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to stamp (e.g. 1.0.0-test). Used for package.json and R2 path."
|
||||
type: string
|
||||
default: "0.0.0-dev"
|
||||
publish_r2:
|
||||
description: "Upload installers to R2 + register in D1 (needs Cloudflare secrets)"
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
# Don't cancel the other platforms if one fails — we want to see all
|
||||
# failures in a single run.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS arm64
|
||||
os: macos-14
|
||||
platform: mac
|
||||
arch: arm64
|
||||
eb_flags: --mac --arm64
|
||||
- name: macOS x64
|
||||
os: macos-15-intel
|
||||
platform: mac
|
||||
arch: x64
|
||||
eb_flags: --mac --x64
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
platform: win
|
||||
arch: x64
|
||||
eb_flags: --win --x64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Derive version
|
||||
# Tag push: strip the leading "v" from GITHUB_REF_NAME (e.g. v1.2.0).
|
||||
# Manual dispatch: use the provided version input.
|
||||
id: ver
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
ref="${GITHUB_REF_NAME:-}"
|
||||
echo "version=${ref#v}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Build Python backend (PyInstaller)
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r desktop/build/requirements-desktop.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 "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build & publish (electron-builder)
|
||||
working-directory: desktop
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Signing secrets are passed through as-is; we only export them to the
|
||||
# environment below when non-empty. An empty CSC_LINK would make
|
||||
# electron-builder try to load a bogus certificate and fail, so unset
|
||||
# is the correct state for unsigned builds.
|
||||
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||
run: |
|
||||
npm run build
|
||||
|
||||
# Only export signing vars when provided. Empty strings are NOT the
|
||||
# same as unset to electron-builder: an empty CSC_LINK is treated as
|
||||
# a (broken) certificate path and aborts the build. Leaving them
|
||||
# unset makes electron-builder fall back to an unsigned build.
|
||||
if [ -n "$MAC_CSC_LINK" ]; then
|
||||
export CSC_LINK="$MAC_CSC_LINK"
|
||||
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
||||
fi
|
||||
if [ -z "$WIN_CSC_LINK" ]; then
|
||||
unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD
|
||||
fi
|
||||
|
||||
# Publish to the GitHub Release on tag pushes; otherwise build only.
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
PUBLISH=always
|
||||
else
|
||||
PUBLISH=never
|
||||
fi
|
||||
npx electron-builder ${{ matrix.eb_flags }} --publish "$PUBLISH"
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
# One bundle per platform/arch so the publish job can collect them all.
|
||||
name: cowagent-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: |
|
||||
desktop/release/*.dmg
|
||||
desktop/release/*.exe
|
||||
desktop/release/*.yml
|
||||
desktop/release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
# Mirror the release installers to R2 (CDN-backed) and register them in D1 so
|
||||
# cowagent.ai/download/{platform}/latest can resolve and count downloads.
|
||||
# Runs only on tag pushes, and is a no-op (skips) until the Cloudflare secrets
|
||||
# are configured, so it never blocks unsigned/dry builds.
|
||||
publish-r2:
|
||||
name: Publish to R2 + D1
|
||||
# Require every platform in the build matrix to succeed before publishing,
|
||||
# so a release on R2/D1 is always complete (all installers present) rather
|
||||
# than partial. needs: build already gates on all matrix jobs succeeding.
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
# Run on a tag push, or on a manual dispatch when publish_r2 is checked.
|
||||
if: >-
|
||||
(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) ||
|
||||
(github.event_name == 'workflow_dispatch' && github.event.inputs.publish_r2 == 'true')
|
||||
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."
|
||||
fi
|
||||
|
||||
- name: Derive version
|
||||
if: steps.guard.outputs.enabled == 'true'
|
||||
id: ver
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Download all build artifacts
|
||||
if: steps.guard.outputs.enabled == 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Stage installers
|
||||
if: steps.guard.outputs.enabled == 'true'
|
||||
id: stage
|
||||
run: |
|
||||
mkdir -p dist
|
||||
# Flatten installers from every per-platform artifact dir; only the
|
||||
# user-facing installers go to R2 (updater .yml/.blockmap stay on the
|
||||
# GitHub Release, which electron-updater reads directly).
|
||||
find artifacts -type f \( -name '*.dmg' -o -name '*.exe' \) -exec cp {} dist/ \;
|
||||
echo "Staged files:"; ls -la dist
|
||||
# When the whole matrix failed there's nothing to publish; flag it so
|
||||
# the R2/D1 steps skip instead of writing an empty/partial release.
|
||||
if [ -n "$(ls -A dist 2>/dev/null)" ]; then
|
||||
echo "has_files=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_files=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::No installers found in any artifact — skipping R2/D1 publish."
|
||||
fi
|
||||
|
||||
- name: Upload installers to R2
|
||||
if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
VER: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
# Reuse the existing cow-skills bucket under a desktop/ prefix; this
|
||||
# is served by the cdn.cowagent.ai custom domain.
|
||||
for f in dist/*; do
|
||||
base="$(basename "$f")"
|
||||
key="desktop/v${VER}/${base}"
|
||||
echo "==> Uploading $base -> r2://cow-skills/$key"
|
||||
npx --yes wrangler@latest r2 object put "cow-skills/$key" \
|
||||
--file "$f" --remote
|
||||
done
|
||||
|
||||
- name: Register release rows in D1
|
||||
if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true'
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
VER: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
# Map each installer filename to a platform id. dmg arch is in the
|
||||
# name (…-arm64.dmg / …-x64.dmg); .exe is the Windows installer.
|
||||
sql_file="$(mktemp)"
|
||||
|
||||
# Pre-releases (e.g. 1.0.0-test / -beta / -rc.1 / -alpha / -dev) are
|
||||
# recorded but NEVER marked latest, so /download/<p>/latest keeps
|
||||
# serving the last stable build. They also must not clear an existing
|
||||
# stable's latest flag. Only a final version (no pre-release suffix)
|
||||
# becomes the new latest and clears the previous one per platform.
|
||||
case "$VER" in
|
||||
*-*) is_latest=0; echo "==> $VER is a pre-release; not marking latest." ;;
|
||||
*) is_latest=1; echo "==> $VER is a stable release; marking latest." ;;
|
||||
esac
|
||||
|
||||
for f in dist/*; do
|
||||
base="$(basename "$f")"
|
||||
size="$(stat -c%s "$f")"
|
||||
case "$base" in
|
||||
*arm64.dmg) platform="mac-arm64" ;;
|
||||
*x64.dmg) platform="mac-x64" ;;
|
||||
*.exe) platform="win" ;;
|
||||
*) echo "Skipping unrecognized artifact: $base"; continue ;;
|
||||
esac
|
||||
key="v${VER}/${base}"
|
||||
# Stable only: clear the previous latest for THIS platform first, so
|
||||
# a partial backfill never wipes other platforms' latest flag.
|
||||
if [ "$is_latest" = "1" ]; then
|
||||
echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file"
|
||||
fi
|
||||
echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, ${is_latest});" >> "$sql_file"
|
||||
done
|
||||
echo "==> D1 statements:"; cat "$sql_file"
|
||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file"
|
||||
32
.github/workflows/test-windows-bash.yml
vendored
Normal file
32
.github/workflows/test-windows-bash.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Windows Bash Streaming Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- "agent/tools/bash/bash.py"
|
||||
- "tests/test_bash_streaming.py"
|
||||
- ".github/workflows/test-windows-bash.yml"
|
||||
|
||||
jobs:
|
||||
windows-bash-tests:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install pytest
|
||||
python -m pip install -r requirements.txt
|
||||
|
||||
- name: Run Windows Bash streaming tests
|
||||
run: python -m pytest tests/test_bash_streaming.py -v
|
||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -45,3 +45,16 @@ dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.cow.pid
|
||||
|
||||
# Desktop backend packaging: keep the source files (spec/requirements/script)
|
||||
# tracked even though the generic build/ rule above ignores them, but never
|
||||
# track the build outputs or local venv.
|
||||
!desktop/build/
|
||||
desktop/build/*
|
||||
!desktop/build/cowagent-backend.spec
|
||||
!desktop/build/requirements-desktop.txt
|
||||
!desktop/build/build-backend.sh
|
||||
|
||||
# Icon authoring scratch dir: intermediate assets used to produce the final
|
||||
# icons. Only the finished icons under desktop/resources/ should be committed.
|
||||
desktop/resources/.icon-work/
|
||||
|
||||
37
README.md
37
README.md
@@ -1,13 +1,21 @@
|
||||
<p align="center"><img src="https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent" alt="Latest release"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/github/license/zhayujie/CowAgent" alt="License: MIT"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square" alt="Stars"></a> <br/>
|
||||
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
||||
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
||||
<a href="https://docs.cowagent.ai/"><img src="https://img.shields.io/badge/Docs-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="Docs"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/25763" target="_blank"><img src="https://trendshift.io/api/badge/repositories/25763" alt="zhayujie%2FCowAgent | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
[English] | [<a href="docs/zh/README.md">中文</a>] | [<a href="docs/ja/README.md">日本語</a>]
|
||||
</p>
|
||||
|
||||
**CowAgent** is an open-source super AI assistant that proactively plans tasks, controls your computer and external services, creates and runs Skills, and grows alongside you through a personal knowledge base and long-term memory — a reference implementation of Agent Harness engineering.
|
||||
**CowAgent** is an open-source super AI assistant that proactively plans tasks, controls your computer and external services, creates and runs Skills, builds a personal knowledge base and long-term memory, and grows alongside you through self-evolution — a reference implementation of Agent Harness engineering.
|
||||
|
||||
CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major LLM provider and run it 24/7 on a personal computer or server, across the web and all major IM platforms.
|
||||
|
||||
@@ -28,6 +36,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
|
||||
| [Planning](https://docs.cowagent.ai/intro/architecture) | Decomposes complex tasks and executes them step by step, looping over tools until the goal is reached |
|
||||
| [Memory](https://docs.cowagent.ai/memory/index) | Three-tier architecture (context → daily → core), automatic Deep Dream distillation, hybrid keyword + vector retrieval |
|
||||
| [Knowledge](https://docs.cowagent.ai/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing |
|
||||
| [Evolution](https://docs.cowagent.ai/memory/self-evolution) | Self-Evolution reviews conversations automatically to improve skills, follow up on unfinished tasks, and consolidate memory and knowledge, growing through everyday use |
|
||||
| [Skills](https://docs.cowagent.ai/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation |
|
||||
| [Tools](https://docs.cowagent.ai/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration |
|
||||
| [Channels](https://docs.cowagent.ai/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, Telegram, and Slack |
|
||||
@@ -39,7 +48,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.jpg" alt="CowAgent Architecture" width="750"/>
|
||||
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/en/architecture.png" alt="CowAgent Architecture" width="750"/>
|
||||
|
||||
CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible.
|
||||
|
||||
@@ -94,15 +103,15 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
||||
|
||||
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-opus-4-8 | ✅ | ✅ | | | | |
|
||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-max | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M2.7 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
||||
| [LinkAI](https://docs.cowagent.ai/models/linkai) | One key for 100+ models | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
@@ -190,6 +199,10 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
|
||||
|
||||
## 🏷 Changelog
|
||||
|
||||
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements.
|
||||
|
||||
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — Self-Evolution, Web console upgrades (message management, parallel sessions), cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus), Python 3.13 support.
|
||||
|
||||
> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — Internationalization, new channels (Telegram, Discord, Slack, WeChat Customer Service), CLI interaction upgrades, streamlined one-line install, MCP Streamable HTTP support, new models (claude-opus-4-8, MiMo).
|
||||
|
||||
> **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — Model management, MCP protocol support, persistent browser sessions, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max), deployment hardening.
|
||||
@@ -238,9 +251,9 @@ For enterprise inquiries: sales@simple-future.tech or [scan the QR code](https:/
|
||||
|
||||
## 🛠️ Development & Contributing
|
||||
|
||||
Contributions are welcome — add a new channel by following the [Feishu channel reference](https://github.com/zhayujie/CowAgent/blob/master/channel/feishu/feishu_channel.py), or contribute new skills to [Skill Hub](https://skills.cowagent.ai/submit).
|
||||
All kinds of contributions are welcome — new features, bug fixes, performance improvements, docs, or sharing your own skills on the [Skill Hub](https://skills.cowagent.ai/submit). See [CONTRIBUTING.md](/CONTRIBUTING.md) to get started, then open an Issue to discuss or send a PR directly.
|
||||
|
||||
⭐ Star the project to follow updates, and feel free to open PRs and Issues.
|
||||
⭐ Star the project to show your support, and Watch → Custom → Releases to get notified of new versions. PRs and Issues are always welcome.
|
||||
|
||||
## 🌟 Contributors
|
||||
|
||||
|
||||
@@ -49,6 +49,16 @@ class ChatService:
|
||||
agent.model.channel_type = channel_type or ""
|
||||
agent.model.session_id = session_id or ""
|
||||
|
||||
# Build a context so context-aware tools (e.g. scheduler) can resolve the
|
||||
# receiver/session. This streaming path bypasses agent_bridge.agent_reply,
|
||||
# so the attach step that normally happens there must be done here too.
|
||||
context = self._build_context(query, session_id, channel_type)
|
||||
self._attach_context_aware_tools(agent, context)
|
||||
|
||||
# Mark this session as mid-run so the self-evolution idle scan does not
|
||||
# fire concurrently when a single turn runs longer than idle_minutes.
|
||||
self._mark_run_active(agent, True)
|
||||
|
||||
# State shared between the event callback and this method
|
||||
state = _StreamState()
|
||||
|
||||
@@ -171,6 +181,12 @@ class ChatService:
|
||||
|
||||
from agent.protocol.agent_stream import AgentStreamExecutor
|
||||
|
||||
# Register a cancel token so /cancel can abort this in-flight run.
|
||||
# IM channels key on session_id (no per-turn request_id here).
|
||||
from agent.protocol import get_cancel_registry
|
||||
registry = get_cancel_registry()
|
||||
cancel_event = registry.register(session_id, session_id=session_id) if session_id else None
|
||||
|
||||
executor = AgentStreamExecutor(
|
||||
agent=agent,
|
||||
model=agent.model,
|
||||
@@ -180,6 +196,7 @@ class ChatService:
|
||||
on_event=on_event,
|
||||
messages=messages_copy,
|
||||
max_context_turns=max_context_turns,
|
||||
cancel_event=cancel_event,
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -191,6 +208,15 @@ class ChatService:
|
||||
agent.messages.clear()
|
||||
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
||||
raise
|
||||
finally:
|
||||
# Clear the mid-run flag so idle scans can review this session again.
|
||||
self._mark_run_active(agent, False)
|
||||
# Release cancel token to keep the registry bounded.
|
||||
if session_id:
|
||||
try:
|
||||
registry.unregister(session_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sync executor messages back to agent (thread-safe).
|
||||
# The executor may have trimmed context, making its list shorter than
|
||||
@@ -254,10 +280,68 @@ class ChatService:
|
||||
# Execute post-process tools
|
||||
agent._execute_post_process_tools()
|
||||
|
||||
# Record this user turn for the self-evolution idle trigger. This
|
||||
# streaming path bypasses agent_bridge.agent_reply, so the activity must
|
||||
# be noted here, otherwise idle scans never see any signal to evolve.
|
||||
self._note_evolution_turn(agent, context)
|
||||
|
||||
logger.info(f"[ChatService] Agent run completed: session={session_id}")
|
||||
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _build_context(query: str, session_id: str, channel_type: str):
|
||||
"""Build a Context for tool resolution on the streaming chat path.
|
||||
|
||||
receiver falls back to session_id; the scheduler's delivery keys on
|
||||
session_id as the receiver.
|
||||
"""
|
||||
from bridge.context import Context, ContextType
|
||||
# Pass an explicit kwargs dict: Context's default kwargs is a shared
|
||||
# mutable default, so omitting it would leak fields across sessions.
|
||||
ctx = Context(ContextType.TEXT, query, kwargs={})
|
||||
ctx["session_id"] = session_id
|
||||
ctx["receiver"] = session_id
|
||||
ctx["isgroup"] = False
|
||||
ctx["channel_type"] = channel_type or ""
|
||||
return ctx
|
||||
|
||||
@staticmethod
|
||||
def _attach_context_aware_tools(agent, context):
|
||||
"""Attach the current context to tools that need it (scheduler)."""
|
||||
try:
|
||||
if not (context and getattr(agent, "tools", None)):
|
||||
return
|
||||
for tool in agent.tools:
|
||||
if tool.name == "scheduler":
|
||||
from agent.tools.scheduler.integration import attach_scheduler_to_tool
|
||||
attach_scheduler_to_tool(tool, context)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"[ChatService] Failed to attach context to scheduler: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _mark_run_active(agent, active):
|
||||
"""Toggle the self-evolution mid-run flag for this session's agent."""
|
||||
try:
|
||||
from agent.evolution.trigger import mark_run_active
|
||||
mark_run_active(agent, active)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _note_evolution_turn(agent, context):
|
||||
"""Record a user turn so the self-evolution idle trigger has signal."""
|
||||
try:
|
||||
from agent.evolution.trigger import note_user_turn
|
||||
ch = (context.get("channel_type") or "") if context else ""
|
||||
rcv = (context.get("receiver") or "") if context else ""
|
||||
is_group = bool(context.get("isgroup")) if context else False
|
||||
# Only single chats get a proactive push target; group push is noisy.
|
||||
note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else ""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""):
|
||||
try:
|
||||
|
||||
19
agent/evolution/__init__.py
Normal file
19
agent/evolution/__init__.py
Normal file
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Self-evolution subsystem for CowAgent.
|
||||
|
||||
Runs a lightweight, isolated review pass after a conversation goes idle to
|
||||
decide whether anything is worth durably learning (memory / skill) or whether
|
||||
an unfinished task can be pushed forward. Conservative by design: most
|
||||
conversations should produce no change at all.
|
||||
|
||||
Public entry points:
|
||||
from agent.evolution import get_evolution_config
|
||||
from agent.evolution.trigger import start_evolution_trigger, note_user_turn
|
||||
"""
|
||||
|
||||
from agent.evolution.config import EvolutionConfig, get_evolution_config
|
||||
|
||||
__all__ = [
|
||||
"EvolutionConfig",
|
||||
"get_evolution_config",
|
||||
]
|
||||
102
agent/evolution/backup.py
Normal file
102
agent/evolution/backup.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""File backup / rollback support for self-evolution.
|
||||
|
||||
Before the evolution agent edits MEMORY.md or a skill file, we snapshot the
|
||||
current state into ``memory/.evolution_backups/<backup_id>/`` so a later "undo"
|
||||
can restore it. File-level restore only — simple and reliable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from common.log import logger
|
||||
|
||||
_BACKUP_DIRNAME = ".evolution_backups"
|
||||
_MANIFEST_NAME = "manifest.json"
|
||||
# Keep only the most recent N backups to bound disk usage.
|
||||
_MAX_BACKUPS = 10
|
||||
|
||||
|
||||
def _backups_root(workspace_dir: Path) -> Path:
|
||||
return Path(workspace_dir) / "memory" / _BACKUP_DIRNAME
|
||||
|
||||
|
||||
def create_backup(workspace_dir: Path, files: List[Path]) -> Optional[str]:
|
||||
"""Snapshot ``files`` (those that exist) under a new backup id.
|
||||
|
||||
Returns the backup_id, or None when there is nothing to back up.
|
||||
"""
|
||||
existing = [Path(f) for f in files if Path(f).exists()]
|
||||
if not existing:
|
||||
return None
|
||||
|
||||
backup_id = datetime.now().strftime("%Y%m%d-%H%M%S-") + str(int(time.time() * 1000) % 1000)
|
||||
root = _backups_root(workspace_dir)
|
||||
target = root / backup_id
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
ws = Path(workspace_dir)
|
||||
manifest = []
|
||||
for idx, src in enumerate(existing):
|
||||
# Store under a flat index plus the relative path so restore knows
|
||||
# where it came from, even for nested skill files.
|
||||
try:
|
||||
rel = str(src.relative_to(ws))
|
||||
except ValueError:
|
||||
rel = src.name
|
||||
dst = target / f"{idx}.bak"
|
||||
shutil.copy2(src, dst)
|
||||
manifest.append({"rel": rel, "bak": f"{idx}.bak"})
|
||||
(target / _MANIFEST_NAME).write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||
)
|
||||
_prune_old_backups(root)
|
||||
# Caller logs a combined backup+review line; keep this at debug.
|
||||
logger.debug(f"[Evolution] Created backup {backup_id} ({len(manifest)} file(s))")
|
||||
return backup_id
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Failed to create backup: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def restore_backup(workspace_dir: Path, backup_id: str) -> bool:
|
||||
"""Restore all files captured under ``backup_id``. Returns success."""
|
||||
if not backup_id:
|
||||
return False
|
||||
target = _backups_root(workspace_dir) / backup_id
|
||||
manifest_path = target / _MANIFEST_NAME
|
||||
if not manifest_path.exists():
|
||||
logger.warning(f"[Evolution] Backup not found: {backup_id}")
|
||||
return False
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
ws = Path(workspace_dir)
|
||||
for entry in manifest:
|
||||
bak = target / entry["bak"]
|
||||
dst = ws / entry["rel"]
|
||||
if bak.exists():
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(bak, dst)
|
||||
logger.info(f"[Evolution] Restored backup {backup_id} ({len(manifest)} file(s))")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Failed to restore backup {backup_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _prune_old_backups(root: Path) -> None:
|
||||
"""Drop the oldest backups beyond _MAX_BACKUPS (sorted by name = chronological)."""
|
||||
try:
|
||||
dirs = sorted(
|
||||
[d for d in root.iterdir() if d.is_dir()],
|
||||
key=lambda p: p.name,
|
||||
)
|
||||
for old in dirs[:-_MAX_BACKUPS]:
|
||||
shutil.rmtree(old, ignore_errors=True)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Evolution] Backup prune skipped: {e}")
|
||||
76
agent/evolution/config.py
Normal file
76
agent/evolution/config.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Configuration for the self-evolution subsystem.
|
||||
|
||||
Reads flat ``self_evolution_*`` keys from config.json. All fields have safe
|
||||
defaults so the feature degrades gracefully when keys are absent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
# Defaults — conservative (see executor module docstring). Disabled by default
|
||||
# until release; enable via ``self_evolution_enabled``.
|
||||
DEFAULT_ENABLED = False
|
||||
DEFAULT_IDLE_MINUTES = 10
|
||||
DEFAULT_MIN_TURNS = 6
|
||||
# Max review steps for the isolated evolution agent. Kept small (not exposed as
|
||||
# config): the review is meant to be cheap and focused, not a long autonomous run.
|
||||
DEFAULT_MAX_STEPS = 12
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvolutionConfig:
|
||||
"""Resolved self-evolution settings."""
|
||||
|
||||
enabled: bool = DEFAULT_ENABLED
|
||||
idle_minutes: int = DEFAULT_IDLE_MINUTES
|
||||
min_turns: int = DEFAULT_MIN_TURNS
|
||||
max_steps: int = DEFAULT_MAX_STEPS
|
||||
|
||||
@property
|
||||
def idle_seconds(self) -> int:
|
||||
return max(60, self.idle_minutes * 60)
|
||||
|
||||
|
||||
def _as_bool(value: Any, fallback: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
v = value.strip().lower()
|
||||
if v in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if v in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return fallback
|
||||
|
||||
|
||||
def _as_pos_int(value: Any, fallback: int) -> int:
|
||||
try:
|
||||
n = int(value)
|
||||
return n if n > 0 else fallback
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
|
||||
def get_evolution_config() -> EvolutionConfig:
|
||||
"""Build EvolutionConfig from the live config.json ``self_evolution_*`` keys."""
|
||||
try:
|
||||
from config import conf
|
||||
c = conf()
|
||||
except Exception:
|
||||
c = {}
|
||||
|
||||
def _get(key, default):
|
||||
try:
|
||||
return c.get(key, default)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
return EvolutionConfig(
|
||||
enabled=_as_bool(_get("self_evolution_enabled", None), DEFAULT_ENABLED),
|
||||
idle_minutes=_as_pos_int(_get("self_evolution_idle_minutes", None), DEFAULT_IDLE_MINUTES),
|
||||
min_turns=_as_pos_int(_get("self_evolution_min_turns", None), DEFAULT_MIN_TURNS),
|
||||
max_steps=DEFAULT_MAX_STEPS,
|
||||
)
|
||||
551
agent/evolution/executor.py
Normal file
551
agent/evolution/executor.py
Normal file
@@ -0,0 +1,551 @@
|
||||
"""Self-evolution executor.
|
||||
|
||||
Runs an isolated review agent over an idle conversation's transcript and, if a
|
||||
clear signal is found, lets it edit memory / skills via a restricted toolset.
|
||||
Conservative by design: most runs return ``[SILENT]`` and change nothing.
|
||||
|
||||
Flow:
|
||||
1. Build a transcript from the session's new (since last pass) messages.
|
||||
2. Snapshot MEMORY.md + daily file + editable skills (for undo) -> backup_id.
|
||||
3. Run an isolated agent (same model, restricted tools, evolution prompt).
|
||||
4. If output is [SILENT], or no workspace file actually changed -> done.
|
||||
5. Otherwise -> record to the evolution log, inject an [EVOLUTION] note into
|
||||
the user session (so the main agent can honor "undo"), and push the
|
||||
summary to the user's channel.
|
||||
|
||||
Reuses existing infrastructure (AgentBridge.create_agent, ToolManager,
|
||||
remember_scheduled_output, channel_factory) rather than introducing a fork.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from common.log import logger
|
||||
|
||||
from agent.evolution.backup import create_backup
|
||||
from agent.evolution.config import get_evolution_config
|
||||
from agent.evolution.prompts import (
|
||||
EVOLUTION_MARKER,
|
||||
EVOLUTION_SYSTEM_PROMPT,
|
||||
SILENT_TOKEN,
|
||||
build_review_user_message,
|
||||
)
|
||||
from agent.evolution.record import append_session_evolution
|
||||
|
||||
# Tools the isolated evolution agent is allowed to use. Everything else is
|
||||
# withheld so a review pass can only read context, run workspace scripts, and
|
||||
# edit memory/skill files. bash is needed by skill-creator's init script and is
|
||||
# confined to the workspace by _BashWorkspaceGuard.
|
||||
_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "bash", "memory_search", "memory_get"}
|
||||
|
||||
# Cap concurrent evolution passes so a burst of idle sessions can't spawn many
|
||||
# background model runs at once. Extra sessions simply wait for the next scan.
|
||||
_MAX_CONCURRENT = 2
|
||||
_running_lock = threading.Lock()
|
||||
_running_count = 0
|
||||
|
||||
|
||||
def _builtin_skill_names() -> set:
|
||||
"""Names of skills shipped with the product (project-root ``skills/``).
|
||||
|
||||
These are protected: the evolution agent must never edit them, even though
|
||||
a same-named copy exists in the workspace at runtime. The project dir is the
|
||||
authoritative list of what counts as built-in.
|
||||
"""
|
||||
try:
|
||||
# executor.py -> agent/evolution -> agent -> project root
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
builtin_dir = project_root / "skills"
|
||||
if not builtin_dir.is_dir():
|
||||
return set()
|
||||
names = set()
|
||||
for entry in builtin_dir.iterdir():
|
||||
if entry.is_dir() and not entry.name.startswith("."):
|
||||
names.add(entry.name)
|
||||
return names
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _build_transcript(messages: List[dict], max_chars: int = 12000) -> str:
|
||||
"""Render the session messages into a compact text transcript."""
|
||||
lines: List[str] = []
|
||||
for msg in messages:
|
||||
role = msg.get("role", "")
|
||||
if role not in ("user", "assistant"):
|
||||
continue
|
||||
content = msg.get("content", "")
|
||||
text = _extract_text(content)
|
||||
if not text.strip():
|
||||
continue
|
||||
speaker = "User" if role == "user" else "Assistant"
|
||||
lines.append(f"{speaker}: {text.strip()}")
|
||||
transcript = "\n".join(lines)
|
||||
# Keep the most RECENT context if oversized (tail is most relevant).
|
||||
if len(transcript) > max_chars:
|
||||
transcript = "...(earlier omitted)...\n" + transcript[-max_chars:]
|
||||
return transcript
|
||||
|
||||
|
||||
def _extract_text(content) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(block.get("text", ""))
|
||||
elif isinstance(block, str):
|
||||
parts.append(block)
|
||||
return "\n".join(parts)
|
||||
return ""
|
||||
|
||||
|
||||
def _select_tools(all_tools: list) -> list:
|
||||
return [t for t in all_tools if getattr(t, "name", None) in _ALLOWED_TOOLS]
|
||||
|
||||
|
||||
# Tools whose writes must be confined to the workspace during evolution.
|
||||
_WRITE_TOOLS = {"write", "edit"}
|
||||
|
||||
|
||||
class _WorkspaceWriteGuard:
|
||||
"""Wraps a write/edit tool so it can ONLY write inside the workspace.
|
||||
|
||||
Hard engineering guard (not prompt-based): any write resolving outside the
|
||||
workspace — e.g. the project's bundled ``skills/`` dir — is rejected. This
|
||||
protects built-in skills regardless of what the model attempts.
|
||||
"""
|
||||
|
||||
def __init__(self, inner, workspace_dir: str):
|
||||
self._inner = inner
|
||||
self._ws = Path(workspace_dir).resolve()
|
||||
# Mirror the attributes the agent runtime reads off a tool.
|
||||
self.name = inner.name
|
||||
self.description = inner.description
|
||||
self.params = inner.params
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self._inner, item)
|
||||
|
||||
def execute_tool(self, params):
|
||||
# The agent runtime calls execute_tool (not execute); route it through
|
||||
# our guarded execute so the path checks always run.
|
||||
try:
|
||||
return self.execute(params)
|
||||
except Exception as e:
|
||||
logger.error(f"[Evolution] guarded tool error: {e}")
|
||||
from agent.tools.base_tool import ToolResult
|
||||
return ToolResult.fail(f"Error: {e}")
|
||||
|
||||
def execute(self, args):
|
||||
path = (args.get("path") or "").strip()
|
||||
if path:
|
||||
try:
|
||||
resolved = Path(self._inner._resolve_path(path)).resolve()
|
||||
from agent.tools.base_tool import ToolResult
|
||||
# Confine writes to the workspace. This protects the product's
|
||||
# bundled skills (which live outside the workspace) from ever
|
||||
# being modified, no matter what path the model attempts.
|
||||
if self._ws not in resolved.parents and resolved != self._ws:
|
||||
return ToolResult.fail(
|
||||
"Error: evolution may only write inside the workspace; "
|
||||
f"path '{path}' is outside and was blocked."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return self._inner.execute(args)
|
||||
|
||||
|
||||
class _BashWorkspaceGuard:
|
||||
"""Wraps the bash tool so evolution can only run commands inside the
|
||||
workspace.
|
||||
|
||||
Evolution needs bash for skill-creator's init script, but it runs
|
||||
unattended in the background, so a raw shell is too broad. This guard:
|
||||
- forces the command to execute with cwd = workspace,
|
||||
- rejects commands that reference an absolute path or ``..`` segment
|
||||
pointing OUTSIDE the workspace (the common ways to escape it).
|
||||
It is a coarse textual check, not a sandbox — paired with the model's
|
||||
instruction to only run skill-creator scripts, it keeps writes local.
|
||||
"""
|
||||
|
||||
def __init__(self, inner, workspace_dir: str):
|
||||
self._inner = inner
|
||||
self._ws = Path(workspace_dir).resolve()
|
||||
# Pin the shell's working directory to the workspace.
|
||||
try:
|
||||
self._inner.cwd = str(self._ws)
|
||||
except Exception:
|
||||
pass
|
||||
self.name = inner.name
|
||||
self.description = inner.description
|
||||
self.params = inner.params
|
||||
|
||||
def __getattr__(self, item):
|
||||
return getattr(self._inner, item)
|
||||
|
||||
def execute_tool(self, params):
|
||||
try:
|
||||
return self.execute(params)
|
||||
except Exception as e:
|
||||
logger.error(f"[Evolution] guarded bash error: {e}")
|
||||
from agent.tools.base_tool import ToolResult
|
||||
return ToolResult.fail(f"Error: {e}")
|
||||
|
||||
def _escapes_workspace(self, command: str) -> bool:
|
||||
# Absolute paths that are not under the workspace.
|
||||
for tok in re.findall(r'(?:^|\s)(/[^\s\'";|&]+)', command):
|
||||
try:
|
||||
resolved = Path(tok).resolve()
|
||||
except Exception:
|
||||
continue
|
||||
if self._ws != resolved and self._ws not in resolved.parents:
|
||||
return True
|
||||
# Parent-dir traversal that climbs above the workspace.
|
||||
for tok in re.findall(r'[^\s\'";|&]*\.\.[^\s\'";|&]*', command):
|
||||
try:
|
||||
resolved = (self._ws / tok).resolve()
|
||||
except Exception:
|
||||
continue
|
||||
if self._ws != resolved and self._ws not in resolved.parents:
|
||||
return True
|
||||
return False
|
||||
|
||||
def execute(self, args):
|
||||
from agent.tools.base_tool import ToolResult
|
||||
command = (args.get("command") or "").strip()
|
||||
if command and self._escapes_workspace(command):
|
||||
return ToolResult.fail(
|
||||
"Error: evolution may only run commands inside the workspace; "
|
||||
"this command references a path outside it and was blocked."
|
||||
)
|
||||
return self._inner.execute(args)
|
||||
|
||||
|
||||
def _guard_tools(tools: list, workspace_dir: str) -> list:
|
||||
"""Wrap write/edit/bash tools with workspace guards; leave others as-is."""
|
||||
guarded = []
|
||||
for t in tools:
|
||||
name = getattr(t, "name", None)
|
||||
if name in _WRITE_TOOLS:
|
||||
guarded.append(_WorkspaceWriteGuard(t, workspace_dir))
|
||||
elif name == "bash":
|
||||
guarded.append(_BashWorkspaceGuard(t, workspace_dir))
|
||||
else:
|
||||
guarded.append(t)
|
||||
return guarded
|
||||
|
||||
|
||||
# Workspace subtrees worth watching for evolution-induced changes. AGENT.md is
|
||||
# watched too: evolution may rarely refine the assistant's persona/style there.
|
||||
_WATCH_SUBDIRS = ("MEMORY.md", "AGENT.md", "skills", "knowledge", "output")
|
||||
# Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly
|
||||
# dream diary, none of which count as a user-facing change signal.
|
||||
_MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution")
|
||||
# Files the skill subsystem maintains automatically (the enable/disable index).
|
||||
# Not an evolution result, so a rewrite must not count as a change signal.
|
||||
_WATCH_IGNORE_NAMES = ("skills_config.json",)
|
||||
|
||||
|
||||
def _workspace_snapshot(workspace_dir) -> dict:
|
||||
"""Map relative path -> (mtime, size) for watched files. Cheap, no reads."""
|
||||
ws = Path(workspace_dir)
|
||||
snap: dict = {}
|
||||
for name in _WATCH_SUBDIRS:
|
||||
root = ws / name
|
||||
if root.is_file():
|
||||
try:
|
||||
st = root.stat()
|
||||
snap[name] = (st.st_mtime, st.st_size)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for p in root.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
if p.name in _WATCH_IGNORE_NAMES:
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Watch the daily memory files (memory/*.md and per-user dailies) since
|
||||
# evolution now records learnings there. Skip backups/dreams bookkeeping.
|
||||
mem_dir = ws / "memory"
|
||||
if mem_dir.is_dir():
|
||||
for p in mem_dir.rglob("*.md"):
|
||||
rel_parts = p.relative_to(mem_dir).parts
|
||||
if rel_parts and rel_parts[0] in _MEMORY_IGNORE:
|
||||
continue
|
||||
try:
|
||||
st = p.stat()
|
||||
snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size)
|
||||
except OSError:
|
||||
pass
|
||||
return snap
|
||||
|
||||
|
||||
def _workspace_changed(workspace_dir, pre: dict) -> bool:
|
||||
"""True if any watched file was added, removed, or modified since ``pre``."""
|
||||
return _workspace_snapshot(workspace_dir) != pre
|
||||
|
||||
|
||||
def run_evolution_for_session(
|
||||
agent_bridge,
|
||||
session_id: str,
|
||||
channel_type: str = "",
|
||||
receiver: str = "",
|
||||
user_id: Optional[str] = None,
|
||||
idle_minutes: float = 0.0,
|
||||
) -> bool:
|
||||
"""Run one evolution pass for a session. Returns True if it changed anything.
|
||||
|
||||
Safe to call from a background thread. All failures are swallowed and
|
||||
logged — evolution must never disrupt the main pipeline.
|
||||
"""
|
||||
cfg = get_evolution_config()
|
||||
if not cfg.enabled:
|
||||
return False
|
||||
|
||||
# Concurrency gate: bound how many evolution passes run at once.
|
||||
global _running_count
|
||||
with _running_lock:
|
||||
if _running_count >= _MAX_CONCURRENT:
|
||||
logger.info(
|
||||
f"[Evolution] busy ({_running_count}/{_MAX_CONCURRENT} running); "
|
||||
f"skipping session={session_id} this scan"
|
||||
)
|
||||
return False
|
||||
_running_count += 1
|
||||
|
||||
try:
|
||||
agent = agent_bridge.agents.get(session_id) or agent_bridge.default_agent
|
||||
if not agent:
|
||||
return False
|
||||
|
||||
with agent.messages_lock:
|
||||
all_messages = list(agent.messages)
|
||||
total_msgs = len(all_messages)
|
||||
# In-memory evolution cursor: only review messages added since the last
|
||||
# pass so a long session doesn't re-judge (and re-write) old content.
|
||||
# Stored on the agent instance; lost on restart (acceptable — at worst
|
||||
# one redundant pass right after a restart, gated by the file-change
|
||||
# check downstream so it won't double-write identical memory).
|
||||
done = int(getattr(agent, "_evo_done_msg_count", 0))
|
||||
if done > total_msgs:
|
||||
done = 0 # history was trimmed/reset; start fresh
|
||||
new_messages = all_messages[done:]
|
||||
transcript = _build_transcript(new_messages)
|
||||
if not transcript.strip():
|
||||
# Routine no-op: the per-minute scan hits every idle session. Advance
|
||||
# the cursor so we don't re-scan the same tail; no log (pure noise).
|
||||
agent._evo_done_msg_count = total_msgs
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"[Evolution] ▶ Reviewing session={session_id} "
|
||||
f"(idle {idle_minutes:.1f}min, {len(new_messages)} new/{total_msgs} msgs, "
|
||||
f"~{len(transcript)} chars)"
|
||||
)
|
||||
|
||||
# Resolve workspace + files to snapshot for undo.
|
||||
from agent.memory.config import get_default_memory_config
|
||||
mem_cfg = get_default_memory_config()
|
||||
workspace_dir = mem_cfg.get_workspace()
|
||||
if user_id:
|
||||
memory_file = Path(workspace_dir) / "memory" / "users" / user_id / "MEMORY.md"
|
||||
else:
|
||||
memory_file = Path(workspace_dir) / "MEMORY.md"
|
||||
skills_dir = mem_cfg.get_skills_dir()
|
||||
|
||||
# Snapshot MEMORY.md + every NON-protected skill's SKILL.md. Protected
|
||||
# built-in skills are excluded from backup because they must never be
|
||||
# edited in the first place.
|
||||
protected_names = _builtin_skill_names()
|
||||
# Back up both MEMORY.md and today's daily file: evolution now writes to
|
||||
# the daily file, but MEMORY.md is cheap to snapshot and keeps undo safe
|
||||
# if the model ever edits it.
|
||||
today_daily = Path(workspace_dir) / "memory" / (
|
||||
datetime.now().strftime("%Y-%m-%d") + ".md"
|
||||
)
|
||||
if user_id:
|
||||
today_daily = Path(workspace_dir) / "memory" / "users" / user_id / (
|
||||
datetime.now().strftime("%Y-%m-%d") + ".md"
|
||||
)
|
||||
# AGENT.md (persona) is backed up too so a rare persona edit is undoable.
|
||||
# Persona is workspace-global (not per-user): it always lives at the
|
||||
# workspace root, regardless of user_id.
|
||||
agent_file = Path(workspace_dir) / "AGENT.md"
|
||||
backup_files = [Path(memory_file), today_daily, agent_file]
|
||||
if skills_dir.exists():
|
||||
for skill_md in skills_dir.rglob("SKILL.md"):
|
||||
# The skill dir is the SKILL.md's parent (or an ancestor for
|
||||
# collections); guard by checking the immediate top-level dir.
|
||||
try:
|
||||
top = skill_md.relative_to(skills_dir).parts[0]
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
if top in protected_names:
|
||||
continue
|
||||
backup_files.append(skill_md)
|
||||
backup_id = create_backup(workspace_dir, backup_files)
|
||||
_backup_n = sum(1 for f in backup_files if Path(f).exists())
|
||||
|
||||
# Snapshot the whole workspace (path -> mtime/size) so we can reliably
|
||||
# detect ANY file change — including new output files written when
|
||||
# finishing an unfinished task, which are not in backup_files.
|
||||
pre_snapshot = _workspace_snapshot(workspace_dir)
|
||||
|
||||
# Build the isolated review agent: same model, restricted tools, with a
|
||||
# hard guard that confines all writes to the workspace (protects the
|
||||
# project's bundled skills from ever being modified).
|
||||
review_tools = _guard_tools(
|
||||
_select_tools(list(getattr(agent, "tools", []) or [])),
|
||||
str(workspace_dir),
|
||||
)
|
||||
review_agent = agent_bridge.create_agent(
|
||||
system_prompt="",
|
||||
tools=review_tools,
|
||||
description="Self-evolution review agent",
|
||||
max_steps=cfg.max_steps,
|
||||
workspace_dir=str(workspace_dir),
|
||||
skill_manager=getattr(agent, "skill_manager", None),
|
||||
memory_manager=getattr(agent, "memory_manager", None),
|
||||
enable_skills=True,
|
||||
runtime_info=getattr(agent, "runtime_info", None),
|
||||
)
|
||||
# Reuse the live model so it follows the user's configured model.
|
||||
review_agent.model = agent.model
|
||||
# Inject the evolution task brief AFTER the full system prompt: the agent
|
||||
# gets the full context (tools, workspace, user preferences, memory, time)
|
||||
# AND its evolution-specific instructions on top, instead of one
|
||||
# overwriting the other.
|
||||
review_agent.extra_system_suffix = EVOLUTION_SYSTEM_PROMPT
|
||||
|
||||
logger.info(
|
||||
f"[Evolution] backup {backup_id} ({_backup_n} files) → running review agent"
|
||||
)
|
||||
user_msg = build_review_user_message(transcript, protected_skills=list(protected_names))
|
||||
result = review_agent.run_stream(user_msg, clear_history=True)
|
||||
result = (result or "").strip()
|
||||
|
||||
# These messages are now reviewed; advance the cursor so the next pass
|
||||
# only looks at messages added after this point (silent or not).
|
||||
agent._evo_done_msg_count = total_msgs
|
||||
|
||||
# Respect an explicit silent verdict: empty, exactly [SILENT], or text
|
||||
# that STARTS with [SILENT] means the model chose to stay quiet.
|
||||
if not result or result.startswith(SILENT_TOKEN):
|
||||
logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])")
|
||||
return False
|
||||
|
||||
# Anti-nag backstop: if the model wrote a summary but actually changed no
|
||||
# watched file, stay silent — never notify about work that didn't happen.
|
||||
if not _workspace_changed(workspace_dir, pre_snapshot):
|
||||
logger.info(
|
||||
f"[Evolution] ✗ session={session_id}: text produced but no file "
|
||||
f"changed — staying silent"
|
||||
)
|
||||
return False
|
||||
|
||||
# The model produced a real summary. Strip any stray [SILENT] tokens it
|
||||
# left mid-text, then notify.
|
||||
result = result.replace(SILENT_TOKEN, "").strip()
|
||||
if not result:
|
||||
logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])")
|
||||
return False
|
||||
|
||||
logger.info(f"[Evolution] ✓ session={session_id} evolved:\n{result}")
|
||||
append_session_evolution(workspace_dir, result, backup_id=backup_id, user_id=user_id)
|
||||
# Inject an [EVOLUTION] note so the main agent can honor "undo".
|
||||
_inject_evolution_record(agent_bridge, session_id, channel_type, result, backup_id)
|
||||
# The injection appended its own messages ([SCHEDULED]/[EVOLUTION]).
|
||||
# Advance the cursor past them so the next scan does not treat
|
||||
# evolution's own bookkeeping as new user content and re-trigger.
|
||||
try:
|
||||
with agent.messages_lock:
|
||||
agent._evo_done_msg_count = len(agent.messages)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Push the summary to the user's channel. The "did a file actually
|
||||
# change" gate above is the only throttle we need: real evolutions are
|
||||
# rare, so no extra opt-in switch or daily-count limit is required.
|
||||
if channel_type and receiver:
|
||||
_notify_user(channel_type, receiver, result)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Run failed for session={session_id}: {e}")
|
||||
return False
|
||||
finally:
|
||||
with _running_lock:
|
||||
_running_count -= 1
|
||||
|
||||
|
||||
def _inject_evolution_record(
|
||||
agent_bridge, session_id: str, channel_type: str, summary: str, backup_id: Optional[str]
|
||||
) -> None:
|
||||
"""Add an [EVOLUTION] note to the user session so the main agent can undo."""
|
||||
try:
|
||||
note = f"{EVOLUTION_MARKER} {summary}"
|
||||
if backup_id:
|
||||
note += f"\n(backup_id: {backup_id}; to undo, restore this backup)"
|
||||
# Reuse the scheduler-output injection path: isolated execution, only a
|
||||
# compact record lands in the user session.
|
||||
agent_bridge.remember_scheduled_output(
|
||||
session_id=session_id,
|
||||
content=note,
|
||||
channel_type=channel_type,
|
||||
task_description="self-evolution",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[Evolution] Failed to inject evolution record: {e}")
|
||||
|
||||
|
||||
def _notify_user(channel_type: str, receiver: str, summary: str) -> None:
|
||||
"""Push the evolution summary to the user's channel as a new message."""
|
||||
try:
|
||||
from bridge.context import Context, ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from channel.channel_factory import create_channel
|
||||
|
||||
context = Context(ContextType.TEXT, summary)
|
||||
context["receiver"] = receiver
|
||||
context["isgroup"] = False
|
||||
context["session_id"] = receiver
|
||||
# Channels that reply to an original message need msg=None for a fresh push.
|
||||
if channel_type in ("feishu", "dingtalk", "wecom_bot", "qq"):
|
||||
context["msg"] = None
|
||||
if channel_type == "feishu":
|
||||
context["receive_id_type"] = "open_id"
|
||||
|
||||
channel = create_channel(channel_type)
|
||||
if not channel:
|
||||
return
|
||||
|
||||
# Web is request-response: a background push needs a synthetic request_id
|
||||
# plus a request->session mapping so the channel can route the message to
|
||||
# the user's polling queue (same approach the scheduler uses).
|
||||
if channel_type == "web":
|
||||
import uuid
|
||||
request_id = f"evolution_{uuid.uuid4().hex[:8]}"
|
||||
context["request_id"] = request_id
|
||||
if hasattr(channel, "request_to_session"):
|
||||
channel.request_to_session[request_id] = receiver
|
||||
|
||||
channel.send(Reply(ReplyType.TEXT, summary), context)
|
||||
logger.info(f"[Evolution] Notified user via {channel_type}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Failed to notify user: {e}")
|
||||
170
agent/evolution/prompts.py
Normal file
170
agent/evolution/prompts.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""Prompts for the self-evolution review agent.
|
||||
|
||||
The system prompt is intentionally English-only: it governs the agent's
|
||||
internal reasoning and is more stable / cheaper to maintain in one language.
|
||||
The user-facing summary the agent produces should follow the user's own
|
||||
language (instructed at the end of the prompt).
|
||||
|
||||
Design goals (see ref/hermes-agent background_review for inspiration):
|
||||
- Default to doing NOTHING. Evolution is the exception, not the rule.
|
||||
- Signal types: skill, unfinished task, memory, knowledge.
|
||||
- An explicit "do NOT capture" list to avoid self-poisoning over time.
|
||||
- Generic examples only — never bake in domain-specific business terms.
|
||||
"""
|
||||
|
||||
# Sentinel the agent emits when there is nothing worth evolving.
|
||||
SILENT_TOKEN = "[SILENT]"
|
||||
|
||||
# Marker prefix for the evolution record injected into the user session, so the
|
||||
# main chat agent can recognize past evolutions and honor an "undo" request.
|
||||
EVOLUTION_MARKER = "[EVOLUTION]"
|
||||
|
||||
|
||||
EVOLUTION_SYSTEM_PROMPT = """You are a self-evolution review agent for an AI assistant.
|
||||
|
||||
You are given a transcript of a conversation that just went idle. Your job is to
|
||||
decide whether anything from it is worth durably learning so future
|
||||
conversations go better — and if so, to make that change.
|
||||
|
||||
# Top principle: default to doing NOTHING
|
||||
|
||||
Most ordinary conversations need no evolution. Only act when there is a CLEAR
|
||||
signal below. If there is none, reply with exactly `[SILENT]` and stop. Staying
|
||||
silent is the normal, correct outcome — not a failure.
|
||||
|
||||
Greetings, small talk, acknowledgements ("ok", "thanks", "got it"), and casual
|
||||
chat are NOT signals. For these, output exactly `[SILENT]` immediately — do not
|
||||
explore files, do not write a summary, do not be polite. Just `[SILENT]`.
|
||||
|
||||
IMPORTANT: A summary is only allowed if you ACTUALLY made a file change via a
|
||||
tool (write/edit) in this pass. If you did not change any file, you MUST output
|
||||
exactly `[SILENT]` — never describe a change you only intended to make.
|
||||
|
||||
# Signals worth acting on (act only if at least one clearly appears)
|
||||
|
||||
SKILL and UNFINISHED TASK are your PRIMARY value — no other mechanism handles
|
||||
them. When their signal is clear, act; do not be shy here.
|
||||
|
||||
1. SKILL — two cases:
|
||||
a) PATCH an existing skill: a skill used here showed a STRUCTURAL problem (a
|
||||
missing step/section, a wrong or outdated detail, an error in its
|
||||
content), or its OUTPUT repeatedly misses something the user flagged. Read
|
||||
the relevant skill file under the skills directory and make a small
|
||||
incremental edit so it never recurs.
|
||||
b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that
|
||||
no existing skill covers and the user is likely to want again. Follow the
|
||||
`skill-creator` skill's conventions (read its SKILL.md for the required
|
||||
structure), then create `skills/<name>/SKILL.md` by WRITING the file
|
||||
directly with the write tool — this is the simplest reliable path. (bash
|
||||
is available and confined to the workspace if a helper script is truly
|
||||
needed, but a direct write is preferred.) Only create when the workflow is
|
||||
genuinely reusable — not for a one-off task.
|
||||
|
||||
CRITICAL — fix the SOURCE, do not just remember the symptom: when the root
|
||||
cause of a problem lives IN a skill file itself (its instructions, content,
|
||||
or configuration are wrong/outdated), the correct action is to EDIT that
|
||||
skill so the problem cannot recur. Recording the corrected fact in memory
|
||||
does NOT prevent recurrence — only fixing the skill does. Never log "skill X
|
||||
has wrong detail Y" as a memory note in place of editing skill X.
|
||||
|
||||
2. UNFINISHED TASK — a specific deliverable you promised but didn't produce,
|
||||
AND you already have everything needed to finish it. DO IT now with the
|
||||
available tools and produce the result (e.g. write the file you said you'd
|
||||
write). If key info is missing, or the task is merely waiting on the user's
|
||||
reply/decision, do NOTHING and stay [SILENT] — do not nag or ping the user.
|
||||
You only ever notify the user as a side effect of having actually done work.
|
||||
|
||||
3. MEMORY — RARE, last resort. Default to writing NOTHING here. The main
|
||||
assistant already writes memory during the chat, and a nightly pass plus
|
||||
context-overflow saves are dedicated safety nets — so memory is almost always
|
||||
already covered without you. Skip unless the main assistant clearly missed a
|
||||
durable fact that belongs in no skill AND would visibly change future replies.
|
||||
- MEMORY.md is the curated long-term index, auto-loaded into EVERY future
|
||||
conversation. Treat it as precious: edit it in place to CORRECT a wrong
|
||||
fact, or append a new durable preference/decision/lesson — but do so
|
||||
SPARINGLY (a lasting fact, not a passing detail; the nightly pass handles
|
||||
routine consolidation).
|
||||
- For a NEW fact that is important but not yet clearly lasting, append ONE
|
||||
short bullet to today's `memory/YYYY-MM-DD.md` instead. When unsure, the
|
||||
daily file is the safe place — but first ask whether this really belongs
|
||||
in a skill.
|
||||
- PERSONA (AGENT.md) — EXTREMELY rare: only on an explicit, repeated signal
|
||||
about the assistant's own identity/personality/style, make a small edit to
|
||||
AGENT.md; never for user/world facts, and when in doubt do nothing.
|
||||
- Keep it to ONE short bullet. Never write paragraphs, never re-summarize the
|
||||
conversation, never copy what the main assistant already recorded.
|
||||
- If it is already captured anywhere (check MEMORY.md AND the daily file
|
||||
first), do NOTHING.
|
||||
|
||||
4. KNOWLEDGE — only if the conversation produced durable, reusable reference
|
||||
knowledge on a topic (the kind worth looking up again) that the main
|
||||
assistant did NOT already save to `knowledge/`. Add or update the relevant
|
||||
file there. Like memory, this is the exception: skip routine Q&A, and if the
|
||||
topic is already covered in `knowledge/`, do NOTHING rather than duplicate.
|
||||
|
||||
# Do NOT capture (these poison future behavior)
|
||||
|
||||
- Environment failures: missing binaries, unset credentials, uninstalled
|
||||
packages, "command not found". The user can fix these; they are not durable
|
||||
rules.
|
||||
- Negative claims about tools or features ("tool X does not work"). These
|
||||
harden into refusals the agent cites against itself later.
|
||||
- One-off task narratives (e.g. summarizing today's content). Not a class of
|
||||
reusable work.
|
||||
- Transient errors that resolved on retry within the conversation.
|
||||
|
||||
# Execution constraints
|
||||
|
||||
- Before changing memory or a skill, READ the current content first and make a
|
||||
small INCREMENTAL edit. Never fabricate, never rewrite large sections.
|
||||
- AVOID DUPLICATES. Before writing memory, READ both MEMORY.md AND today's
|
||||
daily file `memory/YYYY-MM-DD.md`. If the fact/preference is already recorded
|
||||
in EITHER (even if worded differently), do NOT add it again. The main
|
||||
assistant likely already wrote it during the chat — only add what is
|
||||
genuinely new or a correction not yet reflected anywhere.
|
||||
- You may only edit files inside the workspace. Built-in skills shipped with
|
||||
the product live outside it and are write-protected; do not try to edit them.
|
||||
- Make at most the few edits the signals justify; do not go looking for work.
|
||||
|
||||
# Output
|
||||
|
||||
- Nothing worth evolving -> output exactly `[SILENT]` and nothing else.
|
||||
- Otherwise, after performing the edits, output a short user-facing summary in
|
||||
the SAME LANGUAGE the user speaks in the conversation transcript. Write it for an ordinary user, in plain
|
||||
everyday words — NOT a developer report. No need to expose internal details
|
||||
(file names/paths, system mechanics, etc.). Briefly speak directly TO the user, telling them that you just did a self-learning pass,
|
||||
what you learned, and what you changed in THIS pass. Keep it clear and focused on the key changes (a few lines), and let
|
||||
the user know they can undo it.
|
||||
"""
|
||||
|
||||
|
||||
def build_review_user_message(transcript: str, protected_skills: list = None) -> str:
|
||||
"""Wrap the conversation transcript as the review agent's user message.
|
||||
|
||||
``protected_skills`` lists skill names that must never be edited (built-in
|
||||
skills shipped with the product). Surfaced so the agent avoids them.
|
||||
"""
|
||||
protected_note = ""
|
||||
if protected_skills:
|
||||
names = ", ".join(sorted(protected_skills))
|
||||
protected_note = (
|
||||
"\n\nPROTECTED skills (built-in — never edit these): "
|
||||
f"{names}\n"
|
||||
)
|
||||
try:
|
||||
from common import i18n
|
||||
lang_name = "中文" if i18n.is_zh() else "English"
|
||||
except Exception:
|
||||
lang_name = "中文"
|
||||
return (
|
||||
"Here is the conversation transcript that just went idle. Review it per "
|
||||
"your instructions. Acting is the exception: the main value is fixing or "
|
||||
"creating a skill and finishing promised work. Memory and knowledge are "
|
||||
"rare last resorts — stay [SILENT] unless there is a clear, durable signal "
|
||||
"not already covered."
|
||||
f"{protected_note}\n"
|
||||
f"The summary should preferably be written in: {lang_name}\n"
|
||||
"<transcript>\n"
|
||||
f"{transcript}\n"
|
||||
"</transcript>"
|
||||
)
|
||||
55
agent/evolution/record.py
Normal file
55
agent/evolution/record.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Self-evolution record log.
|
||||
|
||||
Session-level evolutions are appended to their OWN per-day file under
|
||||
``memory/evolution/YYYY-MM-DD.md`` (separate from the nightly Deep Dream diary
|
||||
in ``memory/dreams/``). Each day's file accumulates one short section per
|
||||
evolution pass — tagged with a timestamp and a backup id for undo — so the
|
||||
memory UI can surface "what the agent learned/changed today" on one timeline
|
||||
without ever mixing into the dream diary or the main conversation memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from common.log import logger
|
||||
|
||||
|
||||
def _evolution_dir(workspace_dir: Path, user_id: Optional[str] = None) -> Path:
|
||||
base = Path(workspace_dir) / "memory"
|
||||
if user_id:
|
||||
return base / "users" / user_id / "evolution"
|
||||
return base / "evolution"
|
||||
|
||||
|
||||
def append_session_evolution(
|
||||
workspace_dir: Path,
|
||||
summary: str,
|
||||
backup_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Append a session-evolution entry to today's evolution log."""
|
||||
if not summary or not summary.strip():
|
||||
return
|
||||
try:
|
||||
evo_dir = _evolution_dir(workspace_dir, user_id)
|
||||
evo_dir.mkdir(parents=True, exist_ok=True)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
log_file = evo_dir / f"{today}.md"
|
||||
|
||||
ts = datetime.now().strftime("%H:%M")
|
||||
header = f"## {ts}"
|
||||
body = summary.strip()
|
||||
if backup_id:
|
||||
body += f"\n\n_backup_id: {backup_id}_"
|
||||
|
||||
# Create with a title if the file is new, otherwise append a section.
|
||||
if not log_file.exists():
|
||||
log_file.write_text(f"# Self-Evolution: {today}\n\n", encoding="utf-8")
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(f"\n{header}\n\n{body}\n")
|
||||
logger.info(f"[Evolution] Recorded session evolution to {log_file.name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Failed to record session evolution: {e}")
|
||||
151
agent/evolution/trigger.py
Normal file
151
agent/evolution/trigger.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Idle-based evolution trigger.
|
||||
|
||||
A single background thread periodically scans live agent sessions and runs an
|
||||
evolution pass for any session that is idle for >= idle_minutes AND has enough
|
||||
accumulated signal, where "enough signal" is EITHER:
|
||||
- >= min_turns user turns since the last evolution, OR
|
||||
- the live context has grown past _CONTEXT_RATIO of the agent's token budget
|
||||
(mirrors how OpenClacky / Claude Code consolidate under context pressure).
|
||||
|
||||
Turn counting is per user turn (not per message), measured from the last
|
||||
evolution (or session start). After a pass runs, the baseline resets so a long
|
||||
session can evolve multiple times without re-judging old content.
|
||||
|
||||
Per-session evolution state is stored on the agent instance via lightweight
|
||||
attributes set by AgentBridge.agent_reply (see _note_user_turn).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from common.log import logger
|
||||
|
||||
from agent.evolution.config import get_evolution_config
|
||||
from agent.evolution.executor import run_evolution_for_session
|
||||
|
||||
_SCAN_INTERVAL_SECONDS = 60
|
||||
|
||||
# Context-pressure trigger: evolve once the live context exceeds this fraction
|
||||
# of the agent's token budget, even if min_turns hasn't been reached. Kept as a
|
||||
# module constant (not user config) for now. Fallback budget matches
|
||||
# agent_initializer / config.py (agent_max_context_tokens default = 50000).
|
||||
_CONTEXT_RATIO = 0.8
|
||||
_FALLBACK_CONTEXT_BUDGET = 50000
|
||||
|
||||
|
||||
def _context_pressure_reached(agent) -> bool:
|
||||
"""True if the agent's live context exceeds _CONTEXT_RATIO of its budget.
|
||||
|
||||
Uses the agent's own (estimated) token accounting so behavior matches the
|
||||
existing context-trimming path. Best-effort: any error -> False.
|
||||
"""
|
||||
try:
|
||||
with agent.messages_lock:
|
||||
messages = list(agent.messages)
|
||||
if not messages:
|
||||
return False
|
||||
est = sum(agent._estimate_message_tokens(m) for m in messages)
|
||||
budget = getattr(agent, "max_context_tokens", None) or _FALLBACK_CONTEXT_BUDGET
|
||||
return est / budget > _CONTEXT_RATIO
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def note_user_turn(agent, channel_type: str = "", receiver: str = "") -> None:
|
||||
"""Record activity for a session's agent. Called once per real user turn.
|
||||
|
||||
Maintains, on the agent instance:
|
||||
_evo_last_active : epoch seconds of the last user turn
|
||||
_evo_turns : user turns since the last evolution
|
||||
_evo_channel_type : originating channel (for later notify)
|
||||
_evo_receiver : push target for notify
|
||||
"""
|
||||
try:
|
||||
agent._evo_last_active = time.time()
|
||||
agent._evo_turns = int(getattr(agent, "_evo_turns", 0)) + 1
|
||||
if channel_type:
|
||||
agent._evo_channel_type = channel_type
|
||||
if receiver:
|
||||
agent._evo_receiver = receiver
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def mark_run_active(agent, active: bool) -> None:
|
||||
"""Flag whether the agent is mid-run, so idle scans skip a busy session.
|
||||
|
||||
Without this, a single run that lasts longer than idle_minutes would let
|
||||
the scanner fire an evolution pass concurrently with the live turn.
|
||||
"""
|
||||
try:
|
||||
agent._evo_run_active = bool(active)
|
||||
if active:
|
||||
agent._evo_last_active = time.time()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def start_evolution_trigger(agent_bridge) -> None:
|
||||
"""Start the idle-scan thread once per process (idempotent)."""
|
||||
if getattr(agent_bridge, "_evolution_trigger_started", False):
|
||||
return
|
||||
agent_bridge._evolution_trigger_started = True
|
||||
|
||||
t = threading.Thread(
|
||||
target=_scan_loop, args=(agent_bridge,), daemon=True, name="evolution-trigger"
|
||||
)
|
||||
t.start()
|
||||
logger.info("[Evolution] Idle trigger started")
|
||||
|
||||
|
||||
def _scan_loop(agent_bridge) -> None:
|
||||
while True:
|
||||
try:
|
||||
time.sleep(_SCAN_INTERVAL_SECONDS)
|
||||
cfg = get_evolution_config()
|
||||
if not cfg.enabled:
|
||||
continue
|
||||
_scan_once(agent_bridge, cfg)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Scan loop error: {e}")
|
||||
time.sleep(_SCAN_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def _scan_once(agent_bridge, cfg) -> None:
|
||||
now = time.time()
|
||||
# Snapshot to avoid holding the dict while running long evolutions.
|
||||
sessions = list(getattr(agent_bridge, "agents", {}).items())
|
||||
for session_id, agent in sessions:
|
||||
try:
|
||||
# Skip sessions whose agent is mid-run: a long turn must not be
|
||||
# reviewed while it is still producing the answer.
|
||||
if getattr(agent, "_evo_run_active", False):
|
||||
continue
|
||||
last_active = getattr(agent, "_evo_last_active", 0)
|
||||
turns = int(getattr(agent, "_evo_turns", 0))
|
||||
# Enough signal = enough turns OR enough context pressure.
|
||||
enough_signal = turns >= cfg.min_turns or _context_pressure_reached(agent)
|
||||
if not enough_signal:
|
||||
continue
|
||||
idle = now - last_active if last_active > 0 else -1
|
||||
if last_active <= 0 or idle < cfg.idle_seconds:
|
||||
continue
|
||||
|
||||
channel_type = getattr(agent, "_evo_channel_type", "") or ""
|
||||
receiver = getattr(agent, "_evo_receiver", "") or ""
|
||||
|
||||
# Reset baseline BEFORE running so a long pass / new messages during
|
||||
# it don't double-trigger; turns accrue fresh from here.
|
||||
agent._evo_turns = 0
|
||||
|
||||
run_evolution_for_session(
|
||||
agent_bridge,
|
||||
session_id=session_id,
|
||||
channel_type=channel_type,
|
||||
receiver=receiver,
|
||||
idle_minutes=(now - last_active) / 60 if last_active > 0 else 0.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Evolution] Failed to evaluate session={session_id}: {e}")
|
||||
@@ -12,11 +12,16 @@ Knowledge file layout (under workspace_root):
|
||||
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Iterable
|
||||
|
||||
from common.log import logger
|
||||
from config import conf
|
||||
from agent.memory.config import MemoryConfig
|
||||
from agent.memory.manager import MemoryManager
|
||||
|
||||
|
||||
class KnowledgeService:
|
||||
@@ -25,9 +30,189 @@ class KnowledgeService:
|
||||
Operates directly on the filesystem.
|
||||
"""
|
||||
|
||||
def __init__(self, workspace_root: str):
|
||||
self.workspace_root = workspace_root
|
||||
self.knowledge_dir = os.path.join(workspace_root, "knowledge")
|
||||
PROTECTED_FILES = {"index.md", "log.md"}
|
||||
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
|
||||
|
||||
def __init__(self, workspace_root: str, memory_manager=None):
|
||||
self.workspace_root = os.path.abspath(workspace_root)
|
||||
self.knowledge_dir = os.path.join(self.workspace_root, "knowledge")
|
||||
self._memory_manager = memory_manager
|
||||
|
||||
def _resolve_path(self, rel_path: str, *, kind: Optional[str] = None,
|
||||
allow_missing: bool = True) -> tuple:
|
||||
if not isinstance(rel_path, str) or not rel_path.strip():
|
||||
raise ValueError("path is required")
|
||||
rel_path = rel_path.replace("\\", "/").strip("/")
|
||||
parts = rel_path.split("/")
|
||||
if any(not p or p in (".", "..") or self.INVALID_NAME_RE.search(p) for p in parts):
|
||||
raise ValueError("invalid path")
|
||||
if kind == "document" and not rel_path.lower().endswith(".md"):
|
||||
raise ValueError("document path must end with .md")
|
||||
|
||||
root = Path(self.knowledge_dir).resolve()
|
||||
candidate = root.joinpath(*parts)
|
||||
# Resolve the nearest existing ancestor so a symlink cannot be used
|
||||
# to escape when the final destination does not exist yet.
|
||||
ancestor = candidate
|
||||
while not ancestor.exists() and ancestor != root:
|
||||
ancestor = ancestor.parent
|
||||
try:
|
||||
ancestor.resolve().relative_to(root)
|
||||
except ValueError:
|
||||
raise ValueError("path outside knowledge dir")
|
||||
if candidate.exists():
|
||||
try:
|
||||
candidate.resolve().relative_to(root)
|
||||
except ValueError:
|
||||
raise ValueError("path outside knowledge dir")
|
||||
elif not allow_missing:
|
||||
raise FileNotFoundError(f"path not found: {rel_path}")
|
||||
return rel_path, candidate
|
||||
|
||||
def _ensure_not_protected(self, rel_path: str):
|
||||
if rel_path in self.PROTECTED_FILES:
|
||||
raise ValueError(f"protected knowledge file: {rel_path}")
|
||||
|
||||
def _manager(self):
|
||||
if self._memory_manager is None:
|
||||
self._memory_manager = MemoryManager(MemoryConfig(workspace_root=self.workspace_root))
|
||||
return self._memory_manager
|
||||
|
||||
@staticmethod
|
||||
def _run_sync(coro):
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
result = []
|
||||
error = []
|
||||
|
||||
def runner():
|
||||
try:
|
||||
result.append(asyncio.run(coro))
|
||||
except Exception as exc:
|
||||
error.append(exc)
|
||||
|
||||
thread = threading.Thread(target=runner)
|
||||
thread.start()
|
||||
thread.join()
|
||||
if error:
|
||||
raise error[0]
|
||||
return result[0] if result else None
|
||||
|
||||
def _sync_index(self, old_paths: Iterable[str]):
|
||||
old_paths = sorted(set(old_paths))
|
||||
if not old_paths:
|
||||
return
|
||||
manager = self._manager()
|
||||
for rel_path in old_paths:
|
||||
manager.storage.delete_by_path(f"knowledge/{rel_path}")
|
||||
manager.mark_dirty()
|
||||
self._run_sync(manager.sync())
|
||||
|
||||
def create_category(self, path: str) -> dict:
|
||||
rel_path, full_path = self._resolve_path(path, kind="category")
|
||||
if full_path.exists():
|
||||
return {"path": rel_path, "created": False, "reason": "already_exists"}
|
||||
full_path.mkdir(parents=True)
|
||||
return {"path": rel_path, "created": True}
|
||||
|
||||
def rename_category(self, path: str, new_path: str) -> dict:
|
||||
old_rel, old_full = self._resolve_path(path, kind="category", allow_missing=False)
|
||||
new_rel, new_full = self._resolve_path(new_path, kind="category")
|
||||
if not old_full.is_dir():
|
||||
raise ValueError(f"not a category: {old_rel}")
|
||||
if new_full.exists():
|
||||
raise FileExistsError(f"target already exists: {new_rel}")
|
||||
old_documents = [str(p.relative_to(old_full)).replace(os.sep, "/")
|
||||
for p in old_full.rglob("*.md") if p.is_file()]
|
||||
new_full.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
old_full.rename(new_full)
|
||||
except FileNotFoundError:
|
||||
return {"old_path": old_rel, "path": new_rel, "moved": False, "reason": "not_found"}
|
||||
except FileExistsError:
|
||||
raise FileExistsError(f"target already exists: {new_rel}")
|
||||
old_paths = [f"{old_rel}/{p}" for p in old_documents]
|
||||
self._sync_index(old_paths)
|
||||
return {"old_path": old_rel, "path": new_rel, "moved_documents": len(old_documents)}
|
||||
|
||||
def delete_category(self, path: str, confirm: bool = False) -> dict:
|
||||
rel_path, full_path = self._resolve_path(path, kind="category")
|
||||
if not full_path.exists():
|
||||
return {"path": rel_path, "deleted": False, "reason": "not_found"}
|
||||
if not full_path.is_dir():
|
||||
raise ValueError(f"not a category: {rel_path}")
|
||||
knowledge_root = Path(self.knowledge_dir).resolve()
|
||||
documents = [str(p.relative_to(knowledge_root)).replace(os.sep, "/")
|
||||
for p in full_path.rglob("*.md") if p.is_file()]
|
||||
if any(p in self.PROTECTED_FILES for p in documents):
|
||||
raise ValueError("category contains protected knowledge files")
|
||||
if any(full_path.iterdir()) and not confirm:
|
||||
raise ValueError("category is not empty; confirmation is required")
|
||||
try:
|
||||
shutil.rmtree(full_path)
|
||||
except FileNotFoundError:
|
||||
return {"path": rel_path, "deleted": False, "reason": "not_found"}
|
||||
self._sync_index(documents)
|
||||
return {"path": rel_path, "deleted": True, "deleted_documents": len(documents)}
|
||||
|
||||
def delete_documents(self, paths: Iterable[str]) -> dict:
|
||||
if not isinstance(paths, list):
|
||||
raise ValueError("paths must be a list")
|
||||
results = []
|
||||
deleted = []
|
||||
for path in paths:
|
||||
rel_path, full_path = self._resolve_path(path, kind="document")
|
||||
self._ensure_not_protected(rel_path)
|
||||
if not full_path.exists():
|
||||
deleted.append(rel_path)
|
||||
results.append({"path": rel_path, "deleted": False, "reason": "not_found"})
|
||||
continue
|
||||
if not full_path.is_file():
|
||||
raise ValueError(f"not a document: {rel_path}")
|
||||
try:
|
||||
full_path.unlink()
|
||||
deleted.append(rel_path)
|
||||
results.append({"path": rel_path, "deleted": True})
|
||||
except FileNotFoundError:
|
||||
deleted.append(rel_path)
|
||||
results.append({"path": rel_path, "deleted": False, "reason": "not_found"})
|
||||
self._sync_index(deleted)
|
||||
return {"results": results, "deleted": sum(1 for item in results if item["deleted"])}
|
||||
|
||||
def move_documents(self, paths: Iterable[str], target_category: str) -> dict:
|
||||
if not isinstance(paths, list):
|
||||
raise ValueError("paths must be a list")
|
||||
target_rel, target_full = self._resolve_path(target_category, kind="category")
|
||||
if not target_full.is_dir():
|
||||
raise FileNotFoundError(f"category not found: {target_rel}")
|
||||
results = []
|
||||
moved_old_paths = []
|
||||
for path in paths:
|
||||
rel_path, full_path = self._resolve_path(path, kind="document")
|
||||
self._ensure_not_protected(rel_path)
|
||||
if not full_path.exists():
|
||||
results.append({"path": rel_path, "moved": False, "reason": "not_found"})
|
||||
continue
|
||||
destination = target_full / full_path.name
|
||||
new_rel = str(destination.relative_to(Path(self.knowledge_dir).resolve())).replace(os.sep, "/")
|
||||
if destination.exists():
|
||||
results.append({"path": rel_path, "moved": False, "reason": "target_exists",
|
||||
"target": new_rel})
|
||||
continue
|
||||
try:
|
||||
os.link(full_path, destination)
|
||||
full_path.unlink()
|
||||
moved_old_paths.append(rel_path)
|
||||
results.append({"path": rel_path, "moved": True, "target": new_rel})
|
||||
except FileExistsError:
|
||||
results.append({"path": rel_path, "moved": False, "reason": "target_exists",
|
||||
"target": new_rel})
|
||||
except FileNotFoundError:
|
||||
results.append({"path": rel_path, "moved": False, "reason": "not_found"})
|
||||
self._sync_index(moved_old_paths)
|
||||
return {"results": results, "moved": len(moved_old_paths)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# list — directory tree with stats
|
||||
@@ -121,15 +306,8 @@ class KnowledgeService:
|
||||
:raises ValueError: if path is invalid or escapes knowledge dir
|
||||
:raises FileNotFoundError: if file does not exist
|
||||
"""
|
||||
if not rel_path or ".." in rel_path:
|
||||
raise ValueError("invalid path")
|
||||
|
||||
full_path = os.path.normpath(os.path.join(self.knowledge_dir, rel_path))
|
||||
allowed = os.path.normpath(self.knowledge_dir)
|
||||
if not full_path.startswith(allowed + os.sep) and full_path != allowed:
|
||||
raise ValueError("path outside knowledge dir")
|
||||
|
||||
if not os.path.isfile(full_path):
|
||||
rel_path, full_path = self._resolve_path(rel_path, kind="document")
|
||||
if not full_path.is_file():
|
||||
raise FileNotFoundError(f"file not found: {rel_path}")
|
||||
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
@@ -228,13 +406,26 @@ class KnowledgeService:
|
||||
result = self.build_graph()
|
||||
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||
|
||||
elif action == "create_category":
|
||||
result = self.create_category(payload.get("path"))
|
||||
elif action == "rename_category":
|
||||
result = self.rename_category(payload.get("path"), payload.get("new_path"))
|
||||
elif action == "delete_category":
|
||||
result = self.delete_category(payload.get("path"), payload.get("confirm", False))
|
||||
elif action == "delete_documents":
|
||||
result = self.delete_documents(payload.get("paths") or [])
|
||||
elif action == "move_documents":
|
||||
result = self.move_documents(payload.get("paths") or [], payload.get("target_category"))
|
||||
else:
|
||||
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
||||
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||
|
||||
except ValueError as e:
|
||||
return {"action": action, "code": 403, "message": str(e), "payload": None}
|
||||
except FileNotFoundError as e:
|
||||
return {"action": action, "code": 404, "message": str(e), "payload": None}
|
||||
except FileExistsError as e:
|
||||
return {"action": action, "code": 409, "message": str(e), "payload": None}
|
||||
except Exception as e:
|
||||
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
|
||||
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
||||
|
||||
@@ -13,6 +13,7 @@ Storage path: ~/cow/sessions/conversations.db
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
@@ -109,6 +110,48 @@ def _extract_display_text(content: Any) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# Internal markers written into the session for the agent's own bookkeeping
|
||||
# (scheduler injection / self-evolution undo). They must stay in the stored
|
||||
# content (the LLM reads them, e.g. to find a backup_id for undo) but should
|
||||
# never be shown verbatim to the user in the chat history UI.
|
||||
_SCHEDULED_DISPLAY_MARKERS = ("[SCHEDULED]", "Scheduled task")
|
||||
_EVOLUTION_DISPLAY_MARKER = "[EVOLUTION]"
|
||||
|
||||
|
||||
def _is_internal_user_marker(text: str) -> bool:
|
||||
"""True if a user-turn text is an internal injection marker (hide from UI)."""
|
||||
t = (text or "").lstrip()
|
||||
return any(t.startswith(m) for m in _SCHEDULED_DISPLAY_MARKERS)
|
||||
|
||||
|
||||
def _is_evolution_text(text: str) -> bool:
|
||||
"""True if assistant text is a self-evolution summary (before cleaning)."""
|
||||
return (text or "").lstrip().startswith(_EVOLUTION_DISPLAY_MARKER)
|
||||
|
||||
|
||||
def _clean_display_text(text: str) -> str:
|
||||
"""Strip internal markers from assistant text for user-facing display.
|
||||
|
||||
Removes a leading ``[EVOLUTION]`` tag and a trailing ``(backup_id: ...)``
|
||||
undo hint. The raw stored message is untouched, so undo + LLM context still
|
||||
work; only the rendered chat bubble is cleaned.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
cleaned = text
|
||||
stripped = cleaned.lstrip()
|
||||
if stripped.startswith(_EVOLUTION_DISPLAY_MARKER):
|
||||
cleaned = stripped[len(_EVOLUTION_DISPLAY_MARKER):].lstrip()
|
||||
# Drop a trailing backup_id undo hint line, e.g.
|
||||
# "(backup_id: 20260607-...; to undo, restore this backup)"
|
||||
cleaned = re.sub(
|
||||
r"\n*\(backup_id:[^\)]*\)\s*$",
|
||||
"",
|
||||
cleaned,
|
||||
).rstrip()
|
||||
return cleaned
|
||||
|
||||
|
||||
def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Extract tool_use blocks from an assistant message content.
|
||||
@@ -210,7 +253,10 @@ def _group_into_display_turns(
|
||||
if user_row:
|
||||
content, created_at, _u_extras = user_row
|
||||
text = _extract_display_text(content)
|
||||
if text:
|
||||
# Hide internal injection markers (scheduler / self-evolution) so the
|
||||
# user never sees a synthetic "[SCHEDULED] self-evolution" bubble;
|
||||
# the assistant reply that follows is still rendered.
|
||||
if text and not _is_internal_user_marker(text):
|
||||
turns.append({"role": "user", "content": text, "created_at": created_at})
|
||||
|
||||
# Build an ordered list of steps preserving the original sequence:
|
||||
@@ -265,6 +311,18 @@ def _group_into_display_turns(
|
||||
step["result"] = tr.get("result", "")
|
||||
step["is_error"] = tr.get("is_error", False)
|
||||
|
||||
# Detect a self-evolution bubble BEFORE cleaning the marker away, so the
|
||||
# UI can flag it even though the visible text stays clean.
|
||||
is_evolution = _is_evolution_text(final_text)
|
||||
|
||||
# Clean internal markers from the user-facing assistant text. Applies to
|
||||
# both the final content and the mirrored content step so the rendered
|
||||
# bubble shows clean text while the stored message keeps the markers.
|
||||
final_text = _clean_display_text(final_text)
|
||||
for step in steps:
|
||||
if step.get("type") == "content":
|
||||
step["content"] = _clean_display_text(step.get("content", ""))
|
||||
|
||||
if steps or final_text:
|
||||
turn = {
|
||||
"role": "assistant",
|
||||
@@ -272,6 +330,8 @@ def _group_into_display_turns(
|
||||
"steps": steps,
|
||||
"created_at": final_ts or (user_row[1] if user_row else 0),
|
||||
}
|
||||
if is_evolution:
|
||||
turn["kind"] = "evolution"
|
||||
if merged_extras:
|
||||
turn["extras"] = merged_extras
|
||||
turns.append(turn)
|
||||
@@ -291,7 +351,7 @@ class ConversationStore:
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self._db_path = db_path
|
||||
self._lock = threading.Lock()
|
||||
self._lock = threading.RLock() # Use RLock to allow reentrant locking
|
||||
self._init_db()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -509,6 +569,65 @@ class ConversationStore:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_latest_pair_seqs(self, session_id: str) -> Dict[str, Optional[int]]:
|
||||
"""Return the seq numbers of the latest visible user message and the
|
||||
latest assistant message in a session.
|
||||
|
||||
A "visible" user message is one whose content is real user text
|
||||
(not just a tool_result block), so tool-execution turns do not
|
||||
shadow the actual user query.
|
||||
|
||||
Returns:
|
||||
Dict with keys ``user_seq`` and ``bot_seq``; either may be None
|
||||
when no matching message exists.
|
||||
"""
|
||||
result: Dict[str, Optional[int]] = {"user_seq": None, "bot_seq": None}
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
# Latest assistant message (cheap: single row by seq DESC).
|
||||
row = conn.execute(
|
||||
"SELECT seq FROM messages "
|
||||
"WHERE session_id = ? AND role = 'assistant' "
|
||||
"ORDER BY seq DESC LIMIT 1",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
result["bot_seq"] = int(row[0])
|
||||
|
||||
# Latest visible user message: scan recent user rows and
|
||||
# skip pure tool_result entries.
|
||||
rows = conn.execute(
|
||||
"SELECT seq, content FROM messages "
|
||||
"WHERE session_id = ? AND role = 'user' "
|
||||
"ORDER BY seq DESC LIMIT 20",
|
||||
(session_id,),
|
||||
).fetchall()
|
||||
for seq, content_raw in rows:
|
||||
try:
|
||||
content = json.loads(content_raw)
|
||||
except Exception:
|
||||
result["user_seq"] = int(seq)
|
||||
break
|
||||
if isinstance(content, list):
|
||||
has_text = any(
|
||||
isinstance(b, dict) and b.get("type") == "text"
|
||||
for b in content
|
||||
)
|
||||
has_tool_result = any(
|
||||
isinstance(b, dict) and b.get("type") == "tool_result"
|
||||
for b in content
|
||||
)
|
||||
if has_text and not has_tool_result:
|
||||
result["user_seq"] = int(seq)
|
||||
break
|
||||
else:
|
||||
result["user_seq"] = int(seq)
|
||||
break
|
||||
finally:
|
||||
conn.close()
|
||||
return result
|
||||
|
||||
def clear_session(self, session_id: str) -> None:
|
||||
"""Delete all messages and the session record for a given session_id."""
|
||||
with self._lock:
|
||||
@@ -524,6 +643,109 @@ class ConversationStore:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def delete_message_pair(self, session_id: str, user_seq: int, delete_user: bool = True, cascade: bool = False) -> int:
|
||||
"""Delete a user message and/or its corresponding assistant reply.
|
||||
|
||||
The assistant reply is identified as all messages between user_seq
|
||||
and the next visible user message (or end of session).
|
||||
|
||||
Args:
|
||||
session_id: Session identifier.
|
||||
user_seq: The seq number of the user message.
|
||||
delete_user: If True (default), delete the user message too.
|
||||
If False, only delete assistant reply (for regenerate scenarios).
|
||||
cascade: If True, also delete all subsequent turns after this one.
|
||||
Used by edit-message which removes this turn and everything after.
|
||||
|
||||
Returns:
|
||||
Number of message rows deleted.
|
||||
"""
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
with conn:
|
||||
# Verify this is a user message
|
||||
row = conn.execute(
|
||||
"SELECT role FROM messages WHERE session_id = ? AND seq = ?",
|
||||
(session_id, user_seq),
|
||||
).fetchone()
|
||||
if not row or row[0] != "user":
|
||||
return 0
|
||||
|
||||
if cascade:
|
||||
# Delete from this message to end of session
|
||||
start_seq = user_seq if delete_user else user_seq + 1
|
||||
end_seq_row = conn.execute(
|
||||
"SELECT MAX(seq) FROM messages WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
end_seq = (end_seq_row[0] or user_seq) + 1
|
||||
else:
|
||||
# Find the next visible user message seq (exclude tool_result)
|
||||
# Use batched query to avoid loading too many rows at once
|
||||
next_user_seq = None
|
||||
batch_size = 100
|
||||
offset = 0
|
||||
while True:
|
||||
batch = conn.execute(
|
||||
"""
|
||||
SELECT seq, content FROM messages
|
||||
WHERE session_id = ? AND seq > ? AND role = 'user'
|
||||
ORDER BY seq ASC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(session_id, user_seq, batch_size, offset),
|
||||
).fetchall()
|
||||
if not batch:
|
||||
break
|
||||
for seq, content in batch:
|
||||
try:
|
||||
content_obj = json.loads(content)
|
||||
except Exception:
|
||||
content_obj = content
|
||||
if _is_visible_user_message(content_obj):
|
||||
next_user_seq = seq
|
||||
break
|
||||
if next_user_seq is not None:
|
||||
break
|
||||
offset += batch_size
|
||||
|
||||
# Determine the end boundary for deletion
|
||||
if next_user_seq is not None:
|
||||
end_seq = next_user_seq
|
||||
else:
|
||||
end_seq_row = conn.execute(
|
||||
"SELECT MAX(seq) FROM messages WHERE session_id = ?",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
end_seq = (end_seq_row[0] or user_seq) + 1
|
||||
|
||||
# Determine the start boundary for deletion
|
||||
start_seq = user_seq if delete_user else user_seq + 1
|
||||
|
||||
# Delete messages from start_seq to end_seq (exclusive)
|
||||
cur = conn.execute(
|
||||
"DELETE FROM messages WHERE session_id = ? AND seq >= ? AND seq < ?",
|
||||
(session_id, start_seq, end_seq),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
|
||||
# Update session msg_count
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET msg_count = (
|
||||
SELECT COUNT(*) FROM messages WHERE session_id = ?
|
||||
)
|
||||
WHERE session_id = ?
|
||||
""",
|
||||
(session_id, session_id),
|
||||
)
|
||||
|
||||
return deleted
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def prune_scheduled_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
@@ -1053,3 +1275,4 @@ def get_conversation_store() -> ConversationStore:
|
||||
_store_instance = ConversationStore(db_path)
|
||||
logger.debug(f"[ConversationStore] Using shared DB at: {db_path}")
|
||||
return _store_instance
|
||||
|
||||
|
||||
@@ -7,10 +7,14 @@ Supports multiple OpenAI-compatible embedding vendors:
|
||||
- dashscope (Aliyun Tongyi text-embedding-v4)
|
||||
- doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark)
|
||||
- zhipu (ZhipuAI embedding-3)
|
||||
- custom (any OpenAI-compatible endpoint)
|
||||
|
||||
Vendor keys here intentionally match the project's bot_type constants in
|
||||
common.const (OPENAI, LINKAI, QWEN_DASHSCOPE, DOUBAO, ZHIPU_AI).
|
||||
|
||||
Custom providers (bot_type "custom" or "custom:<id>") reuse the same
|
||||
OpenAI-compatible REST client with user-supplied api_key / api_base.
|
||||
|
||||
All providers share a single OpenAI-compatible REST client. Vendor-specific
|
||||
behaviors (truncation, query instruction prefix) are configured via metadata.
|
||||
"""
|
||||
@@ -138,6 +142,22 @@ EMBEDDING_VENDORS = {
|
||||
"query_instruction": "",
|
||||
"max_batch_size": 64,
|
||||
},
|
||||
# Custom provider — any OpenAI-compatible /embeddings endpoint. The
|
||||
# user must supply api_key + api_base + model via the web console
|
||||
# (stored in custom_providers list or legacy custom_api_key / custom_api_base).
|
||||
# Dimensions defaults to 1024 but can be overridden via config's
|
||||
# embedding_dimensions. No dim-param support assumption — safest
|
||||
# default for unknown endpoints.
|
||||
"custom": {
|
||||
"default_base_url": "",
|
||||
"default_model": "",
|
||||
"default_dimensions": 1024,
|
||||
"supports_dim_param": False,
|
||||
"needs_client_truncate": False,
|
||||
"needs_client_normalize": True,
|
||||
"query_instruction": "",
|
||||
"max_batch_size": 64,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -472,10 +492,19 @@ def create_embedding_provider(
|
||||
)
|
||||
|
||||
final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"]
|
||||
resolved_model = model or meta["default_model"]
|
||||
resolved_base = api_base or meta["default_base_url"]
|
||||
# Custom providers require explicit api_base and model — they cannot
|
||||
# fall back to OpenAI defaults like built-in vendors do.
|
||||
if provider == "custom":
|
||||
if not resolved_base:
|
||||
raise ValueError("Custom embedding provider requires an api_base URL")
|
||||
if not resolved_model:
|
||||
raise ValueError("Custom embedding provider requires a model name")
|
||||
return OpenAIEmbeddingProvider(
|
||||
model=model or meta["default_model"],
|
||||
model=resolved_model,
|
||||
api_key=api_key,
|
||||
api_base=api_base or meta["default_base_url"],
|
||||
api_base=resolved_base,
|
||||
extra_headers=extra_headers,
|
||||
dimensions=final_dim,
|
||||
supports_dim_param=meta["supports_dim_param"],
|
||||
|
||||
@@ -34,13 +34,18 @@ class MemoryService:
|
||||
# ------------------------------------------------------------------
|
||||
def list_files(self, page: int = 1, page_size: int = 20, category: str = "memory") -> dict:
|
||||
"""
|
||||
List memory or dream files with metadata (without content).
|
||||
List memory, dream, or evolution files with metadata (without content).
|
||||
|
||||
Args:
|
||||
category: ``"memory"`` (default) — MEMORY.md + daily files;
|
||||
``"dream"`` — dream diary files from memory/dreams/
|
||||
``"dream"`` — dream diary files from memory/dreams/;
|
||||
``"evolution"`` — self-evolution logs from memory/evolution/
|
||||
merged with the nightly dream diaries, so
|
||||
one tab shows everything the agent learned.
|
||||
"""
|
||||
if category == "dream":
|
||||
if category == "evolution":
|
||||
files = self._list_evolution_files()
|
||||
elif category == "dream":
|
||||
files = self._list_dream_files()
|
||||
else:
|
||||
files = self._list_memory_files()
|
||||
@@ -93,6 +98,26 @@ class MemoryService:
|
||||
|
||||
return files
|
||||
|
||||
def _list_evolution_files(self) -> List[dict]:
|
||||
"""Self-evolution logs (memory/evolution/*.md) merged with the nightly
|
||||
dream diaries (memory/dreams/*.md), newest first.
|
||||
|
||||
Both are surfaced under the unified "Self-Evolution" tab. A file's
|
||||
``type`` records its origin so the reader can resolve the right dir.
|
||||
"""
|
||||
files: List[dict] = []
|
||||
for sub, ftype in (("evolution", "evolution"), ("dreams", "dream")):
|
||||
sub_dir = os.path.join(self.memory_dir, sub)
|
||||
if not os.path.isdir(sub_dir):
|
||||
continue
|
||||
for name in os.listdir(sub_dir):
|
||||
full = os.path.join(sub_dir, name)
|
||||
if os.path.isfile(full) and name.endswith(".md"):
|
||||
files.append(self._file_info(full, name, ftype))
|
||||
# Sort newest first by filename (date-named); ties favor evolution.
|
||||
files.sort(key=lambda f: (f["filename"], f["type"] != "evolution"), reverse=True)
|
||||
return files
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# content — read a single file
|
||||
# ------------------------------------------------------------------
|
||||
@@ -101,7 +126,7 @@ class MemoryService:
|
||||
Read the full content of a memory or dream file.
|
||||
|
||||
:param filename: File name, e.g. ``MEMORY.md``, ``2026-02-20.md``
|
||||
:param category: ``"memory"`` or ``"dream"``
|
||||
:param category: ``"memory"``, ``"dream"`` or ``"evolution"``
|
||||
:return: dict with ``filename`` and ``content``
|
||||
:raises FileNotFoundError: if the file does not exist
|
||||
"""
|
||||
@@ -125,7 +150,7 @@ class MemoryService:
|
||||
Dispatch a memory management action.
|
||||
|
||||
:param action: ``list`` or ``content``
|
||||
:param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"``)
|
||||
:param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"`` | ``"evolution"``)
|
||||
:return: protocol-compatible response dict
|
||||
"""
|
||||
payload = payload or {}
|
||||
@@ -166,6 +191,7 @@ class MemoryService:
|
||||
- ``MEMORY.md`` → ``{workspace_root}/MEMORY.md``
|
||||
- ``2026-02-20.md`` (memory) → ``{workspace_root}/memory/2026-02-20.md``
|
||||
- ``2026-02-20.md`` (dream) → ``{workspace_root}/memory/dreams/2026-02-20.md``
|
||||
- ``2026-02-20.md`` (evolution) → ``{workspace_root}/memory/evolution/2026-02-20.md``
|
||||
|
||||
Raises ValueError if the resolved path escapes the allowed directory.
|
||||
"""
|
||||
@@ -173,6 +199,8 @@ class MemoryService:
|
||||
base_dir = self.workspace_root
|
||||
elif category == "dream":
|
||||
base_dir = os.path.join(self.memory_dir, "dreams")
|
||||
elif category == "evolution":
|
||||
base_dir = os.path.join(self.memory_dir, "evolution")
|
||||
else:
|
||||
base_dir = self.memory_dir
|
||||
|
||||
|
||||
@@ -825,9 +825,10 @@ class MemoryStorage:
|
||||
return []
|
||||
|
||||
def delete_by_path(self, path: str):
|
||||
"""Delete all chunks from a file"""
|
||||
"""Delete all chunks and file metadata for a path."""
|
||||
with self._lock:
|
||||
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
|
||||
self.conn.execute("DELETE FROM files WHERE path = ?", (path,))
|
||||
self.conn.commit()
|
||||
|
||||
def get_file_hash(self, path: str) -> Optional[str]:
|
||||
|
||||
@@ -462,13 +462,12 @@ class MemoryFlushManager:
|
||||
daily_content=daily_content or "(no recent daily records)",
|
||||
)
|
||||
from agent.protocol.models import LLMRequest
|
||||
# Scale max_tokens based on input size to avoid truncating large MEMORY.md
|
||||
input_chars = len(memory_content) + len(daily_content)
|
||||
dream_max_tokens = max(2000, min(input_chars, 8000))
|
||||
# No output cap: the prompt already keeps MEMORY.md concise (~50
|
||||
# items), so a hard max_tokens would only risk truncating a large
|
||||
# rewrite. Let the model use its default output budget.
|
||||
request = LLMRequest(
|
||||
messages=[{"role": "user", "content": user_msg}],
|
||||
temperature=0.3,
|
||||
max_tokens=dream_max_tokens,
|
||||
stream=False,
|
||||
system=_dream_system_prompt(),
|
||||
)
|
||||
|
||||
@@ -52,6 +52,11 @@ class Agent:
|
||||
self.workspace_dir = workspace_dir # Workspace directory
|
||||
self.enable_skills = enable_skills # Skills enabled flag
|
||||
self.runtime_info = runtime_info # Runtime info for dynamic time update
|
||||
# Optional extra instructions appended AFTER the rebuilt full system
|
||||
# prompt. Used by the self-evolution review agent to add its task brief
|
||||
# on top of the full context (tools, workspace, user preferences, time)
|
||||
# so it both follows the user's preferences and knows its evolution job.
|
||||
self.extra_system_suffix = None
|
||||
|
||||
# Initialize skill manager
|
||||
self.skill_manager = None
|
||||
@@ -120,15 +125,20 @@ class Agent:
|
||||
except Exception:
|
||||
lang = "zh"
|
||||
builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang)
|
||||
return builder.build(
|
||||
full = builder.build(
|
||||
tools=self.tools,
|
||||
context_files=context_files,
|
||||
skill_manager=self.skill_manager,
|
||||
memory_manager=self.memory_manager,
|
||||
runtime_info=self.runtime_info,
|
||||
)
|
||||
if self.extra_system_suffix:
|
||||
full = f"{full}\n\n{self.extra_system_suffix}"
|
||||
return full
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to rebuild system prompt, using cached version: {e}")
|
||||
if self.extra_system_suffix:
|
||||
return f"{self.system_prompt}\n\n{self.extra_system_suffix}"
|
||||
return self.system_prompt
|
||||
|
||||
def refresh_skills(self):
|
||||
|
||||
@@ -347,11 +347,14 @@ class AgentStreamExecutor:
|
||||
Returns:
|
||||
Final response text
|
||||
"""
|
||||
# Log user message with model info
|
||||
|
||||
# Log user message with model info. Truncate very long messages (e.g.
|
||||
# injected transcripts / large prompts) so logs stay readable.
|
||||
thinking_enabled = self._is_thinking_enabled()
|
||||
thinking_label = " | 💭 thinking" if thinking_enabled else ""
|
||||
logger.info(f"🤖 {self.model.model}{thinking_label} | 👤 {user_message}")
|
||||
_log_msg = user_message if len(user_message) <= 500 else (
|
||||
user_message[:500] + f" …(+{len(user_message) - 500} chars)"
|
||||
)
|
||||
logger.info(f"🤖 {self.model.model}{thinking_label} | 👤 {_log_msg}")
|
||||
|
||||
# Add user message (Claude format - use content blocks for consistency)
|
||||
self.messages.append({
|
||||
@@ -1171,10 +1174,21 @@ class AgentStreamExecutor:
|
||||
# Set tool context
|
||||
tool.model = self.model
|
||||
tool.context = self.agent
|
||||
tool.progress_callback = lambda message: self._emit_event(
|
||||
"tool_execution_progress",
|
||||
{
|
||||
"tool_call_id": tool_id,
|
||||
"tool_name": tool_name,
|
||||
"message": message,
|
||||
}
|
||||
)
|
||||
|
||||
# Execute tool
|
||||
start_time = time.time()
|
||||
result: ToolResult = tool.execute_tool(arguments)
|
||||
try:
|
||||
result: ToolResult = tool.execute_tool(arguments)
|
||||
finally:
|
||||
tool.progress_callback = None
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
result_dict = {
|
||||
@@ -1716,4 +1730,4 @@ class AgentStreamExecutor:
|
||||
not as a message. The AgentLLMModel will handle this.
|
||||
"""
|
||||
# Don't add system message here - it will be handled separately by the LLM adapter
|
||||
return self.messages
|
||||
return self.messages
|
||||
|
||||
@@ -34,6 +34,27 @@ class SkillService:
|
||||
"""
|
||||
self.manager = skill_manager
|
||||
|
||||
def _safe_skill_dir(self, name: str) -> str:
|
||||
"""Derive and validate the skill directory path.
|
||||
|
||||
Ensures the resolved path stays within the custom_dir root,
|
||||
preventing path traversal via names like ``../escaped``.
|
||||
|
||||
:raises ValueError: if the name would escape the skills root.
|
||||
"""
|
||||
if not name or not name.strip():
|
||||
raise ValueError("skill name is required")
|
||||
# Reject obvious traversal components.
|
||||
if ".." in name or name.startswith("/") or name.startswith("\\"):
|
||||
raise ValueError(f"invalid skill name (path traversal detected): {name!r}")
|
||||
skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name))
|
||||
root = os.path.realpath(self.manager.custom_dir)
|
||||
if not skill_dir.startswith(root + os.sep) and skill_dir != root:
|
||||
raise ValueError(
|
||||
f"skill name {name!r} resolves outside the skills directory"
|
||||
)
|
||||
return skill_dir
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# query
|
||||
# ------------------------------------------------------------------
|
||||
@@ -107,7 +128,7 @@ class SkillService:
|
||||
if not files:
|
||||
raise ValueError("skill files list is empty")
|
||||
|
||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
||||
skill_dir = self._safe_skill_dir(name)
|
||||
|
||||
tmp_dir = skill_dir + ".tmp"
|
||||
if os.path.exists(tmp_dir):
|
||||
@@ -146,7 +167,7 @@ class SkillService:
|
||||
raise ValueError("package url is required")
|
||||
|
||||
url = files[0]["url"]
|
||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
||||
skill_dir = self._safe_skill_dir(name)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||
zip_path = os.path.join(tmp_dir, "package.zip")
|
||||
@@ -217,7 +238,7 @@ class SkillService:
|
||||
if not name:
|
||||
raise ValueError("skill name is required")
|
||||
|
||||
skill_dir = os.path.join(self.manager.custom_dir, name)
|
||||
skill_dir = self._safe_skill_dir(name)
|
||||
if os.path.exists(skill_dir):
|
||||
shutil.rmtree(skill_dir)
|
||||
logger.info(f"[SkillService] delete: removed directory {skill_dir}")
|
||||
|
||||
@@ -14,6 +14,9 @@ from agent.tools.send.send import Send
|
||||
from agent.tools.memory.memory_search import MemorySearchTool
|
||||
from agent.tools.memory.memory_get import MemoryGetTool
|
||||
|
||||
# Import self-evolution tools
|
||||
from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool
|
||||
|
||||
# Import tools with optional dependencies
|
||||
def _import_optional_tools():
|
||||
"""Import tools that have optional dependencies"""
|
||||
@@ -135,6 +138,7 @@ __all__ = [
|
||||
'Send',
|
||||
'MemorySearchTool',
|
||||
'MemoryGetTool',
|
||||
'EvolutionUndoTool',
|
||||
'EnvConfig',
|
||||
'SchedulerTool',
|
||||
'WebSearch',
|
||||
|
||||
@@ -38,6 +38,16 @@ class BaseTool:
|
||||
description: str = "Base tool"
|
||||
params: dict = {} # Store JSON Schema
|
||||
model: Optional[Any] = None # LLM model instance, type depends on bot implementation
|
||||
progress_callback = None
|
||||
|
||||
def report_progress(self, message: str):
|
||||
callback = getattr(self, "progress_callback", None)
|
||||
if not callback:
|
||||
return
|
||||
try:
|
||||
callback(str(message))
|
||||
except Exception as e:
|
||||
logger.debug(f"[{self.name}] progress callback failed: {e}")
|
||||
|
||||
@classmethod
|
||||
def get_json_schema(cls) -> dict:
|
||||
|
||||
@@ -4,9 +4,12 @@ Bash tool - Execute bash commands
|
||||
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Any
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
@@ -19,6 +22,10 @@ class Bash(BaseTool):
|
||||
"""Tool for executing bash commands"""
|
||||
|
||||
_IS_WIN = sys.platform == "win32"
|
||||
_PROGRESS_MAX_BYTES = 4 * 1024
|
||||
_PROGRESS_INTERVAL = 0.5
|
||||
# cmd.exe command line limit is ~8191 chars; rewrite python -c above this.
|
||||
_WIN_CMD_SAFE_LEN = 7000
|
||||
|
||||
name: str = "bash"
|
||||
description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file.
|
||||
@@ -69,8 +76,8 @@ SAFETY:
|
||||
if not command:
|
||||
return ToolResult.fail("Error: command parameter is required")
|
||||
|
||||
# Security check: Prevent accessing sensitive config files
|
||||
if "~/.cow/.env" in command or "~/.cow" in command:
|
||||
# Security check: Prevent direct access to the credential file
|
||||
if re.search(r'\.cow[/\\]\.env', command):
|
||||
return ToolResult.fail(
|
||||
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
||||
)
|
||||
@@ -106,25 +113,35 @@ SAFETY:
|
||||
else:
|
||||
logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}")
|
||||
|
||||
# Temp script written for long `python -c` commands (Windows only),
|
||||
# cleaned up after execution.
|
||||
temp_script_path = None
|
||||
|
||||
# On Windows, convert $VAR references to %VAR% for cmd.exe
|
||||
if self._IS_WIN:
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
command = self._convert_env_vars_for_windows(command, dotenv_vars)
|
||||
# cmd.exe has an ~8191 char command line limit. Long
|
||||
# `python -c "..."` commands silently fail, so spill the inline
|
||||
# code into a temp .py file and run that instead.
|
||||
if len(command) > self._WIN_CMD_SAFE_LEN:
|
||||
command, temp_script_path = self._rewrite_long_python_c(command)
|
||||
if command and not command.strip().lower().startswith("chcp"):
|
||||
command = f"chcp 65001 >nul 2>&1 && {command}"
|
||||
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=self.cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
result = self._run_streaming(
|
||||
command,
|
||||
timeout,
|
||||
env,
|
||||
dotenv_vars,
|
||||
)
|
||||
finally:
|
||||
if temp_script_path:
|
||||
try:
|
||||
os.remove(temp_script_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
logger.debug(f"[Bash] Exit code: {result.returncode}")
|
||||
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
|
||||
@@ -236,6 +253,105 @@ SAFETY:
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error executing command: {str(e)}")
|
||||
|
||||
def _run_streaming(self, command: str, timeout: int, env: dict, dotenv_vars: dict):
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=self.cwd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
env=env,
|
||||
start_new_session=not self._IS_WIN,
|
||||
)
|
||||
stdout_chunks, stderr_chunks = [], []
|
||||
recent = bytearray()
|
||||
recent_lock = threading.Lock()
|
||||
|
||||
def drain(stream, chunks):
|
||||
while True:
|
||||
chunk = os.read(stream.fileno(), 4096)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
with recent_lock:
|
||||
recent.extend(chunk)
|
||||
if len(recent) > self._PROGRESS_MAX_BYTES:
|
||||
del recent[:-self._PROGRESS_MAX_BYTES]
|
||||
|
||||
readers = [
|
||||
threading.Thread(target=drain, args=(process.stdout, stdout_chunks), daemon=True),
|
||||
threading.Thread(target=drain, args=(process.stderr, stderr_chunks), daemon=True),
|
||||
]
|
||||
for reader in readers:
|
||||
reader.start()
|
||||
|
||||
started = time.monotonic()
|
||||
last_reported_at = started
|
||||
last_snapshot = None
|
||||
try:
|
||||
while process.poll() is None:
|
||||
now = time.monotonic()
|
||||
elapsed = now - started
|
||||
if elapsed >= timeout:
|
||||
self._kill_process(process)
|
||||
raise subprocess.TimeoutExpired(command, timeout)
|
||||
if elapsed >= self._PROGRESS_INTERVAL and now - last_reported_at >= self._PROGRESS_INTERVAL:
|
||||
with recent_lock:
|
||||
snapshot = bytes(recent).decode("utf-8", errors="replace")
|
||||
snapshot = self._redact_progress(snapshot, dotenv_vars)
|
||||
if snapshot and snapshot != last_snapshot:
|
||||
self.report_progress(snapshot)
|
||||
last_snapshot = snapshot
|
||||
last_reported_at = now
|
||||
time.sleep(0.1)
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
self._kill_process(process)
|
||||
process.wait()
|
||||
join_deadline = time.monotonic() + 5
|
||||
for reader in readers:
|
||||
reader.join(timeout=max(0, join_deadline - time.monotonic()))
|
||||
|
||||
from types import SimpleNamespace
|
||||
return SimpleNamespace(
|
||||
returncode=process.returncode,
|
||||
stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"),
|
||||
stderr=b"".join(stderr_chunks).decode("utf-8", errors="replace"),
|
||||
)
|
||||
|
||||
def _kill_process(self, process):
|
||||
if self._IS_WIN:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(process.pid)],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode != 0 and process.poll() is None:
|
||||
process.kill()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
else:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except (PermissionError, ProcessLookupError):
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
|
||||
@staticmethod
|
||||
def _redact_progress(text: str, dotenv_vars: dict) -> str:
|
||||
text = re.sub(
|
||||
r'(?i)\b(API_KEY|TOKEN|PASSWORD|AUTHORIZATION)\s*=\s*[^\s]+',
|
||||
lambda match: f"{match.group(1)}=[REDACTED]",
|
||||
text,
|
||||
)
|
||||
for value in dotenv_vars.values():
|
||||
value = str(value or "")
|
||||
if len(value) >= 6:
|
||||
text = text.replace(value, "[REDACTED]")
|
||||
return text
|
||||
|
||||
def _get_safety_warning(self, command: str) -> str:
|
||||
"""
|
||||
Get safety warning for absolutely catastrophic commands only.
|
||||
@@ -293,3 +409,43 @@ SAFETY:
|
||||
return m.group(0)
|
||||
|
||||
return re.sub(r'\$\{(\w+)\}|\$(\w+)', replace_match, command)
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_long_python_c(command: str):
|
||||
"""
|
||||
Rewrite `python -c "<code>"` into `python <tempfile>` to bypass the
|
||||
cmd.exe command line length limit on Windows.
|
||||
|
||||
Returns (new_command, temp_file_path). On any parse failure the original
|
||||
command and None are returned, so behavior is unchanged when unmatched.
|
||||
"""
|
||||
# Match: <python|python3|py> [flags] -c "<code>" (single or double quoted)
|
||||
m = re.search(
|
||||
r'^(?P<prefix>.*?\b(?:python3?|py)\b[^\n]*?\s-c\s+)'
|
||||
r'(?P<quote>["\'])(?P<code>.*)(?P=quote)\s*(?P<suffix>.*)$',
|
||||
command,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not m:
|
||||
return command, None
|
||||
|
||||
quote = m.group("quote")
|
||||
code = m.group("code")
|
||||
# Reverse common shell-level escaping of the quote char inside the code.
|
||||
code = code.replace("\\" + quote, quote)
|
||||
|
||||
try:
|
||||
fd, path = tempfile.mkstemp(suffix=".py", prefix="bash-pyc-")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(code)
|
||||
except OSError:
|
||||
return command, None
|
||||
|
||||
prefix = m.group("prefix")
|
||||
# Drop the trailing "-c " from the prefix, keep the interpreter + flags.
|
||||
interp = re.sub(r'\s-c\s+$', ' ', prefix).rstrip()
|
||||
suffix = m.group("suffix").strip()
|
||||
new_command = f'{interp} "{path}"'
|
||||
if suffix:
|
||||
new_command += f' {suffix}'
|
||||
return new_command, path
|
||||
|
||||
@@ -15,15 +15,24 @@ Launch modes (configured under `tools.browser` in config.json):
|
||||
- fresh: Set `persistent` to false to fall back to a clean context every run.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
from agent.tools.browser.browser_service import BrowserService
|
||||
from common.log import logger
|
||||
|
||||
|
||||
# Cloud-metadata endpoints worth blocking even though they are not link-local.
|
||||
# (169.254.169.254 — AWS/GCP/Azure IMDS — is already covered by is_link_local;
|
||||
# fd00:ec2::254 is the AWS IPv6 IMDS address.)
|
||||
_CLOUD_METADATA_IPS = frozenset({ipaddress.ip_address("fd00:ec2::254")})
|
||||
|
||||
|
||||
class BrowserTool(BaseTool):
|
||||
"""Single tool exposing all browser actions via an 'action' parameter."""
|
||||
|
||||
@@ -121,6 +130,61 @@ class BrowserTool(BaseTool):
|
||||
BrowserTool._shared_service = self._service
|
||||
return self._service
|
||||
|
||||
def _allow_private_targets(self) -> bool:
|
||||
"""Whether the link-local / cloud-metadata guard is disabled.
|
||||
|
||||
Defaults to False (guard active). Loopback and RFC1918/LAN targets are
|
||||
always reachable so local dev servers work out of the box; this opt-out
|
||||
only lifts the remaining block on link-local / cloud-metadata targets,
|
||||
for an operator who deliberately needs them, by setting
|
||||
``allow_private_targets: true`` under ``tools.browser`` in config.json.
|
||||
"""
|
||||
return bool(self.config.get("allow_private_targets", False))
|
||||
|
||||
@staticmethod
|
||||
def _validate_url_safe(url: str) -> None:
|
||||
"""Reject URLs that target link-local / cloud-metadata addresses (SSRF guard).
|
||||
|
||||
Resolves the hostname to its IP address(es) and blocks any that are
|
||||
link-local (169.254.0.0/16 — which includes the 169.254.169.254
|
||||
cloud-metadata endpoint — and IPv6 fe80::/10) or a known IPv6
|
||||
cloud-metadata address. Also rejects URLs with no host, non-HTTP(S)
|
||||
schemes, or hosts that fail DNS resolution.
|
||||
|
||||
Loopback and RFC1918/LAN targets are intentionally left reachable:
|
||||
unlike the vision/web_fetch tools, the browser legitimately opens local
|
||||
pages (a dev server on ``localhost`` / ``127.0.0.1`` / a LAN IP), so a
|
||||
blanket "block all internal" policy would break that core workflow.
|
||||
|
||||
Raises:
|
||||
ValueError: if the URL targets a disallowed address.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise ValueError("URL has no hostname")
|
||||
|
||||
try:
|
||||
# Resolve all addresses for the hostname.
|
||||
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
except socket.gaierror:
|
||||
raise ValueError(f"Cannot resolve hostname: {hostname}")
|
||||
|
||||
for family, _, _, _, sockaddr in addr_infos:
|
||||
ip_str = sockaddr[0]
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
# Block only the high-risk targets — link-local (incl. the
|
||||
# 169.254.169.254 cloud-metadata endpoint) and the IPv6 metadata
|
||||
# address. Loopback and RFC1918/LAN stay reachable for local dev.
|
||||
if ip.is_link_local or ip in _CLOUD_METADATA_IPS:
|
||||
raise ValueError(
|
||||
f"URL resolves to a link-local / cloud-metadata address "
|
||||
f"({ip_str}), request blocked for security"
|
||||
)
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
action = args.get("action", "").strip().lower()
|
||||
if not action:
|
||||
@@ -148,6 +212,16 @@ class BrowserTool(BaseTool):
|
||||
# Only auto-prepend https:// for bare hosts; preserve file://, about:, data:, etc.
|
||||
if "://" not in url and not url.startswith(("about:", "data:")):
|
||||
url = "https://" + url
|
||||
# SSRF guard: for http(s) targets, reject hosts that resolve to
|
||||
# link-local / cloud-metadata addresses before the browser navigates
|
||||
# (and then auto-snapshots the page back to the model). Loopback and
|
||||
# RFC1918/LAN are allowed so local dev servers work. Non-HTTP schemes
|
||||
# (about:/data:/file:/chrome:) are not network-egress targets here.
|
||||
if url.split(":", 1)[0].lower() in ("http", "https") and not self._allow_private_targets():
|
||||
try:
|
||||
self._validate_url_safe(url)
|
||||
except ValueError as e:
|
||||
return ToolResult.fail(f"Error: {e}")
|
||||
timeout = args.get("timeout", 30000)
|
||||
service = self._get_service()
|
||||
result = service.navigate(url, timeout=timeout)
|
||||
|
||||
3
agent/tools/evolution_undo/__init__.py
Normal file
3
agent/tools/evolution_undo/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool
|
||||
|
||||
__all__ = ["EvolutionUndoTool"]
|
||||
58
agent/tools/evolution_undo/evolution_undo.py
Normal file
58
agent/tools/evolution_undo/evolution_undo.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Evolution undo tool.
|
||||
|
||||
Lets the main chat agent roll back a previous self-evolution when the user asks
|
||||
("undo the last learning"). The rollback itself is a deterministic FILE RESTORE
|
||||
from the snapshot taken before the evolution — the model only supplies the
|
||||
backup_id it reads from the [EVOLUTION] record in the conversation. No LLM-driven
|
||||
re-editing is involved, so a restore can never make things worse.
|
||||
"""
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
|
||||
|
||||
class EvolutionUndoTool(BaseTool):
|
||||
"""Restore memory/skill files to the state before a self-evolution."""
|
||||
|
||||
name: str = "evolution_undo"
|
||||
description: str = (
|
||||
"Undo a previous self-evolution (self-learning) by restoring the "
|
||||
"memory/skill files to their state before that learning. Use this when "
|
||||
"the user asks to undo / revert / roll back the last self-learning. "
|
||||
"Find the backup_id in the most recent [EVOLUTION] record in the "
|
||||
"conversation and pass it here."
|
||||
)
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"backup_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The backup_id from the [EVOLUTION] record to restore "
|
||||
"(e.g. '20260607-155551-850')."
|
||||
),
|
||||
}
|
||||
},
|
||||
"required": ["backup_id"],
|
||||
}
|
||||
|
||||
def execute(self, args: dict):
|
||||
backup_id = (args.get("backup_id") or "").strip()
|
||||
if not backup_id:
|
||||
return ToolResult.fail("Error: backup_id is required")
|
||||
try:
|
||||
from agent.memory.config import get_default_memory_config
|
||||
from agent.evolution.backup import restore_backup
|
||||
|
||||
workspace_dir = get_default_memory_config().get_workspace()
|
||||
ok = restore_backup(workspace_dir, backup_id)
|
||||
if ok:
|
||||
return ToolResult.success(
|
||||
f"Restored memory/skills to the state before evolution "
|
||||
f"{backup_id}. The previous self-learning has been undone."
|
||||
)
|
||||
return ToolResult.fail(
|
||||
f"Could not find or restore backup {backup_id}. It may have "
|
||||
f"expired or already been rolled back."
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error during undo: {e}")
|
||||
@@ -7,7 +7,7 @@ without any external MCP SDK dependency.
|
||||
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import urllib.request
|
||||
@@ -34,6 +34,8 @@ class McpClient:
|
||||
self.config = config
|
||||
self.name: str = config.get("name", "unknown")
|
||||
raw_transport: str = config.get("type", "stdio")
|
||||
# Per-server timeout for tool calls (default 120s, suitable for data queries)
|
||||
self._timeout: int = int(config.get("timeout", 120))
|
||||
# Normalize streamable-http aliases to a single internal key
|
||||
self.transport: str = (
|
||||
"streamable-http"
|
||||
@@ -43,6 +45,7 @@ class McpClient:
|
||||
|
||||
# stdio state
|
||||
self._proc: Optional[subprocess.Popen] = None
|
||||
self._read_queue: queue.Queue = queue.Queue()
|
||||
|
||||
# SSE state
|
||||
self._sse_url: Optional[str] = None
|
||||
@@ -56,7 +59,13 @@ class McpClient:
|
||||
# Shared state
|
||||
self._next_id = 1
|
||||
self._id_lock = threading.Lock()
|
||||
# _call_lock serializes all requests on the single stdio pipe.
|
||||
# SSE and streamable-http use independent HTTP requests, so they
|
||||
# do not acquire this lock (see _send_request).
|
||||
self._call_lock = threading.Lock()
|
||||
# _http_lock protects _http_session_id initialization across
|
||||
# concurrent streamable-http requests.
|
||||
self._http_lock = threading.Lock()
|
||||
self._initialized = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -172,6 +181,9 @@ class McpClient:
|
||||
threading.Thread(
|
||||
target=self._drain_stderr, daemon=True, name=f"mcp-stderr-{self.name}"
|
||||
).start()
|
||||
threading.Thread(
|
||||
target=self._drain_stdout, daemon=True, name=f"mcp-stdout-{self.name}"
|
||||
).start()
|
||||
|
||||
return self._handshake()
|
||||
|
||||
@@ -179,14 +191,35 @@ class McpClient:
|
||||
for line in self._proc.stderr:
|
||||
line = line.strip()
|
||||
if line:
|
||||
logger.debug(f"[MCP:{self.name}] stderr: {line}")
|
||||
logger.warning(f"[MCP:{self.name}] stderr: {line}")
|
||||
|
||||
def _readline_with_timeout(self, timeout: int = 30) -> str:
|
||||
"""Read one line from stdio stdout with a hard timeout."""
|
||||
ready, _, _ = select.select([self._proc.stdout], [], [], timeout)
|
||||
if not ready:
|
||||
raise TimeoutError(f"[MCP:{self.name}] stdio read timed out after {timeout}s")
|
||||
return self._proc.stdout.readline()
|
||||
def _drain_stdout(self):
|
||||
"""Background thread: read lines from stdout and put them into the queue."""
|
||||
try:
|
||||
for line in self._proc.stdout:
|
||||
self._read_queue.put(line)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
self._read_queue.put("")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _readline_with_timeout(self, timeout: Optional[int] = None) -> str:
|
||||
"""Read one line from stdio stdout with a hard timeout (cross-platform).
|
||||
|
||||
Uses the per-server timeout from mcp.json config when no explicit
|
||||
timeout is provided.
|
||||
"""
|
||||
effective = timeout if timeout is not None else self._timeout
|
||||
try:
|
||||
line = self._read_queue.get(timeout=effective)
|
||||
except queue.Empty:
|
||||
raise TimeoutError(f"[MCP:{self.name}] stdio read timed out after {effective}s")
|
||||
if not line:
|
||||
raise IOError(f"[MCP:{self.name}] stdio process closed unexpectedly")
|
||||
return line
|
||||
|
||||
def _stdio_send(self, message: dict) -> dict:
|
||||
"""Send a JSON-RPC message over stdio and read the response."""
|
||||
@@ -194,6 +227,7 @@ class McpClient:
|
||||
self._proc.stdin.write(raw)
|
||||
self._proc.stdin.flush()
|
||||
|
||||
expected_id = message.get("id")
|
||||
while True:
|
||||
line = self._readline_with_timeout()
|
||||
if not line:
|
||||
@@ -208,6 +242,14 @@ class McpClient:
|
||||
if "id" not in data:
|
||||
logger.debug(f"[MCP:{self.name}] notification skipped: {data.get('method', '?')}")
|
||||
continue
|
||||
# Verify response id matches request id to avoid consuming a stale
|
||||
# response left over from a previously failed/timed-out request.
|
||||
if data.get("id") != expected_id:
|
||||
logger.warning(
|
||||
f"[MCP:{self.name}] Stale response id={data.get('id')} "
|
||||
f"(expected {expected_id}), skipping"
|
||||
)
|
||||
continue
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -302,8 +344,12 @@ class McpClient:
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
}
|
||||
if self._http_session_id:
|
||||
headers["Mcp-Session-Id"] = self._http_session_id
|
||||
# Read session id under lock to avoid racing with the
|
||||
# initialization write below during concurrent requests.
|
||||
with self._http_lock:
|
||||
sid = self._http_session_id
|
||||
if sid:
|
||||
headers["Mcp-Session-Id"] = sid
|
||||
headers.update(self._http_headers)
|
||||
|
||||
req = urllib.request.Request(
|
||||
@@ -329,8 +375,13 @@ class McpClient:
|
||||
with resp:
|
||||
# Capture session id assigned by the server (if any)
|
||||
session_id = resp.headers.get("Mcp-Session-Id")
|
||||
# Double-checked lock: only the first response sets the
|
||||
# session id, preventing concurrent initializers from
|
||||
# overwriting each other.
|
||||
if session_id and not self._http_session_id:
|
||||
self._http_session_id = session_id
|
||||
with self._http_lock:
|
||||
if not self._http_session_id:
|
||||
self._http_session_id = session_id
|
||||
|
||||
status = resp.status if hasattr(resp, "status") else resp.getcode()
|
||||
|
||||
@@ -409,15 +460,18 @@ class McpClient:
|
||||
|
||||
message = self._build_request(method, params)
|
||||
|
||||
with self._call_lock:
|
||||
if self.transport == "stdio":
|
||||
# stdio transport uses a single pipe and must be serialized.
|
||||
# SSE and streamable-http use independent HTTP requests and
|
||||
# can safely run concurrently across sessions.
|
||||
if self.transport == "stdio":
|
||||
with self._call_lock:
|
||||
return self._stdio_send(message)
|
||||
elif self.transport == "sse":
|
||||
return self._sse_send(message)
|
||||
elif self.transport == "streamable-http":
|
||||
return self._streamable_http_send(message)
|
||||
else:
|
||||
raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}")
|
||||
elif self.transport == "sse":
|
||||
return self._sse_send(message)
|
||||
elif self.transport == "streamable-http":
|
||||
return self._streamable_http_send(message)
|
||||
else:
|
||||
raise ValueError(f"[MCP:{self.name}] Unsupported transport: {self.transport}")
|
||||
|
||||
def _send_notification(self, method: str, params: dict):
|
||||
"""Fire-and-forget notification (no response expected)."""
|
||||
|
||||
@@ -4,6 +4,8 @@ Memory get tool
|
||||
Allows agents to read specific sections from memory files
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from agent.tools.base_tool import BaseTool
|
||||
|
||||
|
||||
@@ -87,8 +89,13 @@ class MemoryGetTool(BaseTool):
|
||||
|
||||
file_path = (workspace_dir / path).resolve()
|
||||
workspace_resolved = workspace_dir.resolve()
|
||||
|
||||
if not str(file_path).startswith(str(workspace_resolved) + '/') and file_path != workspace_resolved:
|
||||
|
||||
# Use os.path.realpath + os.sep for cross-platform path validation.
|
||||
# str(Path).startswith(str + '/') fails on Windows where Path uses
|
||||
# backslashes — see MemoryService._resolve_path for the same pattern.
|
||||
real_file = os.path.realpath(str(file_path))
|
||||
real_workspace = os.path.realpath(str(workspace_resolved))
|
||||
if real_file != real_workspace and not real_file.startswith(real_workspace + os.sep):
|
||||
return ToolResult.fail(f"Error: Access denied: path outside workspace")
|
||||
|
||||
if not file_path.exists():
|
||||
|
||||
@@ -182,8 +182,15 @@ class TaskStore:
|
||||
if enabled_only:
|
||||
task_list = [t for t in task_list if t.get("enabled", True)]
|
||||
|
||||
# Sort by next_run_at
|
||||
task_list.sort(key=lambda t: t.get("next_run_at", float('inf')))
|
||||
# Sort by enabled status (enabled first), then by next_run_at
|
||||
def sort_key(t):
|
||||
enabled = t.get("enabled", True)
|
||||
next_run = t.get("next_run_at", "")
|
||||
# Enabled tasks first (0), disabled tasks second (1)
|
||||
# Then sort by next_run_at (empty string sorts last)
|
||||
return (0 if enabled else 1, next_run if next_run else "9999-12-31")
|
||||
|
||||
task_list.sort(key=sort_key)
|
||||
|
||||
return task_list
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ from .diff import (
|
||||
FuzzyMatchResult
|
||||
)
|
||||
|
||||
from .url_safety import (
|
||||
validate_url_safe,
|
||||
assert_public_ip
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'truncate_head',
|
||||
'truncate_tail',
|
||||
@@ -36,5 +41,7 @@ __all__ = [
|
||||
'normalize_for_fuzzy_match',
|
||||
'fuzzy_find_text',
|
||||
'generate_diff_string',
|
||||
'FuzzyMatchResult'
|
||||
'FuzzyMatchResult',
|
||||
'validate_url_safe',
|
||||
'assert_public_ip'
|
||||
]
|
||||
|
||||
66
agent/tools/utils/url_safety.py
Normal file
66
agent/tools/utils/url_safety.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Shared SSRF guard utilities for tools that fetch model-supplied URLs.
|
||||
|
||||
A URL is only considered safe when it uses an http/https scheme, has a
|
||||
hostname, that hostname resolves, and every resolved address is a public
|
||||
(internet-routable) address. Loopback, private (RFC1918 / ULA), link-local
|
||||
(incl. the 169.254.169.254 cloud-metadata endpoint) and otherwise reserved
|
||||
addresses are rejected, for both IPv4 and IPv6.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
|
||||
"""Return True if the address is not safe to connect to (non-public)."""
|
||||
return (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def assert_public_ip(ip_str: str) -> None:
|
||||
"""Raise ValueError if the given literal IP is a non-public address.
|
||||
|
||||
Used to re-validate the concrete address a redirect resolved to.
|
||||
"""
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
if _is_blocked_ip(ip):
|
||||
raise ValueError(
|
||||
f"URL resolves to a non-public address ({ip_str}), "
|
||||
f"request blocked for security"
|
||||
)
|
||||
|
||||
|
||||
def validate_url_safe(url: str) -> None:
|
||||
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
|
||||
|
||||
Resolves the hostname to its IP address(es) and blocks any that fall
|
||||
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
|
||||
schemes, or hosts that fail DNS resolution.
|
||||
|
||||
Raises:
|
||||
ValueError: if the URL targets a disallowed address.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise ValueError("URL has no hostname")
|
||||
|
||||
try:
|
||||
# Resolve all addresses for the hostname.
|
||||
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
except socket.gaierror:
|
||||
raise ValueError(f"Cannot resolve hostname: {hostname}")
|
||||
|
||||
for family, _, _, _, sockaddr in addr_infos:
|
||||
assert_public_ip(sockaddr[0])
|
||||
@@ -26,13 +26,14 @@ from typing import Any, Dict, List, Optional
|
||||
import requests
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
from agent.tools.utils.url_safety import validate_url_safe
|
||||
from common import const
|
||||
from common.log import logger
|
||||
from config import conf
|
||||
|
||||
DEFAULT_MODEL = const.GPT_41_MINI
|
||||
DEFAULT_TIMEOUT = 60
|
||||
MAX_TOKENS = 1000
|
||||
DEFAULT_TIMEOUT = 180
|
||||
MAX_TOKENS = 4000
|
||||
COMPRESS_THRESHOLD = 1_048_576 # 1 MB
|
||||
|
||||
SUPPORTED_EXTENSIONS = {
|
||||
@@ -51,7 +52,7 @@ _MAIN_MODEL_PROVIDER_NAME = "MainModel"
|
||||
_DISCOVERABLE_MODELS = [
|
||||
("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"),
|
||||
("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"),
|
||||
("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN36_PLUS, "DashScope"),
|
||||
("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN37_PLUS, "DashScope"),
|
||||
("claude_api_key", const.CLAUDEAPI, const.CLAUDE_4_6_SONNET, "Claude"),
|
||||
("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"),
|
||||
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
|
||||
@@ -161,7 +162,7 @@ class Vision(BaseTool):
|
||||
"Error: No model available for Vision.\n"
|
||||
"The main model does not support vision and no other API keys are configured.\n"
|
||||
"Options:\n"
|
||||
" 1. Switch to a multimodal model (e.g. ernie-4.5-turbo-vl, qwen3.6-plus, claude-sonnet-4-6, gemini-2.0-flash)\n"
|
||||
" 1. Switch to a multimodal model (e.g. ernie-4.5-turbo-vl, qwen3.7-plus, claude-sonnet-4-6, gemini-2.0-flash)\n"
|
||||
" 2. Configure OPENAI_API_KEY: env_config(action=\"set\", key=\"OPENAI_API_KEY\", value=\"your-key\")\n"
|
||||
" 3. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")"
|
||||
)
|
||||
@@ -330,6 +331,12 @@ class Vision(BaseTool):
|
||||
- None : unknown provider id, or the bot can't be created.
|
||||
Caller falls through to model-name-based routing.
|
||||
"""
|
||||
# Custom OpenAI-compatible providers — read credentials from
|
||||
# custom_providers list, same pattern as embedding.
|
||||
if provider_id.startswith("custom:"):
|
||||
p = self._build_custom_provider(provider_id, user_model)
|
||||
return [p] if p else None
|
||||
|
||||
display_name = _PROVIDER_ID_TO_DISPLAY.get(provider_id)
|
||||
if not display_name:
|
||||
return None
|
||||
@@ -595,6 +602,34 @@ class Vision(BaseTool):
|
||||
model_override=preferred_model,
|
||||
)
|
||||
|
||||
def _build_custom_provider(self, provider_id: str, preferred_model: Optional[str] = None) -> Optional[VisionProvider]:
|
||||
"""Build a VisionProvider from a custom:<id> entry in custom_providers.
|
||||
Uses the standard OpenAI /chat/completions endpoint — any
|
||||
OpenAI-compatible multimodal endpoint works."""
|
||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||
_, custom_id = parse_custom_bot_type(provider_id)
|
||||
if not custom_id:
|
||||
return None
|
||||
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||
if not entry:
|
||||
logger.warning(f"[Vision] custom provider '{provider_id}' not found in custom_providers")
|
||||
return None
|
||||
api_key = (entry.get("api_key") or "").strip()
|
||||
api_base = (entry.get("api_base") or "").strip()
|
||||
if not api_key or not api_base:
|
||||
logger.warning(f"[Vision] custom provider '{provider_id}' missing api_key or api_base")
|
||||
return None
|
||||
model = preferred_model or entry.get("model") or ""
|
||||
if not model:
|
||||
logger.warning(f"[Vision] custom provider '{provider_id}' has no model configured")
|
||||
return None
|
||||
return VisionProvider(
|
||||
name=entry.get("name") or provider_id,
|
||||
api_key=api_key,
|
||||
api_base=self._ensure_v1(api_base.rstrip("/")),
|
||||
model_override=model,
|
||||
)
|
||||
|
||||
def _call_via_bot(self, model: str, question: str, image_content: dict,
|
||||
provider: Optional[VisionProvider] = None) -> ToolResult:
|
||||
"""
|
||||
@@ -654,6 +689,22 @@ class Vision(BaseTool):
|
||||
return api_base
|
||||
return api_base.rstrip("/") + "/v1"
|
||||
|
||||
@staticmethod
|
||||
def _validate_url_safe(url: str) -> None:
|
||||
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
|
||||
|
||||
Resolves the hostname to its IP address(es) and blocks any that fall
|
||||
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
|
||||
schemes, or hosts that fail DNS resolution.
|
||||
|
||||
Delegates to the shared ``agent.tools.utils.url_safety`` helper so the
|
||||
same guard protects every tool that fetches model-supplied URLs.
|
||||
|
||||
Raises:
|
||||
ValueError: if the URL targets a disallowed address.
|
||||
"""
|
||||
validate_url_safe(url)
|
||||
|
||||
def _build_image_content(self, image: str) -> dict:
|
||||
"""
|
||||
Build the image_url content block.
|
||||
@@ -661,6 +712,7 @@ class Vision(BaseTool):
|
||||
so every bot backend can consume them without extra downloads.
|
||||
"""
|
||||
if image.startswith(("http://", "https://")):
|
||||
self._validate_url_safe(image)
|
||||
return self._download_to_data_url(image)
|
||||
|
||||
if not os.path.isfile(image):
|
||||
|
||||
@@ -16,11 +16,15 @@ import requests
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
from agent.tools.utils.truncate import truncate_head, format_size
|
||||
from agent.tools.utils.url_safety import validate_url_safe
|
||||
from common.log import logger
|
||||
|
||||
|
||||
DEFAULT_TIMEOUT = 30
|
||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
||||
# Cap on how many redirects we follow; each hop's target is re-validated
|
||||
# against the SSRF guard so a public URL cannot bounce us into an internal one.
|
||||
MAX_REDIRECTS = 10
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
@@ -107,23 +111,65 @@ class WebFetch(BaseTool):
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return ToolResult.fail("Error: Invalid URL (must start with http:// or https://)")
|
||||
|
||||
# SSRF guard: reject URLs that resolve to private/loopback/link-local/
|
||||
# cloud-metadata addresses before any request is issued.
|
||||
try:
|
||||
validate_url_safe(url)
|
||||
except ValueError as e:
|
||||
return ToolResult.fail(f"Error: {e}")
|
||||
|
||||
if _is_document_url(url):
|
||||
return self._fetch_document(url)
|
||||
|
||||
return self._fetch_webpage(url)
|
||||
|
||||
# ---- Safe request helper ----
|
||||
|
||||
@staticmethod
|
||||
def _safe_get(url: str, **kwargs) -> requests.Response:
|
||||
"""Issue a GET request while re-validating every redirect hop (SSRF guard).
|
||||
|
||||
Auto-redirect is disabled and each hop is followed manually so the
|
||||
target of every redirect is re-resolved and checked against the SSRF
|
||||
guard. This prevents a public URL from 3xx-bouncing into a private,
|
||||
loopback, link-local or cloud-metadata address. ``kwargs`` are passed
|
||||
through to ``requests.get`` (e.g. ``stream``).
|
||||
|
||||
Raises:
|
||||
ValueError: if any hop resolves to a non-public address.
|
||||
"""
|
||||
kwargs.pop("allow_redirects", None)
|
||||
current = url
|
||||
for _ in range(MAX_REDIRECTS + 1):
|
||||
response = requests.get(
|
||||
current,
|
||||
headers=DEFAULT_HEADERS,
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
allow_redirects=False,
|
||||
**kwargs,
|
||||
)
|
||||
if not response.is_redirect and not response.is_permanent_redirect:
|
||||
return response
|
||||
|
||||
location = response.headers.get("Location")
|
||||
if not location:
|
||||
return response
|
||||
|
||||
# Resolve the redirect target relative to the current URL, then
|
||||
# re-validate it before following.
|
||||
current = requests.compat.urljoin(current, location)
|
||||
validate_url_safe(current)
|
||||
response.close()
|
||||
|
||||
raise ValueError(f"Too many redirects (>{MAX_REDIRECTS})")
|
||||
|
||||
# ---- Web page fetching ----
|
||||
|
||||
def _fetch_webpage(self, url: str) -> ToolResult:
|
||||
"""Fetch and extract readable text from an HTML web page."""
|
||||
parsed = urlparse(url)
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=DEFAULT_HEADERS,
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
allow_redirects=True,
|
||||
)
|
||||
response = self._safe_get(url)
|
||||
response.raise_for_status()
|
||||
except requests.Timeout:
|
||||
return ToolResult.fail(f"Error: Request timed out after {DEFAULT_TIMEOUT}s")
|
||||
@@ -131,6 +177,8 @@ class WebFetch(BaseTool):
|
||||
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
|
||||
except requests.HTTPError as e:
|
||||
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
|
||||
except ValueError as e:
|
||||
return ToolResult.fail(f"Error: {e}")
|
||||
except Exception as e:
|
||||
return ToolResult.fail(f"Error: Failed to fetch URL: {e}")
|
||||
|
||||
@@ -158,13 +206,7 @@ class WebFetch(BaseTool):
|
||||
logger.info(f"[WebFetch] Downloading document: {url} -> {local_path}")
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=DEFAULT_HEADERS,
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
stream=True,
|
||||
allow_redirects=True,
|
||||
)
|
||||
response = self._safe_get(url, stream=True)
|
||||
response.raise_for_status()
|
||||
|
||||
content_length = int(response.headers.get("Content-Length", 0))
|
||||
@@ -191,6 +233,9 @@ class WebFetch(BaseTool):
|
||||
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
|
||||
except requests.HTTPError as e:
|
||||
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
|
||||
except ValueError as e:
|
||||
self._cleanup_file(local_path)
|
||||
return ToolResult.fail(f"Error: {e}")
|
||||
except Exception as e:
|
||||
self._cleanup_file(local_path)
|
||||
return ToolResult.fail(f"Error: Failed to download file: {e}")
|
||||
|
||||
24
app.py
24
app.py
@@ -15,6 +15,11 @@ import threading
|
||||
|
||||
_channel_mgr = None
|
||||
|
||||
# Desktop mode: a lighter runtime for the packaged Electron client. The plugin
|
||||
# framework is still bundled (it's tiny and on the web channel's import path),
|
||||
# but we skip loading actual plugins and MCP tools to keep startup fast.
|
||||
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
|
||||
|
||||
|
||||
def get_channel_manager():
|
||||
return _channel_mgr
|
||||
@@ -75,7 +80,7 @@ class ChannelManager:
|
||||
if self._primary_channel is None and channels:
|
||||
self._primary_channel = channels[0][1]
|
||||
|
||||
if first_start:
|
||||
if first_start and not DESKTOP_MODE:
|
||||
PluginManager().load_plugins()
|
||||
|
||||
# Cloud client is optional. It is only started when
|
||||
@@ -236,6 +241,9 @@ def _clear_singleton_cache(channel_name: str):
|
||||
const.DINGTALK: "channel.dingtalk.dingtalk_channel.DingTalkChanel",
|
||||
const.WECOM_BOT: "channel.wecom_bot.wecom_bot_channel.WecomBotChannel",
|
||||
const.QQ: "channel.qq.qq_channel.QQChannel",
|
||||
const.TELEGRAM: "channel.telegram.telegram_channel.TelegramChannel",
|
||||
const.SLACK: "channel.slack.slack_channel.SlackChannel",
|
||||
const.DISCORD: "channel.discord.discord_channel.DiscordChannel",
|
||||
const.WEIXIN: "channel.weixin.weixin_channel.WeixinChannel",
|
||||
"wx": "channel.weixin.weixin_channel.WeixinChannel",
|
||||
}
|
||||
@@ -361,10 +369,18 @@ def run():
|
||||
_sync_builtin_skills()
|
||||
|
||||
# Kick off MCP server loading in the background so first-message
|
||||
# latency isn't dominated by npx package downloads.
|
||||
_warmup_mcp_tools()
|
||||
# latency isn't dominated by npx package downloads. Skipped in desktop
|
||||
# mode (MCP relies on external npx/uvx runtimes that aren't bundled).
|
||||
if not DESKTOP_MODE:
|
||||
_warmup_mcp_tools()
|
||||
|
||||
_warmup_scheduler()
|
||||
if DESKTOP_MODE:
|
||||
# Defer the (heavy) AgentBridge/scheduler warmup to a background
|
||||
# thread so the web API becomes available within a couple seconds.
|
||||
# The scheduler still starts; it just doesn't block UI readiness.
|
||||
threading.Thread(target=_warmup_scheduler, daemon=True).start()
|
||||
else:
|
||||
_warmup_scheduler()
|
||||
|
||||
logger.info(f"[App] Starting channels: {channel_names}")
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ class AgentLLMModel(LLMModel):
|
||||
("moonshot", const.MOONSHOT), ("kimi", const.MOONSHOT),
|
||||
("doubao", const.DOUBAO), ("deepseek", const.DEEPSEEK),
|
||||
("ernie", const.QIANFAN),
|
||||
("mimo-", const.MIMO),
|
||||
]
|
||||
|
||||
def __init__(self, bridge: Bridge, bot_type: str = "chat"):
|
||||
@@ -294,6 +295,14 @@ class AgentBridge:
|
||||
self.scheduler_initialized = True
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentBridge] Eager scheduler init failed: {e}")
|
||||
|
||||
# Start the self-evolution idle trigger (idempotent, daemon thread).
|
||||
try:
|
||||
from agent.evolution.trigger import start_evolution_trigger
|
||||
start_evolution_trigger(self)
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentBridge] Evolution trigger init failed: {e}")
|
||||
|
||||
def create_agent(self, system_prompt: str, tools: List = None, **kwargs) -> Agent:
|
||||
"""
|
||||
Create the super agent with COW integration
|
||||
@@ -382,7 +391,49 @@ class AgentBridge:
|
||||
"""Initialize agent for a specific session"""
|
||||
agent = self.initializer.initialize_agent(session_id=session_id)
|
||||
self.agents[session_id] = agent
|
||||
|
||||
|
||||
def sync_session_messages_from_store(self, session_id: str) -> int:
|
||||
"""Reload an agent's in-memory ``messages`` list from the persistent
|
||||
conversation store.
|
||||
|
||||
Used after an external mutation (e.g. user edits / deletes a message
|
||||
via the web console) so the agent's next turn sees the same history
|
||||
as the database. The operation is a no-op when the agent has not been
|
||||
instantiated yet for the session.
|
||||
|
||||
Returns:
|
||||
Number of messages now held in the agent's memory. Returns -1 if
|
||||
the agent does not exist or has no compatible ``messages`` attr.
|
||||
"""
|
||||
if not session_id or session_id not in self.agents:
|
||||
return -1
|
||||
agent = self.agents[session_id]
|
||||
if not (hasattr(agent, "messages") and hasattr(agent, "messages_lock")):
|
||||
return -1
|
||||
try:
|
||||
from agent.memory import get_conversation_store
|
||||
store = get_conversation_store()
|
||||
# No turn cap here: we want a faithful mirror of what the store
|
||||
# has for this session after deletion.
|
||||
remaining = store.load_messages(session_id, max_turns=10**6)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[AgentBridge] Failed to load messages for sync (session={session_id}): {e}"
|
||||
)
|
||||
return -1
|
||||
with agent.messages_lock:
|
||||
agent.messages.clear()
|
||||
for msg in remaining:
|
||||
agent.messages.append({
|
||||
"role": msg["role"],
|
||||
"content": msg["content"],
|
||||
})
|
||||
count = len(agent.messages)
|
||||
logger.info(
|
||||
f"[AgentBridge] Synced agent memory for session={session_id}, messages={count}"
|
||||
)
|
||||
return count
|
||||
|
||||
def agent_reply(self, query: str, context: Context = None,
|
||||
on_event=None, clear_history: bool = False) -> Reply:
|
||||
"""
|
||||
@@ -464,6 +515,24 @@ class AgentBridge:
|
||||
)
|
||||
self._trim_in_memory_to_turns(agent, scheduler_keep_turns)
|
||||
|
||||
# Eagerly persist the user message BEFORE running the agent so the
|
||||
# session and the user's bubble are immediately visible — even if
|
||||
# the user switches away or refreshes before the reply finishes.
|
||||
# The reply (assistant/tool messages) is appended once the run
|
||||
# completes; the final persist skips this already-stored user turn.
|
||||
pre_persisted = self._pre_persist_user_message(
|
||||
session_id, query, context, clear_history
|
||||
)
|
||||
|
||||
# Mark this session as mid-run so the self-evolution idle scan does
|
||||
# not fire concurrently when a single turn runs longer than
|
||||
# idle_minutes.
|
||||
try:
|
||||
from agent.evolution.trigger import mark_run_active
|
||||
mark_run_active(agent, True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Use agent's run_stream method with event handler
|
||||
response = agent.run_stream(
|
||||
@@ -473,6 +542,13 @@ class AgentBridge:
|
||||
cancel_event=cancel_event,
|
||||
)
|
||||
finally:
|
||||
# Clear the mid-run flag so idle scans can review this session.
|
||||
try:
|
||||
from agent.evolution.trigger import mark_run_active
|
||||
mark_run_active(agent, False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Restore original tools
|
||||
if context and context.get("is_scheduled_task"):
|
||||
agent.tools = original_tools
|
||||
@@ -490,7 +566,11 @@ class AgentBridge:
|
||||
# Persist new messages generated during this run
|
||||
if session_id:
|
||||
channel_type = (context.get("channel_type") or "") if context else ""
|
||||
new_messages = getattr(agent, '_last_run_new_messages', [])
|
||||
new_messages = list(getattr(agent, '_last_run_new_messages', []))
|
||||
# The leading user turn was already persisted eagerly above;
|
||||
# drop it here so it isn't stored twice.
|
||||
if pre_persisted and new_messages and new_messages[0].get("role") == "user":
|
||||
new_messages = new_messages[1:]
|
||||
if new_messages:
|
||||
self._persist_messages(session_id, list(new_messages), channel_type)
|
||||
else:
|
||||
@@ -504,6 +584,23 @@ class AgentBridge:
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentBridge] Failed to clear DB after recovery: {e}")
|
||||
|
||||
# Record this user turn for the self-evolution idle trigger. Skip
|
||||
# scheduler-injected / scheduled-task sessions so internal runs do
|
||||
# not count as user activity.
|
||||
if session_id and not session_id.startswith("scheduler_") and not (
|
||||
context and context.get("is_scheduled_task")
|
||||
):
|
||||
try:
|
||||
from agent.evolution.trigger import note_user_turn
|
||||
ch = (context.get("channel_type") or "") if context else ""
|
||||
rcv = (context.get("receiver") or "") if context else ""
|
||||
is_group = bool(context.get("isgroup")) if context else False
|
||||
# Only enable proactive push for single chats (group push is
|
||||
# noisy); group sessions still evolve, just without notify.
|
||||
note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else ""))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Post-message hot-reload: detect edits to ~/cow/mcp.json and
|
||||
# sync any new/removed MCP tools into the live agent in the
|
||||
# background. Off the critical path so user latency is unaffected;
|
||||
@@ -689,6 +786,48 @@ class AgentBridge:
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentBridge] Failed to sync API keys: {e}")
|
||||
|
||||
def _pre_persist_user_message(
|
||||
self, session_id: str, query: str, context: Context, clear_history: bool
|
||||
) -> bool:
|
||||
"""Persist the user's message before the agent runs.
|
||||
|
||||
This makes a brand-new session (and the user's bubble) visible even if
|
||||
the reply hasn't finished — switching away or refreshing no longer
|
||||
loses the in-flight session. Returns True when the user turn was
|
||||
stored, so the caller can skip it in the post-run persist.
|
||||
|
||||
Best-effort: any failure is swallowed and reported as not-persisted.
|
||||
"""
|
||||
if not session_id or not query:
|
||||
return False
|
||||
# Only real user turns: skip scheduler-injected / scheduled-task runs.
|
||||
if session_id.startswith("scheduler_") or (
|
||||
context and context.get("is_scheduled_task")
|
||||
):
|
||||
return False
|
||||
try:
|
||||
from config import conf
|
||||
if not conf().get("conversation_persistence", True):
|
||||
return False
|
||||
from agent.memory import get_conversation_store
|
||||
store = get_conversation_store()
|
||||
# clear_history starts a fresh transcript: wipe the store first so
|
||||
# the eager user turn becomes seq 0, matching in-memory state.
|
||||
if clear_history:
|
||||
store.clear_session(session_id)
|
||||
channel_type = (context.get("channel_type") or "") if context else ""
|
||||
user_msg = {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": query}],
|
||||
}
|
||||
store.append_messages(session_id, [user_msg], channel_type=channel_type)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[AgentBridge] Failed to pre-persist user message for session={session_id}: {e}"
|
||||
)
|
||||
return False
|
||||
|
||||
def _persist_messages(
|
||||
self, session_id: str, new_messages: list, channel_type: str = ""
|
||||
) -> None:
|
||||
|
||||
@@ -395,7 +395,13 @@ class AgentInitializer:
|
||||
from agent.memory.embedding import EMBEDDING_VENDORS
|
||||
from config import conf
|
||||
|
||||
meta = EMBEDDING_VENDORS.get(provider_key)
|
||||
# Custom providers ("custom:<id>") resolve credentials
|
||||
# from the custom_providers list.
|
||||
resolved_provider_key = provider_key
|
||||
if provider_key.startswith("custom:"):
|
||||
resolved_provider_key = "custom"
|
||||
|
||||
meta = EMBEDDING_VENDORS.get(resolved_provider_key)
|
||||
if meta is None:
|
||||
logger.error(
|
||||
f"[AgentInitializer] Unknown embedding_provider '{provider_key}'. "
|
||||
@@ -414,7 +420,17 @@ class AgentInitializer:
|
||||
)
|
||||
return None
|
||||
|
||||
model = (conf().get("embedding_model") or "").strip() or meta["default_model"]
|
||||
model = (conf().get("embedding_model") or "").strip()
|
||||
# Custom providers without a model fall back to the provider's default.
|
||||
if not model and resolved_provider_key == "custom":
|
||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||
_, custom_id = parse_custom_bot_type(provider_key)
|
||||
if custom_id:
|
||||
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||
if entry and entry.get("model"):
|
||||
model = entry["model"]
|
||||
if not model and resolved_provider_key != "custom":
|
||||
model = meta["default_model"]
|
||||
try:
|
||||
cfg_dim = int(conf().get("embedding_dimensions") or 0)
|
||||
except (TypeError, ValueError):
|
||||
@@ -423,7 +439,7 @@ class AgentInitializer:
|
||||
|
||||
try:
|
||||
provider = create_embedding_provider(
|
||||
provider=provider_key,
|
||||
provider=resolved_provider_key,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
@@ -450,6 +466,17 @@ class AgentInitializer:
|
||||
"""Pick the API key for an explicit embedding provider from config."""
|
||||
from config import conf
|
||||
|
||||
# Custom providers ("custom:<id>") resolve from the custom_providers list.
|
||||
if provider_key.startswith("custom:"):
|
||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||
_, custom_id = parse_custom_bot_type(provider_key)
|
||||
if custom_id:
|
||||
providers = get_custom_providers()
|
||||
entry = _find_provider_by_id(providers, custom_id)
|
||||
if entry:
|
||||
return entry.get("api_key", "")
|
||||
return ""
|
||||
|
||||
key_map = {
|
||||
"openai": "open_ai_api_key",
|
||||
"linkai": "linkai_api_key",
|
||||
@@ -470,6 +497,17 @@ class AgentInitializer:
|
||||
"""Pick the API base for an explicit embedding provider from config."""
|
||||
from config import conf
|
||||
|
||||
# Custom providers ("custom:<id>") resolve from the custom_providers list.
|
||||
if provider_key.startswith("custom:"):
|
||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||
_, custom_id = parse_custom_bot_type(provider_key)
|
||||
if custom_id:
|
||||
providers = get_custom_providers()
|
||||
entry = _find_provider_by_id(providers, custom_id)
|
||||
if entry and entry.get("api_base"):
|
||||
return entry["api_base"]
|
||||
return default_base
|
||||
|
||||
base_map = {
|
||||
"openai": "open_ai_api_base",
|
||||
"linkai": "linkai_api_base",
|
||||
@@ -524,6 +562,14 @@ class AgentInitializer:
|
||||
logger.debug("[AgentInitializer] WebSearch skipped - no search provider configured")
|
||||
continue
|
||||
|
||||
# Skip evolution_undo when self-evolution is disabled: with no
|
||||
# evolution there is nothing to roll back, so the tool is dead weight.
|
||||
if tool_name == "evolution_undo":
|
||||
from agent.evolution.config import get_evolution_config
|
||||
if not get_evolution_config().enabled:
|
||||
logger.debug("[AgentInitializer] evolution_undo skipped - self-evolution disabled")
|
||||
continue
|
||||
|
||||
# Special handling for EnvConfig tool
|
||||
if tool_name == "env_config":
|
||||
from agent.tools import EnvConfig
|
||||
|
||||
@@ -519,7 +519,10 @@ class ChatChannel(Channel):
|
||||
def cancel_session(self, session_id):
|
||||
with self.lock:
|
||||
if session_id in self.sessions:
|
||||
for future in self.futures[session_id]:
|
||||
# futures[session_id] is only created in consume() when a task is
|
||||
# dispatched, so it may be absent if cancel happens right after
|
||||
# produce() but before the first dispatch. Default to [].
|
||||
for future in self.futures.get(session_id, []):
|
||||
future.cancel()
|
||||
cnt = self.sessions[session_id][0].qsize()
|
||||
if cnt > 0:
|
||||
@@ -529,7 +532,7 @@ class ChatChannel(Channel):
|
||||
def cancel_all_session(self):
|
||||
with self.lock:
|
||||
for session_id in self.sessions:
|
||||
for future in self.futures[session_id]:
|
||||
for future in self.futures.get(session_id, []):
|
||||
future.cancel()
|
||||
cnt = self.sessions[session_id][0].qsize()
|
||||
if cnt > 0:
|
||||
|
||||
@@ -285,7 +285,7 @@
|
||||
</button>
|
||||
|
||||
<!-- Docs Link -->
|
||||
<a href="https://docs.cowagent.ai" target="_blank" rel="noopener noreferrer"
|
||||
<a id="docs-link" href="https://docs.cowagent.ai" target="_blank" rel="noopener noreferrer"
|
||||
class="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
|
||||
cursor-pointer transition-colors duration-150" title="Documentation">
|
||||
<i class="fas fa-book text-base"></i>
|
||||
@@ -620,6 +620,18 @@
|
||||
after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center gap-1.5 text-sm font-medium text-slate-600 dark:text-slate-400">
|
||||
<span data-i18n="config_self_evolution">Self-Evolution</span>
|
||||
<span class="cfg-tip" data-tip-key="config_self_evolution_hint"><i class="fas fa-circle-question"></i></span>
|
||||
</label>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input id="cfg-self-evolution" type="checkbox" class="sr-only peer">
|
||||
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-700 peer-checked:bg-primary-400 rounded-full
|
||||
after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white
|
||||
after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-full"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<span id="cfg-agent-status" class="text-xs text-primary-500 opacity-0 transition-opacity duration-300"></span>
|
||||
<button id="cfg-agent-save"
|
||||
@@ -760,7 +772,7 @@
|
||||
</button>
|
||||
<button id="memory-tab-dreams" onclick="switchMemoryTab('dreams')"
|
||||
class="memory-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-moon mr-1.5"></i><span data-i18n="memory_tab_dreams">梦境日记</span>
|
||||
<i class="fas fa-seedling mr-1.5"></i><span data-i18n="memory_tab_dreams">自主进化</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -828,6 +840,11 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
|
||||
<span id="knowledge-action-status" class="text-xs opacity-0 transition-opacity duration-200"></span>
|
||||
<button onclick="createKnowledgeCategory()"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium cursor-pointer transition-colors">
|
||||
<i class="fas fa-folder-plus"></i><span data-i18n="knowledge_new_category">新建分类</span>
|
||||
</button>
|
||||
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
|
||||
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
|
||||
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
|
||||
@@ -983,6 +1000,14 @@
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="tasks_title">定时任务</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="tasks_desc">查看和管理定时任务</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="task-refresh-btn" onclick="refreshTasksView()"
|
||||
class="px-3 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||
text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5
|
||||
text-sm font-medium cursor-pointer transition-colors duration-150">
|
||||
<i class="fas fa-refresh text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tasks-empty" class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
|
||||
@@ -1056,6 +1081,34 @@
|
||||
</div><!-- /main-content -->
|
||||
</div><!-- /app -->
|
||||
|
||||
<!-- Knowledge Action Dialog -->
|
||||
<div id="knowledge-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl w-full max-w-md mx-4 overflow-hidden">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-10 h-10 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center">
|
||||
<i id="knowledge-dialog-icon" class="fas fa-folder text-emerald-500"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="knowledge-dialog-title" class="font-semibold text-slate-800 dark:text-slate-100"></h3>
|
||||
<p id="knowledge-dialog-subtitle" class="text-xs text-slate-400 dark:text-slate-500 mt-0.5"></p>
|
||||
</div>
|
||||
</div>
|
||||
<label id="knowledge-dialog-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label>
|
||||
<input id="knowledge-dialog-input" type="text"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500">
|
||||
<select id="knowledge-dialog-select"
|
||||
class="hidden w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-[#222] text-sm text-slate-800 dark:text-slate-100 focus:outline-none focus:border-primary-500"></select>
|
||||
<p id="knowledge-dialog-hint" class="mt-2 text-xs text-slate-400 dark:text-slate-500"></p>
|
||||
<p id="knowledge-dialog-error" class="mt-2 text-xs text-red-500 hidden"></p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5">
|
||||
<button id="knowledge-dialog-cancel" class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-300 text-sm hover:bg-slate-50 dark:hover:bg-white/5">取消</button>
|
||||
<button id="knowledge-dialog-submit" class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium disabled:opacity-50">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Dialog -->
|
||||
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||
@@ -1153,6 +1206,240 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Provider Modal (multiple OpenAI-compatible providers) -->
|
||||
<div id="custom-provider-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||
w-full max-w-md mx-4">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-sliders text-primary-500"></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 id="custom-provider-modal-title" class="font-semibold text-slate-800 dark:text-slate-100 text-base"></h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5" data-i18n="models_custom_name">名称</label>
|
||||
<input id="custom-provider-name" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 transition-colors">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Base</label>
|
||||
<input id="custom-provider-base" type="text"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||
placeholder="https://...../v1">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">API Key</label>
|
||||
<input id="custom-provider-key" type="text" autocomplete="off" data-1p-ignore data-lpignore="true"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
|
||||
<button id="custom-provider-modal-delete"
|
||||
class="px-3 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||
cursor-pointer transition-colors duration-150 hidden"
|
||||
data-i18n="models_custom_delete">删除</button>
|
||||
<span id="custom-provider-modal-status"
|
||||
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
|
||||
<button id="custom-provider-modal-cancel"
|
||||
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||||
hover:bg-slate-50 dark:hover:bg-white/5
|
||||
cursor-pointer transition-colors duration-150"
|
||||
data-i18n="cancel">取消</button>
|
||||
<button id="custom-provider-modal-save"
|
||||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-i18n="save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Edit Modal -->
|
||||
<div id="task-edit-modal-overlay" class="fixed inset-0 bg-black/50 z-[100] hidden flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||
w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
<div class="w-10 h-10 rounded-xl bg-primary-50 dark:bg-primary-900/20 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-clock text-primary-500"></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="font-semibold text-slate-800 dark:text-slate-100 text-base" data-i18n="task_edit_title">编辑定时任务</h3>
|
||||
<p id="task-edit-modal-subtitle" class="text-xs text-slate-500 dark:text-slate-400 mt-0.5 font-mono"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 任务名称和启用状态 -->
|
||||
<div class="flex gap-4 items-end">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_name">任务名称</span>
|
||||
</label>
|
||||
<input id="task-edit-name" type="text"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 transition-colors"
|
||||
placeholder="任务名称">
|
||||
</div>
|
||||
<div class="flex items-center gap-2 pb-[2px]">
|
||||
<label class="text-xs font-medium text-slate-600 dark:text-slate-400">
|
||||
<span data-i18n="task_enabled">启用</span>
|
||||
</label>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="task-edit-enabled" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-500 dark:bg-slate-600 dark:peer-checked:bg-primary-500"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 调度类型 + 调度值 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_schedule_type">调度类型</span>
|
||||
</label>
|
||||
<select id="task-edit-schedule-type"
|
||||
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 transition-colors
|
||||
appearance-none bg-no-repeat bg-right"
|
||||
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
|
||||
<option value="cron" data-i18n="task_schedule_cron">Cron 表达式</option>
|
||||
<option value="interval" data-i18n="task_schedule_interval">固定间隔</option>
|
||||
<option value="once" data-i18n="task_schedule_once">一次性任务</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Cron 表达式 -->
|
||||
<div id="task-edit-cron-wrap">
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_cron_expression">Cron 表达式</span>
|
||||
</label>
|
||||
<input id="task-edit-cron-expression" type="text"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 font-mono transition-colors"
|
||||
placeholder="0 9 * * *">
|
||||
</div>
|
||||
|
||||
<!-- 固定间隔 -->
|
||||
<div id="task-edit-interval-wrap" class="hidden">
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_interval_seconds">间隔秒数</span>
|
||||
</label>
|
||||
<input id="task-edit-interval-seconds" type="number" min="60"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 transition-colors"
|
||||
placeholder="3600">
|
||||
</div>
|
||||
|
||||
<!-- 一次性任务时间 -->
|
||||
<div id="task-edit-once-wrap" class="hidden">
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_once_time">执行时间</span>
|
||||
</label>
|
||||
<input id="task-edit-once-time" type="datetime-local" step="1"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 transition-colors
|
||||
cursor-pointer"
|
||||
onclick="this.showPicker && this.showPicker()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cron/Interval 提示 -->
|
||||
<p id="task-edit-cron-hint" class="text-xs text-slate-400 dark:text-slate-500">
|
||||
<span data-i18n="task_cron_hint">格式: 分 时 日 月 周,例如 "0 9 * * *" 表示每天 9:00</span>
|
||||
</p>
|
||||
<p id="task-edit-interval-hint" class="text-xs text-slate-400 dark:text-slate-500 hidden">
|
||||
<span data-i18n="task_interval_hint">最小 60 秒,例如 3600 表示每小时执行一次</span>
|
||||
</p>
|
||||
|
||||
<!-- 动作类型 + 通道类型 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_action_type">动作类型</span>
|
||||
</label>
|
||||
<select id="task-edit-action-type"
|
||||
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 transition-colors
|
||||
appearance-none bg-no-repeat bg-right"
|
||||
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
|
||||
<option value="send_message" data-i18n="task_action_send_message">发送消息</option>
|
||||
<option value="agent_task" data-i18n="task_action_agent_task">AI 任务</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span data-i18n="task_channel_type">通道类型</span>
|
||||
</label>
|
||||
<select id="task-edit-channel-type"
|
||||
class="w-full px-3 py-2 pr-8 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-[#1A1A1A] text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 transition-colors
|
||||
appearance-none bg-no-repeat bg-right
|
||||
disabled:opacity-100 disabled:text-slate-800 dark:disabled:text-slate-300 disabled:bg-slate-100 dark:disabled:bg-[#252525] disabled:cursor-not-allowed"
|
||||
style="background-image: url('data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 20 20%27 fill=%27none%27%3e%3cpath d=%27M7 7l3 3 3-3%27 stroke=%27%23888%27 stroke-width=%272%27 stroke-linecap=%27round%27 stroke-linejoin=%27round%27/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 1.25rem;">
|
||||
</select>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||||
<span data-i18n="task_channel_hint">选择定时消息发送的通道</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的接收者ID字段(自动填充) -->
|
||||
<input id="task-edit-receiver" type="hidden" value="">
|
||||
|
||||
<!-- 消息内容/任务描述 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 dark:text-slate-400 mb-1.5">
|
||||
<span id="task-edit-content-label" data-i18n="task_message_content">消息内容</span>
|
||||
</label>
|
||||
<textarea id="task-edit-content" rows="3"
|
||||
class="w-full px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-600
|
||||
bg-slate-50 dark:bg-white/5 text-sm text-slate-800 dark:text-slate-100
|
||||
focus:outline-none focus:border-primary-500 transition-colors resize-none"
|
||||
placeholder="输入消息内容或任务描述"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 px-6 py-4 border-t border-slate-100 dark:border-white/5 rounded-b-2xl">
|
||||
<button id="task-edit-modal-delete"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||
cursor-pointer transition-colors duration-150 hidden"
|
||||
data-i18n="task_delete_btn">删除任务</button>
|
||||
<span id="task-edit-modal-status"
|
||||
class="flex-1 text-xs text-primary-500 opacity-0 transition-opacity duration-300 text-left"></span>
|
||||
<button id="task-edit-modal-cancel"
|
||||
class="px-4 py-2 rounded-lg border border-slate-200 dark:border-white/10
|
||||
text-slate-600 dark:text-slate-300 text-sm font-medium
|
||||
hover:bg-slate-50 dark:hover:bg-white/5
|
||||
cursor-pointer transition-colors duration-150"
|
||||
data-i18n="cancel">取消</button>
|
||||
<button id="task-edit-modal-save"
|
||||
class="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-sm font-medium
|
||||
cursor-pointer transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-i18n="save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script defer src="assets/js/console.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -244,6 +244,52 @@
|
||||
}
|
||||
.dark .session-delete:hover { background: rgba(239, 68, 68, 0.15); }
|
||||
|
||||
/* Rename button: shares the look of the delete button, sits to its left.
|
||||
Negative right margin tightens the gap to the delete button only. */
|
||||
.session-rename {
|
||||
flex-shrink: 0;
|
||||
margin-right: -6px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
.session-item:hover .session-rename { opacity: 1; }
|
||||
.session-rename:hover {
|
||||
color: #4ABE6E;
|
||||
background: rgba(74, 190, 110, 0.12);
|
||||
}
|
||||
.dark .session-rename:hover { background: rgba(74, 190, 110, 0.18); }
|
||||
|
||||
/* Inline title editor */
|
||||
.session-title-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: #111827;
|
||||
background: #ffffff;
|
||||
border: 1px solid #4ABE6E;
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
outline: none;
|
||||
}
|
||||
.dark .session-title-input {
|
||||
color: #e5e5e5;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: #4ABE6E;
|
||||
}
|
||||
|
||||
/* Context Divider */
|
||||
.context-divider {
|
||||
display: flex;
|
||||
@@ -605,6 +651,18 @@
|
||||
color: inherit;
|
||||
}
|
||||
.tool-error-text { color: #f87171; }
|
||||
.tool-live-output:empty { display: none; }
|
||||
.tool-streaming .tool-live-output:not(:empty)::after {
|
||||
content: ' ';
|
||||
display: inline-block;
|
||||
width: 0.45em;
|
||||
height: 1em;
|
||||
margin-left: 0.15em;
|
||||
vertical-align: -0.15em;
|
||||
background: currentColor;
|
||||
animation: tool-cursor-blink 1s steps(1) infinite;
|
||||
}
|
||||
@keyframes tool-cursor-blink { 50% { opacity: 0; } }
|
||||
|
||||
/* Log level highlighting */
|
||||
.log-line { display: block; }
|
||||
@@ -854,6 +912,14 @@
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* "Custom" row is an add-new action: trailing + instead of ✓. */
|
||||
.vendor-picker-add-mark {
|
||||
margin-left: auto;
|
||||
padding-left: 12px;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Chat Input */
|
||||
#chat-input {
|
||||
@@ -1191,6 +1257,34 @@
|
||||
background: #EDFDF3;
|
||||
color: #228547;
|
||||
}
|
||||
|
||||
.knowledge-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.knowledge-tree-file:hover .knowledge-actions,
|
||||
.knowledge-tree-group-btn:hover .knowledge-actions,
|
||||
.knowledge-tree-file:focus-within .knowledge-actions,
|
||||
.knowledge-tree-group-btn:focus-within .knowledge-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.knowledge-action {
|
||||
padding: 3px 5px;
|
||||
border-radius: 5px;
|
||||
color: #94a3b8;
|
||||
font-size: 9px;
|
||||
}
|
||||
.knowledge-action:hover {
|
||||
color: #228547;
|
||||
background: rgba(34, 133, 71, 0.08);
|
||||
}
|
||||
.knowledge-action.danger:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
.dark .knowledge-tree-file:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: #e2e8f0;
|
||||
@@ -1399,3 +1493,181 @@
|
||||
.agent-cancelled-tag {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Code Block Enhancements
|
||||
===================================================================== */
|
||||
.code-block-wrapper {
|
||||
position: relative;
|
||||
margin: 1em 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.dark .code-block-wrapper {
|
||||
background: #1e293b;
|
||||
border-color: #334155;
|
||||
}
|
||||
|
||||
.code-block-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5em 1em;
|
||||
background: #e2e8f0;
|
||||
border-bottom: 1px solid #cbd5e1;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.dark .code-block-header {
|
||||
background: #0f172a;
|
||||
border-bottom-color: #334155;
|
||||
}
|
||||
|
||||
.code-block-lang {
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.dark .code-block-lang {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.code-copy-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.code-copy-btn:hover {
|
||||
background: rgba(100, 116, 139, 0.1);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.dark .code-copy-btn {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .code-copy-btn:hover {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.code-block-wrapper pre {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Drag and Drop Overlay
|
||||
===================================================================== */
|
||||
/* Anchor the absolutely-positioned overlay to the chat view. */
|
||||
#view-chat {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.drag-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.drag-overlay.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.drag-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drag-overlay-content {
|
||||
background: white;
|
||||
border: 3px dashed #3b82f6;
|
||||
border-radius: 16px;
|
||||
padding: 3em 4em;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
animation: bounce 1s ease infinite;
|
||||
}
|
||||
|
||||
.dark .drag-overlay-content {
|
||||
background: #1e293b;
|
||||
border-color: #60a5fa;
|
||||
}
|
||||
|
||||
.drag-overlay-content i {
|
||||
font-size: 4em;
|
||||
color: #3b82f6;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.dark .drag-overlay-content i {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.drag-overlay-content p {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dark .drag-overlay-content p {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-10px); }
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Message Action Buttons
|
||||
===================================================================== */
|
||||
.edit-msg-btn,
|
||||
.delete-msg-btn,
|
||||
.regenerate-msg-btn {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.user-message-group:hover .edit-msg-btn,
|
||||
.user-message-group:hover .delete-msg-btn,
|
||||
.bot-message-group:hover .regenerate-msg-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.edit-msg-btn:hover,
|
||||
.regenerate-msg-btn:hover {
|
||||
color: #3b82f6 !important;
|
||||
}
|
||||
|
||||
.delete-msg-btn:hover {
|
||||
color: #ef4444 !important;
|
||||
}
|
||||
|
||||
.edit-msg-btn:disabled,
|
||||
.delete-msg-btn:disabled {
|
||||
cursor: not-allowed !important;
|
||||
opacity: 0.35 !important;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -19,9 +19,15 @@ def verify_server(data):
|
||||
nonce = data.nonce
|
||||
echostr = data.get("echostr", None)
|
||||
token = conf().get("wechatmp_token") # 请按照公众平台官网\基本配置中信息填写
|
||||
# Reject when token is empty: an empty token reduces signature verification
|
||||
# to a predictable hash over attacker-controlled values.
|
||||
if not token:
|
||||
raise web.Forbidden("wechatmp_token is not configured")
|
||||
check_signature(token, signature, timestamp, nonce)
|
||||
return echostr
|
||||
except InvalidSignatureException:
|
||||
raise web.Forbidden("Invalid signature")
|
||||
except web.Forbidden:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise web.Forbidden(str(e))
|
||||
|
||||
@@ -12,16 +12,19 @@ import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import requests
|
||||
import web
|
||||
import websocket
|
||||
|
||||
from bridge.context import Context, ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from channel.chat_channel import ChatChannel, check_prefix
|
||||
from channel.wecom_bot.wecom_bot_crypt import WecomBotCrypt
|
||||
from channel.wecom_bot.wecom_bot_message import WecomBotMessage
|
||||
from common.expired_dict import ExpiredDict
|
||||
from common.log import logger
|
||||
@@ -32,6 +35,9 @@ from config import conf
|
||||
WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
|
||||
HEARTBEAT_INTERVAL = 30
|
||||
MEDIA_CHUNK_SIZE = 512 * 1024 # 512KB per chunk (before base64 encoding)
|
||||
# Fixed URL path for the callback (webhook) HTTP server. The bot's
|
||||
# receive-message URL must point at this path, e.g. http://host:9892/wecombot
|
||||
CALLBACK_PATH = "/wecombot"
|
||||
|
||||
|
||||
def _escape_control_chars_inside_json_strings(s: str) -> str:
|
||||
@@ -97,6 +103,14 @@ class WecomBotChannel(ChatChannel):
|
||||
self._pending_lock = threading.Lock()
|
||||
self._stream_states = {} # req_id -> {"stream_id": str, "content": str}
|
||||
|
||||
# Transport mode: "websocket" (long connection) or "webhook" (HTTP callback)
|
||||
self.mode = "websocket"
|
||||
self._crypt = None
|
||||
self._http_server = None
|
||||
# stream_id -> {"committed", "current", "finished", "images", "last_access"}
|
||||
self._callback_streams = ExpiredDict(60 * 10) # auto-expire after 10min (max poll window is 6min)
|
||||
self._callback_lock = threading.Lock()
|
||||
|
||||
conf()["group_name_white_list"] = ["ALL_GROUP"]
|
||||
conf()["single_chat_prefix"] = [""]
|
||||
|
||||
@@ -105,6 +119,11 @@ class WecomBotChannel(ChatChannel):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def startup(self):
|
||||
self.mode = conf().get("wecom_bot_mode", "websocket")
|
||||
if self.mode == "webhook":
|
||||
self._startup_callback()
|
||||
return
|
||||
|
||||
self.bot_id = conf().get("wecom_bot_id", "")
|
||||
self.bot_secret = conf().get("wecom_bot_secret", "")
|
||||
|
||||
@@ -127,6 +146,13 @@ class WecomBotChannel(ChatChannel):
|
||||
pass
|
||||
self._ws = None
|
||||
self._connected = False
|
||||
if self._http_server:
|
||||
try:
|
||||
self._http_server.stop()
|
||||
logger.info("[WecomBot] Callback HTTP server stopped")
|
||||
except Exception as e:
|
||||
logger.warning(f"[WecomBot] Error stopping HTTP server: {e}")
|
||||
self._http_server = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# WebSocket connection
|
||||
@@ -183,6 +209,192 @@ class WecomBotChannel(ChatChannel):
|
||||
def _gen_req_id(self) -> str:
|
||||
return uuid.uuid4().hex[:16]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Callback (webhook) mode
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _startup_callback(self):
|
||||
"""Start an HTTP server that receives encrypted callbacks (webhook mode).
|
||||
|
||||
The bot's "接收消息" URL in the WeCom admin console should point at this
|
||||
server (any path is accepted). Verification (GET) and message delivery
|
||||
(POST) are both handled by ``WecomBotCallbackController``.
|
||||
"""
|
||||
token = conf().get("wecom_bot_token", "")
|
||||
aes_key = conf().get("wecom_bot_encoding_aes_key", "")
|
||||
if not token or not aes_key:
|
||||
err = "[WecomBot] callback mode requires wecom_bot_token and wecom_bot_encoding_aes_key"
|
||||
logger.error(err)
|
||||
self.report_startup_error(err)
|
||||
return
|
||||
|
||||
try:
|
||||
# Enterprise-internal smart bot: receive_id is an empty string.
|
||||
self._crypt = WecomBotCrypt(token, aes_key, "")
|
||||
except Exception as e:
|
||||
err = f"[WecomBot] invalid callback credentials: {e}"
|
||||
logger.error(err)
|
||||
self.report_startup_error(err)
|
||||
return
|
||||
|
||||
port = int(conf().get("wecom_bot_port", 9892))
|
||||
logger.info(f"[WecomBot] Starting callback (webhook) server on port {port}, path {CALLBACK_PATH} ...")
|
||||
# Only serve the fixed callback path; everything else 404s instead of being
|
||||
# treated as a (signature-failing) WeCom callback.
|
||||
urls = (re.escape(CALLBACK_PATH), "channel.wecom_bot.wecom_bot_channel.WecomBotCallbackController")
|
||||
app = web.application(urls, globals(), autoreload=False)
|
||||
func = web.httpserver.StaticMiddleware(app.wsgifunc())
|
||||
func = web.httpserver.LogMiddleware(func)
|
||||
server = web.httpserver.WSGIServer(("0.0.0.0", port), func)
|
||||
self._http_server = server
|
||||
self.report_startup_success()
|
||||
try:
|
||||
server.start()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
server.stop()
|
||||
|
||||
def _new_callback_stream(self, response_url: str = "") -> str:
|
||||
"""Create a new stream state and return its id."""
|
||||
stream_id = uuid.uuid4().hex[:16]
|
||||
now = time.time()
|
||||
with self._callback_lock:
|
||||
self._callback_streams[stream_id] = {
|
||||
"committed": "",
|
||||
"current": "",
|
||||
"finished": False,
|
||||
"images": [], # list of (base64_str, md5_str), flushed only at finish
|
||||
"image_urls": [], # public http(s) image urls (usable in response_url markdown)
|
||||
"image_pending": False, # an image reply is being prepared; don't finish on text yet
|
||||
"last_access": now,
|
||||
"created_at": now,
|
||||
"response_url": response_url or "",
|
||||
"delivered": False, # final answer handed to WeCom via a poll
|
||||
"url_sent": False, # final answer pushed via response_url (active reply)
|
||||
}
|
||||
return stream_id
|
||||
|
||||
def _callback_handle_message(self, data: dict) -> dict:
|
||||
"""Handle a freshly-received user message in callback mode.
|
||||
|
||||
Produces the context for async processing and returns the initial passive
|
||||
reply (a stream packet with finish=false) so WeCom starts polling for the
|
||||
agent's streamed answer. Returns ``None`` when there's nothing to reply
|
||||
(e.g. an image/file silently cached for the next query).
|
||||
"""
|
||||
msg_id = data.get("msgid", "")
|
||||
if msg_id and self.received_msgs.get(msg_id):
|
||||
logger.debug(f"[WecomBot] Duplicate msg filtered: {msg_id}")
|
||||
return None
|
||||
if msg_id:
|
||||
self.received_msgs[msg_id] = True
|
||||
|
||||
chattype = data.get("chattype", "single")
|
||||
is_group = chattype == "group"
|
||||
|
||||
default_aeskey = conf().get("wecom_bot_encoding_aes_key", "")
|
||||
result = self._build_context(data, is_group, default_aeskey=default_aeskey)
|
||||
if not result:
|
||||
return None
|
||||
context, wecom_msg = result
|
||||
|
||||
# response_url lets us actively reply once within 1h, used as a fallback
|
||||
# when the agent finishes after WeCom stops polling (max ~6min window).
|
||||
response_url = data.get("response_url", "") or ""
|
||||
stream_id = self._new_callback_stream(response_url=response_url)
|
||||
wecom_msg.stream_id = stream_id
|
||||
context["wecom_stream_id"] = stream_id
|
||||
context["on_event"] = self._make_callback_stream_callback(stream_id)
|
||||
self.produce(context)
|
||||
|
||||
# First passive reply: register the stream id, WeCom will poll for updates.
|
||||
return {
|
||||
"msgtype": "stream",
|
||||
"stream": {"id": stream_id, "finish": False, "content": ""},
|
||||
}
|
||||
|
||||
def _callback_handle_stream_poll(self, data: dict) -> dict:
|
||||
"""Handle a "流式消息刷新" poll: return the latest accumulated content."""
|
||||
stream_id = data.get("stream", {}).get("id", "")
|
||||
with self._callback_lock:
|
||||
state = self._callback_streams.get(stream_id)
|
||||
if state is None:
|
||||
# Unknown / expired stream: tell WeCom we're done to stop polling.
|
||||
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
|
||||
state["last_access"] = time.time()
|
||||
if state.get("url_sent"):
|
||||
# Final answer already pushed via response_url; finish silently.
|
||||
return {"msgtype": "stream", "stream": {"id": stream_id, "finish": True, "content": ""}}
|
||||
# We never force-finish on a timer: while a task is still running the
|
||||
# bubble should keep spinning until either the task finishes or the
|
||||
# user cancels. If WeCom's 6min window closes before completion, the
|
||||
# answer is delivered later via response_url instead.
|
||||
finished = state["finished"]
|
||||
content = state["committed"] + state["current"]
|
||||
images = state["images"] if finished else []
|
||||
if finished:
|
||||
state["delivered"] = True
|
||||
logger.debug(f"[WecomBot] stream {stream_id} delivered via poll, len={len(content)}, images={len(images)}")
|
||||
|
||||
stream = {"id": stream_id, "finish": finished, "content": content}
|
||||
if images:
|
||||
stream["msg_item"] = [
|
||||
{"msgtype": "image", "image": {"base64": b64, "md5": md5}}
|
||||
for (b64, md5) in images
|
||||
]
|
||||
return {"msgtype": "stream", "stream": stream}
|
||||
|
||||
def _make_callback_stream_callback(self, stream_id: str):
|
||||
"""Build an on_event callback that accumulates agent output into stream state.
|
||||
|
||||
Mirrors the websocket streaming behaviour: intermediate turns (text before
|
||||
a tool call) are committed with a '---' separator; WeCom reads the full
|
||||
accumulated content on each poll.
|
||||
"""
|
||||
def on_event(event: dict):
|
||||
event_type = event.get("type")
|
||||
edata = event.get("data", {})
|
||||
cancelled = False
|
||||
with self._callback_lock:
|
||||
state = self._callback_streams.get(stream_id)
|
||||
if not state:
|
||||
return
|
||||
|
||||
if event_type == "turn_start":
|
||||
state["current"] = ""
|
||||
elif event_type == "message_update":
|
||||
delta = edata.get("delta", "")
|
||||
if delta:
|
||||
state["current"] += delta
|
||||
elif event_type == "message_end":
|
||||
tool_calls = edata.get("tool_calls", [])
|
||||
if tool_calls:
|
||||
if state["current"].strip():
|
||||
state["committed"] += state["current"].strip() + "\n\n---\n\n"
|
||||
state["current"] = ""
|
||||
else:
|
||||
state["committed"] += state["current"]
|
||||
state["current"] = ""
|
||||
elif event_type == "agent_cancelled":
|
||||
# Mechanism 1: a cancelled run never reaches send(), so finalize
|
||||
# its stream here to stop the "···" bubble immediately.
|
||||
if state["current"]:
|
||||
state["committed"] += state["current"]
|
||||
state["current"] = ""
|
||||
state["committed"] = state["committed"].rstrip()
|
||||
if state["committed"].endswith("---"):
|
||||
state["committed"] = state["committed"][:-3].rstrip()
|
||||
if not state["committed"].strip():
|
||||
state["committed"] = "🛑 已中止"
|
||||
state["finished"] = True
|
||||
state["last_access"] = time.time()
|
||||
cancelled = True
|
||||
|
||||
if cancelled:
|
||||
# Outside the lock: response_url fallback re-acquires it.
|
||||
self._schedule_response_url_fallback(stream_id)
|
||||
|
||||
return on_event
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Subscribe & heartbeat
|
||||
# ------------------------------------------------------------------
|
||||
@@ -287,16 +499,31 @@ class WecomBotChannel(ChatChannel):
|
||||
chattype = body.get("chattype", "single")
|
||||
is_group = chattype == "group"
|
||||
|
||||
result = self._build_context(body, is_group)
|
||||
if not result:
|
||||
return
|
||||
context, wecom_msg = result
|
||||
wecom_msg.req_id = req_id
|
||||
if req_id:
|
||||
context["on_event"] = self._make_stream_callback(req_id)
|
||||
self.produce(context)
|
||||
|
||||
def _build_context(self, body: dict, is_group: bool, default_aeskey: str = ""):
|
||||
"""Parse a wecom message body into a Context, applying file-cache logic.
|
||||
|
||||
Shared by both the websocket (long-connection) and callback (webhook)
|
||||
receive paths. Returns ``(context, wecom_msg)`` when the message should be
|
||||
handed to the agent, or ``None`` when it was consumed (cached image/file,
|
||||
parse failure, etc.).
|
||||
"""
|
||||
try:
|
||||
wecom_msg = WecomBotMessage(body, is_group=is_group)
|
||||
wecom_msg = WecomBotMessage(body, is_group=is_group, default_aeskey=default_aeskey)
|
||||
except NotImplementedError as e:
|
||||
logger.warning(f"[WecomBot] {e}")
|
||||
return
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
|
||||
return
|
||||
|
||||
wecom_msg.req_id = req_id
|
||||
return None
|
||||
|
||||
# File cache logic (same pattern as feishu)
|
||||
from channel.file_cache import get_file_cache
|
||||
@@ -314,13 +541,13 @@ class WecomBotChannel(ChatChannel):
|
||||
if hasattr(wecom_msg, "image_path") and wecom_msg.image_path:
|
||||
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
|
||||
logger.info(f"[WecomBot] Image cached for session {session_id}")
|
||||
return
|
||||
return None
|
||||
|
||||
if wecom_msg.ctype == ContextType.FILE:
|
||||
wecom_msg.prepare()
|
||||
file_cache.add(session_id, wecom_msg.content, file_type="file")
|
||||
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
|
||||
return
|
||||
return None
|
||||
|
||||
if wecom_msg.ctype == ContextType.TEXT:
|
||||
cached_files = file_cache.get(session_id)
|
||||
@@ -346,10 +573,9 @@ class WecomBotChannel(ChatChannel):
|
||||
msg=wecom_msg,
|
||||
no_need_at=True,
|
||||
)
|
||||
if context:
|
||||
if req_id:
|
||||
context["on_event"] = self._make_stream_callback(req_id)
|
||||
self.produce(context)
|
||||
if not context:
|
||||
return None
|
||||
return context, wecom_msg
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Event callback
|
||||
@@ -490,11 +716,233 @@ class WecomBotChannel(ChatChannel):
|
||||
|
||||
return context
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Callback (webhook) send: write the final reply into the stream state
|
||||
# so the next "流式消息刷新" poll returns it with finish=true.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _callback_send(self, reply: Reply, context: Context):
|
||||
msg = context.get("msg")
|
||||
stream_id = getattr(msg, "stream_id", None) if msg else None
|
||||
if not stream_id:
|
||||
stream_id = context.get("wecom_stream_id")
|
||||
if not stream_id:
|
||||
logger.warning("[WecomBot] callback send without stream_id, dropping reply")
|
||||
return
|
||||
|
||||
if reply.type == ReplyType.TEXT:
|
||||
self._callback_finalize_text(stream_id, reply.content)
|
||||
elif reply.type in (ReplyType.IMAGE_URL, ReplyType.IMAGE):
|
||||
self._callback_finalize_image(stream_id, reply.content)
|
||||
elif reply.type == ReplyType.FILE:
|
||||
# Passive callback replies only support text + image (base64); files
|
||||
# are not supported by the protocol, so append a notice to whatever
|
||||
# text the agent already streamed (do not drop it).
|
||||
text = getattr(reply, "text_content", "") or ""
|
||||
note = (text + "\n\n" if text else "") + "(文件无法在企微回调模式下直接发送)"
|
||||
self._callback_finalize_text(stream_id, note, append=True)
|
||||
elif reply.type in (ReplyType.VIDEO, ReplyType.VIDEO_URL, ReplyType.VOICE):
|
||||
logger.warning(f"[WecomBot] reply type {reply.type} not supported in callback mode")
|
||||
text = getattr(reply, "text_content", "") or ""
|
||||
note = (text + "\n\n" if text else "") + "(该消息类型无法在企微回调模式下直接发送)"
|
||||
self._callback_finalize_text(stream_id, note, append=True)
|
||||
else:
|
||||
self._callback_finalize_text(stream_id, str(reply.content))
|
||||
|
||||
def _callback_get_or_create_state(self, stream_id: str) -> dict:
|
||||
state = self._callback_streams.get(stream_id)
|
||||
if state is None:
|
||||
now = time.time()
|
||||
state = {
|
||||
"committed": "",
|
||||
"current": "",
|
||||
"finished": False,
|
||||
"images": [],
|
||||
"image_urls": [],
|
||||
"image_pending": False,
|
||||
"last_access": now,
|
||||
"created_at": now,
|
||||
"response_url": "",
|
||||
"delivered": False,
|
||||
"url_sent": False,
|
||||
}
|
||||
self._callback_streams[stream_id] = state
|
||||
return state
|
||||
|
||||
def _callback_finalize_text(self, stream_id: str, content: str, append: bool = False):
|
||||
with self._callback_lock:
|
||||
state = self._callback_get_or_create_state(stream_id)
|
||||
accumulated = (state["committed"] + state["current"]).strip()
|
||||
if append and accumulated:
|
||||
state["committed"] = (accumulated + "\n\n" + (content or "")).strip()
|
||||
else:
|
||||
state["committed"] = accumulated if accumulated else (content or "")
|
||||
state["current"] = ""
|
||||
state["last_access"] = time.time()
|
||||
# Don't finish synchronously: chat_channel splits an image-with-caption
|
||||
# reply into a TEXT send followed (0.3s later) by the IMAGE send. If the
|
||||
# text finished the stream immediately, WeCom would close it before the
|
||||
# image arrives. Defer the finish so a trailing image can merge in.
|
||||
self._schedule_text_finish(stream_id)
|
||||
|
||||
def _schedule_text_finish(self, stream_id: str, delay: float = 1.2):
|
||||
def _run():
|
||||
time.sleep(delay)
|
||||
with self._callback_lock:
|
||||
state = self._callback_streams.get(stream_id)
|
||||
if not state or state["finished"] or state.get("image_pending"):
|
||||
return # already finished, or an image reply is on its way
|
||||
state["finished"] = True
|
||||
state["last_access"] = time.time()
|
||||
self._schedule_response_url_fallback(stream_id)
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name=f"wecom-textfin-{stream_id}").start()
|
||||
|
||||
def _callback_finalize_image(self, stream_id: str, img_path_or_url: str):
|
||||
# Mark the image as pending up front (before the slow load/compress) so a
|
||||
# preceding text finalize won't close the stream while we work.
|
||||
with self._callback_lock:
|
||||
self._callback_get_or_create_state(stream_id)["image_pending"] = True
|
||||
b64md5 = self._load_image_base64(img_path_or_url)
|
||||
with self._callback_lock:
|
||||
state = self._callback_get_or_create_state(stream_id)
|
||||
accumulated = (state["committed"] + state["current"]).strip()
|
||||
state["current"] = ""
|
||||
if b64md5:
|
||||
state["images"].append(b64md5)
|
||||
state["committed"] = accumulated
|
||||
# Remember the public url (if any) so the response_url fallback
|
||||
# can embed it as markdown when the poll window has closed.
|
||||
if img_path_or_url.startswith(("http://", "https://")):
|
||||
state["image_urls"].append(img_path_or_url)
|
||||
else:
|
||||
state["committed"] = accumulated or "[图片发送失败]"
|
||||
state["finished"] = True
|
||||
state["image_pending"] = False
|
||||
state["last_access"] = time.time()
|
||||
self._schedule_response_url_fallback(stream_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Active reply fallback (response_url): rescue replies that finish after
|
||||
# WeCom stops polling (the passive stream window is ~6 min from the user's
|
||||
# message). A short delay lets an in-flight poll deliver first; only if no
|
||||
# poll picks up the finished answer do we push it actively via response_url.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _schedule_response_url_fallback(self, stream_id: str, delay: float = 3.0):
|
||||
def _run():
|
||||
time.sleep(delay)
|
||||
with self._callback_lock:
|
||||
state = self._callback_streams.get(stream_id)
|
||||
if not state:
|
||||
return
|
||||
if state.get("delivered") or state.get("url_sent"):
|
||||
return # a poll already delivered (or fallback already ran)
|
||||
response_url = state.get("response_url") or ""
|
||||
if not response_url:
|
||||
logger.warning(
|
||||
f"[WecomBot] stream {stream_id} finished after poll window but no response_url; reply dropped"
|
||||
)
|
||||
return
|
||||
content = (state["committed"] + state["current"]).strip()
|
||||
image_urls = list(state.get("image_urls") or [])
|
||||
has_images = bool(state.get("images"))
|
||||
state["url_sent"] = True
|
||||
|
||||
self._send_via_response_url(stream_id, response_url, content, image_urls, has_images)
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name=f"wecom-respurl-{stream_id}").start()
|
||||
|
||||
def _send_via_response_url(self, stream_id, response_url, content, image_urls, has_images):
|
||||
"""Push a one-shot active markdown reply to response_url (valid 1h, single use)."""
|
||||
md = content or ""
|
||||
if image_urls:
|
||||
md += ("\n\n" if md else "") + "\n".join(f"" for u in image_urls)
|
||||
elif has_images:
|
||||
md += ("\n\n" if md else "") + "(图片已生成,但因处理超时无法通过回调发送)"
|
||||
if not md:
|
||||
md = "(处理完成)"
|
||||
payload = {"msgtype": "markdown", "markdown": {"content": md}}
|
||||
try:
|
||||
resp = requests.post(response_url, json=payload, timeout=15)
|
||||
logger.info(
|
||||
f"[WecomBot] response_url active reply sent for {stream_id}: "
|
||||
f"status={resp.status_code}, body={resp.text[:200]}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] response_url active reply failed for {stream_id}: {e}")
|
||||
|
||||
def _load_image_base64(self, img_path_or_url: str):
|
||||
"""Load a local/remote image, ensure JPG/PNG within 10MB, return (base64, md5)."""
|
||||
local_path = img_path_or_url
|
||||
if local_path.startswith("file://"):
|
||||
local_path = local_path[7:]
|
||||
|
||||
# Temp files we create here (downloads/conversions/compressions) must be
|
||||
# cleaned up afterwards; the caller's original local file must not be.
|
||||
temp_files = []
|
||||
try:
|
||||
if local_path.startswith(("http://", "https://")):
|
||||
try:
|
||||
resp = requests.get(local_path, timeout=30)
|
||||
resp.raise_for_status()
|
||||
tmp_path = f"/tmp/wecom_cb_img_{uuid.uuid4().hex[:8]}"
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
temp_files.append(tmp_path)
|
||||
local_path = tmp_path
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] Failed to download image for callback reply: {e}")
|
||||
return None
|
||||
|
||||
if not os.path.exists(local_path):
|
||||
logger.error(f"[WecomBot] Image file not found: {local_path}")
|
||||
return None
|
||||
|
||||
formatted = self._ensure_image_format(local_path)
|
||||
if not formatted:
|
||||
return None
|
||||
if formatted != local_path:
|
||||
temp_files.append(formatted)
|
||||
local_path = formatted
|
||||
|
||||
# Unlike the long-connection path (which uploads and sends only a tiny
|
||||
# media_id), the callback reply embeds the whole image as base64 inside
|
||||
# an AES-encrypted body that is returned on EVERY poll. Empirically a
|
||||
# ~1.5MB image (base64 ~2.1MB, encrypted ~2.8MB) makes WeCom reject the
|
||||
# finish packet and poll forever, so cap well below that.
|
||||
callback_max_size = 512 * 1024
|
||||
if os.path.getsize(local_path) > callback_max_size:
|
||||
compressed = self._compress_image(local_path, callback_max_size)
|
||||
if compressed:
|
||||
temp_files.append(compressed)
|
||||
local_path = compressed
|
||||
else:
|
||||
logger.warning("[WecomBot] callback image compress failed; sending original (may be rejected)")
|
||||
|
||||
try:
|
||||
with open(local_path, "rb") as f:
|
||||
raw = f.read()
|
||||
return base64.b64encode(raw).decode("utf-8"), hashlib.md5(raw).hexdigest()
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] Failed to read image for callback reply: {e}")
|
||||
return None
|
||||
finally:
|
||||
for path in temp_files:
|
||||
try:
|
||||
os.remove(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Send reply
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def send(self, reply: Reply, context: Context):
|
||||
if self.mode == "webhook":
|
||||
self._callback_send(reply, context)
|
||||
return
|
||||
|
||||
msg = context.get("msg")
|
||||
is_group = context.get("isgroup", False)
|
||||
receiver = context.get("receiver", "")
|
||||
@@ -906,3 +1354,85 @@ class WecomBotChannel(ChatChannel):
|
||||
else:
|
||||
logger.error("[WecomBot] Failed to get media_id from finish response")
|
||||
return media_id
|
||||
|
||||
|
||||
class WecomBotCallbackController:
|
||||
"""HTTP controller for wecom bot callback (webhook) mode.
|
||||
|
||||
- GET : URL verification (echo the decrypted echostr).
|
||||
- POST : encrypted message / stream-refresh / event callbacks; returns an
|
||||
encrypted passive reply (or "success" for an empty reply).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _channel() -> "WecomBotChannel":
|
||||
return WecomBotChannel()
|
||||
|
||||
def GET(self):
|
||||
channel = self._channel()
|
||||
params = web.input(msg_signature="", timestamp="", nonce="", echostr="")
|
||||
if not channel._crypt:
|
||||
return "wecom bot callback not ready"
|
||||
ret, echo = channel._crypt.verify_url(
|
||||
params.msg_signature, params.timestamp, params.nonce, params.echostr
|
||||
)
|
||||
if ret != 0:
|
||||
logger.error(f"[WecomBot] URL verify failed: ret={ret}")
|
||||
return "verify fail"
|
||||
if isinstance(echo, bytes):
|
||||
echo = echo.decode("utf-8")
|
||||
return echo
|
||||
|
||||
def POST(self):
|
||||
channel = self._channel()
|
||||
if not channel._crypt:
|
||||
return "success"
|
||||
|
||||
params = web.input(msg_signature="", timestamp="", nonce="")
|
||||
body = web.data()
|
||||
ret, plain = channel._crypt.decrypt_msg(
|
||||
body, params.msg_signature, params.timestamp, params.nonce
|
||||
)
|
||||
if ret != 0:
|
||||
logger.error(f"[WecomBot] callback decrypt failed: ret={ret}")
|
||||
return "success"
|
||||
|
||||
try:
|
||||
data = json.loads(plain)
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] callback json parse failed: {e}")
|
||||
return "success"
|
||||
|
||||
msgtype = data.get("msgtype", "")
|
||||
# Stream polls arrive ~1/s; logging each is noisy, so only log non-poll
|
||||
# callbacks here (poll completion is logged in the stream-poll handler).
|
||||
if msgtype != "stream":
|
||||
logger.debug(f"[WecomBot] callback received msgtype={msgtype}")
|
||||
|
||||
try:
|
||||
if msgtype == "stream":
|
||||
reply = channel._callback_handle_stream_poll(data)
|
||||
elif msgtype == "event":
|
||||
event_type = data.get("event", {}).get("eventtype", "")
|
||||
logger.info(f"[WecomBot] callback event: {event_type}")
|
||||
reply = None
|
||||
elif msgtype in ("text", "image", "voice", "file", "video", "mixed"):
|
||||
reply = channel._callback_handle_message(data)
|
||||
else:
|
||||
logger.warning(f"[WecomBot] unsupported callback msgtype: {msgtype}")
|
||||
reply = None
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] callback handling error: {e}", exc_info=True)
|
||||
reply = None
|
||||
|
||||
if not reply:
|
||||
# Empty reply package is acceptable.
|
||||
return "success"
|
||||
|
||||
plain_reply = json.dumps(reply, ensure_ascii=False)
|
||||
ret, enc = channel._crypt.encrypt_msg(plain_reply, params.nonce, params.timestamp)
|
||||
if ret != 0:
|
||||
logger.error(f"[WecomBot] callback encrypt failed: ret={ret}")
|
||||
return "success"
|
||||
web.header("Content-Type", "application/json; charset=utf-8")
|
||||
return json.dumps(enc, ensure_ascii=False)
|
||||
|
||||
203
channel/wecom_bot/wecom_bot_crypt.py
Normal file
203
channel/wecom_bot/wecom_bot_crypt.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
WeCom (企业微信) smart-bot callback message encryption/decryption.
|
||||
|
||||
Adapted from the official `WXBizJsonMsgCrypt` sample (JSON variant) used by the
|
||||
AI bot callback (webhook) mode. The bot's receive-message callback delivers
|
||||
AES-256-CBC encrypted JSON payloads, and passive replies must be encrypted the
|
||||
same way before being returned in the HTTP response.
|
||||
|
||||
For an enterprise-internal smart bot, ``receive_id`` is always an empty string.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from common.log import logger
|
||||
|
||||
# Error codes (mirrors the official ierror.py)
|
||||
WXBizMsgCrypt_OK = 0
|
||||
WXBizMsgCrypt_ValidateSignature_Error = -40001
|
||||
WXBizMsgCrypt_ParseJson_Error = -40002
|
||||
WXBizMsgCrypt_ComputeSignature_Error = -40003
|
||||
WXBizMsgCrypt_IllegalAesKey = -40004
|
||||
WXBizMsgCrypt_ValidateCorpid_Error = -40005
|
||||
WXBizMsgCrypt_EncryptAES_Error = -40006
|
||||
WXBizMsgCrypt_DecryptAES_Error = -40007
|
||||
WXBizMsgCrypt_IllegalBuffer = -40008
|
||||
WXBizMsgCrypt_EncodeBase64_Error = -40009
|
||||
WXBizMsgCrypt_DecodeBase64_Error = -40010
|
||||
WXBizMsgCrypt_GenReturnJson_Error = -40011
|
||||
|
||||
|
||||
class FormatException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _gen_sha1(token, timestamp, nonce, encrypt):
|
||||
"""Compute the WeCom message signature with SHA1 over the sorted parts."""
|
||||
try:
|
||||
if isinstance(encrypt, bytes):
|
||||
encrypt = encrypt.decode("utf-8")
|
||||
sortlist = [str(token), str(timestamp), str(nonce), str(encrypt)]
|
||||
sortlist.sort()
|
||||
sha = hashlib.sha1()
|
||||
sha.update("".join(sortlist).encode("utf-8"))
|
||||
return WXBizMsgCrypt_OK, sha.hexdigest()
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] compute signature error: {e}")
|
||||
return WXBizMsgCrypt_ComputeSignature_Error, None
|
||||
|
||||
|
||||
class _PKCS7Encoder:
|
||||
"""PKCS#7 padding with a 32-byte block size (AES-256)."""
|
||||
|
||||
block_size = 32
|
||||
|
||||
def encode(self, text: bytes) -> bytes:
|
||||
text_length = len(text)
|
||||
amount_to_pad = self.block_size - (text_length % self.block_size)
|
||||
if amount_to_pad == 0:
|
||||
amount_to_pad = self.block_size
|
||||
pad = bytes([amount_to_pad])
|
||||
return text + pad * amount_to_pad
|
||||
|
||||
def decode(self, decrypted: bytes) -> bytes:
|
||||
pad = decrypted[-1]
|
||||
if pad < 1 or pad > 32:
|
||||
pad = 0
|
||||
return decrypted[:-pad] if pad else decrypted
|
||||
|
||||
|
||||
class _Prpcrypt:
|
||||
"""AES-256-CBC encrypt/decrypt for WeCom callback messages."""
|
||||
|
||||
def __init__(self, key: bytes):
|
||||
self.key = key
|
||||
self.mode = AES.MODE_CBC
|
||||
|
||||
def encrypt(self, text: str, receive_id: str):
|
||||
text_bytes = text.encode()
|
||||
# 16-byte random prefix + network-order length + body + receive_id
|
||||
text_bytes = (
|
||||
self._get_random_str()
|
||||
+ struct.pack("I", socket.htonl(len(text_bytes)))
|
||||
+ text_bytes
|
||||
+ receive_id.encode()
|
||||
)
|
||||
text_bytes = _PKCS7Encoder().encode(text_bytes)
|
||||
try:
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
ciphertext = cryptor.encrypt(text_bytes)
|
||||
return WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] AES encrypt error: {e}")
|
||||
return WXBizMsgCrypt_EncryptAES_Error, None
|
||||
|
||||
def decrypt(self, text, receive_id: str):
|
||||
try:
|
||||
cryptor = AES.new(self.key, self.mode, self.key[:16])
|
||||
plain_text = cryptor.decrypt(base64.b64decode(text))
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] AES decrypt error: {e}")
|
||||
return WXBizMsgCrypt_DecryptAES_Error, None
|
||||
try:
|
||||
pad = plain_text[-1]
|
||||
content = plain_text[16:-pad]
|
||||
json_len = socket.ntohl(struct.unpack("I", content[:4])[0])
|
||||
json_content = content[4 : json_len + 4].decode("utf-8")
|
||||
from_receive_id = content[json_len + 4 :].decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.error(f"[WecomBot] illegal buffer when decrypting: {e}")
|
||||
return WXBizMsgCrypt_IllegalBuffer, None
|
||||
if from_receive_id != receive_id:
|
||||
logger.error(
|
||||
f"[WecomBot] receive_id not match: expect={receive_id}, got={from_receive_id}"
|
||||
)
|
||||
return WXBizMsgCrypt_ValidateCorpid_Error, None
|
||||
return WXBizMsgCrypt_OK, json_content
|
||||
|
||||
@staticmethod
|
||||
def _get_random_str() -> bytes:
|
||||
return str(random.randint(1000000000000000, 9999999999999999)).encode()
|
||||
|
||||
|
||||
class WecomBotCrypt:
|
||||
"""High-level helper for verifying URLs and (de)crypting callback messages."""
|
||||
|
||||
def __init__(self, token: str, encoding_aes_key: str, receive_id: str = ""):
|
||||
try:
|
||||
self.key = base64.b64decode(encoding_aes_key + "=")
|
||||
assert len(self.key) == 32
|
||||
except Exception:
|
||||
raise FormatException("[WecomBot] invalid EncodingAESKey")
|
||||
self.token = token
|
||||
self.receive_id = receive_id
|
||||
|
||||
def verify_url(self, msg_signature, timestamp, nonce, echostr):
|
||||
ret, signature = _gen_sha1(self.token, timestamp, nonce, echostr)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if signature != msg_signature:
|
||||
return WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = _Prpcrypt(self.key)
|
||||
return pc.decrypt(echostr, self.receive_id)
|
||||
|
||||
def encrypt_msg(self, reply_msg: str, nonce: str, timestamp: str = None):
|
||||
"""Encrypt a passive-reply JSON string and return the full response JSON.
|
||||
|
||||
Returns (ret, response_dict). On success ret==0 and response_dict is a
|
||||
dict with encrypt/msgsignature/timestamp/nonce fields.
|
||||
"""
|
||||
pc = _Prpcrypt(self.key)
|
||||
ret, encrypt = pc.encrypt(reply_msg, self.receive_id)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
encrypt = encrypt.decode("utf-8")
|
||||
if timestamp is None:
|
||||
timestamp = str(int(time.time()))
|
||||
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
return WXBizMsgCrypt_OK, {
|
||||
"encrypt": encrypt,
|
||||
"msgsignature": signature,
|
||||
"timestamp": timestamp,
|
||||
"nonce": nonce,
|
||||
}
|
||||
|
||||
def decrypt_msg(self, post_data, msg_signature, timestamp, nonce):
|
||||
"""Verify signature and decrypt the encrypted callback payload.
|
||||
|
||||
``post_data`` may be the raw request body (bytes/str) containing
|
||||
``{"encrypt": "..."}`` or the already-extracted encrypt string.
|
||||
Returns (ret, plaintext_json_str).
|
||||
"""
|
||||
import json
|
||||
|
||||
encrypt = None
|
||||
if isinstance(post_data, (bytes, bytearray)):
|
||||
post_data = post_data.decode("utf-8")
|
||||
if isinstance(post_data, str):
|
||||
try:
|
||||
encrypt = json.loads(post_data).get("encrypt")
|
||||
except Exception:
|
||||
encrypt = post_data
|
||||
elif isinstance(post_data, dict):
|
||||
encrypt = post_data.get("encrypt")
|
||||
if not encrypt:
|
||||
return WXBizMsgCrypt_ParseJson_Error, None
|
||||
|
||||
ret, signature = _gen_sha1(self.token, timestamp, nonce, encrypt)
|
||||
if ret != 0:
|
||||
return ret, None
|
||||
if signature != msg_signature:
|
||||
logger.error("[WecomBot] callback signature not match")
|
||||
return WXBizMsgCrypt_ValidateSignature_Error, None
|
||||
pc = _Prpcrypt(self.key)
|
||||
return pc.decrypt(encrypt, self.receive_id)
|
||||
@@ -87,11 +87,14 @@ def _get_tmp_dir() -> str:
|
||||
class WecomBotMessage(ChatMessage):
|
||||
"""Message wrapper for wecom bot (websocket long-connection mode)."""
|
||||
|
||||
def __init__(self, msg_body: dict, is_group: bool = False):
|
||||
def __init__(self, msg_body: dict, is_group: bool = False, default_aeskey: str = ""):
|
||||
super().__init__(msg_body)
|
||||
self.msg_id = msg_body.get("msgid")
|
||||
self.create_time = msg_body.get("create_time")
|
||||
self.is_group = is_group
|
||||
# In callback (webhook) mode the media bodies carry no per-message aeskey;
|
||||
# the download url is encrypted with the bot's EncodingAESKey instead.
|
||||
self._default_aeskey = default_aeskey
|
||||
|
||||
msg_type = msg_body.get("msgtype")
|
||||
from_userid = msg_body.get("from", {}).get("userid", "")
|
||||
@@ -113,7 +116,7 @@ class WecomBotMessage(ChatMessage):
|
||||
self.ctype = ContextType.IMAGE
|
||||
image_info = msg_body.get("image", {})
|
||||
image_url = image_info.get("url", "")
|
||||
aeskey = image_info.get("aeskey", "")
|
||||
aeskey = image_info.get("aeskey", "") or self._default_aeskey
|
||||
tmp_dir = _get_tmp_dir()
|
||||
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
|
||||
|
||||
@@ -147,7 +150,7 @@ class WecomBotMessage(ChatMessage):
|
||||
elif item_type == "image":
|
||||
img_info = item.get("image", {})
|
||||
img_url = img_info.get("url", "")
|
||||
img_aeskey = img_info.get("aeskey", "")
|
||||
img_aeskey = img_info.get("aeskey", "") or self._default_aeskey
|
||||
img_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}_{idx}.png")
|
||||
try:
|
||||
img_data = _decrypt_media(img_url, img_aeskey)
|
||||
@@ -166,7 +169,7 @@ class WecomBotMessage(ChatMessage):
|
||||
self.ctype = ContextType.FILE
|
||||
file_info = msg_body.get("file", {})
|
||||
file_url = file_info.get("url", "")
|
||||
aeskey = file_info.get("aeskey", "")
|
||||
aeskey = file_info.get("aeskey", "") or self._default_aeskey
|
||||
tmp_dir = _get_tmp_dir()
|
||||
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
|
||||
self.content = base_path
|
||||
@@ -188,7 +191,7 @@ class WecomBotMessage(ChatMessage):
|
||||
self.ctype = ContextType.FILE
|
||||
video_info = msg_body.get("video", {})
|
||||
video_url = video_info.get("url", "")
|
||||
aeskey = video_info.get("aeskey", "")
|
||||
aeskey = video_info.get("aeskey", "") or self._default_aeskey
|
||||
tmp_dir = _get_tmp_dir()
|
||||
self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4")
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from channel.weixin.weixin_message import WeixinMessage
|
||||
from common.expired_dict import ExpiredDict
|
||||
from common.log import logger
|
||||
from common.singleton import singleton
|
||||
from config import conf
|
||||
from config import conf, get_weixin_credentials_path
|
||||
|
||||
MAX_CONSECUTIVE_FAILURES = 3
|
||||
BACKOFF_DELAY = 30
|
||||
@@ -96,9 +96,7 @@ class WeixinChannel(ChatChannel):
|
||||
cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
|
||||
token = conf().get("weixin_token", "")
|
||||
|
||||
self._credentials_path = os.path.expanduser(
|
||||
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
|
||||
)
|
||||
self._credentials_path = get_weixin_credentials_path()
|
||||
|
||||
# Always load credentials so we can restore context_tokens even when
|
||||
# the bot token itself comes from config.
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.0.9
|
||||
2.1.1
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import click
|
||||
from cli import __version__
|
||||
from cli.commands.skill import skill
|
||||
from cli.commands.process import start, stop, restart, update, status, logs
|
||||
from cli.commands.process import start, stop, restart, self_restart, update, status, logs
|
||||
from cli.commands.context import context
|
||||
from cli.commands.install import install_browser
|
||||
from cli.commands.knowledge import knowledge
|
||||
@@ -68,6 +68,7 @@ main.add_command(skill)
|
||||
main.add_command(start)
|
||||
main.add_command(stop)
|
||||
main.add_command(restart)
|
||||
main.add_command(self_restart)
|
||||
main.add_command(update)
|
||||
main.add_command(status)
|
||||
main.add_command(logs)
|
||||
|
||||
@@ -78,12 +78,13 @@ def _is_china_network() -> bool:
|
||||
|
||||
|
||||
def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
||||
"""Install a package, retrying with --user on permission failure."""
|
||||
"""Install a package, preferring prebuilt wheels; retry with --user on perm error."""
|
||||
python = sys.executable
|
||||
ret = subprocess.call([python, "-m", "pip", "install", package_spec])
|
||||
base = [python, "-m", "pip", "install", "--prefer-binary"]
|
||||
ret = subprocess.call(base + [package_spec])
|
||||
if ret != 0:
|
||||
stream(" Retrying with --user flag...", "yellow")
|
||||
ret = subprocess.call([python, "-m", "pip", "install", "--user", package_spec])
|
||||
ret = subprocess.call(base + ["--user", package_spec])
|
||||
return ret
|
||||
|
||||
|
||||
@@ -155,6 +156,22 @@ def run_install_browser(
|
||||
|
||||
target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
|
||||
|
||||
# Windows-only: greenlet 3.2.x ships no Windows wheel, so pip would build it
|
||||
# from source (needs MSVC) and fail. Pre-install 3.1.x (has win wheels for
|
||||
# py3.7-3.13) which still satisfies playwright's greenlet>=3.1.1,<4.
|
||||
if sys.platform == "win32":
|
||||
stream("[1/3] Pre-installing greenlet (prebuilt wheel) for Windows...", "yellow")
|
||||
ret = subprocess.call(
|
||||
[python, "-m", "pip", "install", "--only-binary=:all:", "greenlet>=3.1.1,<3.2"]
|
||||
)
|
||||
if ret != 0:
|
||||
stream(
|
||||
" Could not pre-install a prebuilt greenlet wheel.\n"
|
||||
" playwright may try to build greenlet from source, which needs\n"
|
||||
" Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/",
|
||||
"yellow",
|
||||
)
|
||||
|
||||
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||||
stream("[1/3] Installing playwright Python package...", "yellow")
|
||||
ret = _pip_install(f"playwright=={target_version}", stream)
|
||||
|
||||
@@ -195,6 +195,120 @@ def restart(ctx, no_logs):
|
||||
ctx.invoke(start, no_logs=no_logs)
|
||||
|
||||
|
||||
# Detached relay that survives the caller's process tree. Run via `python -c`
|
||||
# with start_new_session=True so it keeps going after the agent's bash child
|
||||
# (and the main app it kills) both die. Flow: self-check the new code FIRST
|
||||
# (import app); abort without touching the old process if it fails. Only when
|
||||
# the new code is loadable does it SIGTERM the old PID, wait for exit (SIGKILL
|
||||
# fallback), then start a fresh app.py and write the pid.
|
||||
_RELAY_SCRIPT = r"""
|
||||
import os, sys, time, signal, subprocess
|
||||
|
||||
root, python, app_py, pid_file, log_file = sys.argv[1:6]
|
||||
old_pid = int(sys.argv[6]) if len(sys.argv) > 6 and sys.argv[6] else 0
|
||||
|
||||
|
||||
def alive(pid):
|
||||
if not pid:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def log(msg):
|
||||
try:
|
||||
with open(log_file, "a") as f:
|
||||
f.write("[self-restart] " + msg + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# 0) Self-check: make sure the new code actually loads BEFORE killing anything.
|
||||
# `import app` exercises top-level imports / syntax of the entry module. If it
|
||||
# fails, abort and leave the running service untouched — never end up with the
|
||||
# old process killed and the new one unable to start.
|
||||
check = subprocess.run(
|
||||
[python, "-c", "import app"], cwd=root,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
)
|
||||
if check.returncode != 0:
|
||||
detail = (check.stdout or b"").decode("utf-8", "replace").strip()
|
||||
log("self-check FAILED, aborting restart; service left running:\n" + detail)
|
||||
sys.exit(1)
|
||||
log("self-check passed")
|
||||
|
||||
# 1) Ask the old process to exit gracefully (its SIGTERM handler persists state).
|
||||
if alive(old_pid):
|
||||
try:
|
||||
os.kill(old_pid, signal.SIGTERM)
|
||||
except OSError:
|
||||
pass
|
||||
# 2) Wait up to ~15s for it to go, then force-kill as a backstop.
|
||||
for _ in range(150):
|
||||
if not alive(old_pid):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
try:
|
||||
os.kill(old_pid, signal.SIGKILL)
|
||||
except OSError:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
|
||||
# 3) Start a fresh instance, detached, logging to the same file.
|
||||
with open(log_file, "a") as f:
|
||||
proc = subprocess.Popen(
|
||||
[python, app_py], cwd=root,
|
||||
stdout=f, stderr=f, start_new_session=True,
|
||||
)
|
||||
with open(pid_file, "w") as f:
|
||||
f.write(str(proc.pid))
|
||||
log("restarted, new pid=" + str(proc.pid))
|
||||
"""
|
||||
|
||||
|
||||
@click.command(name="self-restart", hidden=True)
|
||||
def self_restart():
|
||||
"""Restart from inside the running agent (detached; survives parent death).
|
||||
|
||||
Intended to be invoked by the agent itself (e.g. via bash after editing its
|
||||
own code), not by users — so it is hidden from `cow help`. Unlike `restart`,
|
||||
the actual stop+start runs in a detached relay process that outlives the
|
||||
agent's bash child, which would otherwise die together with the main app it
|
||||
kills.
|
||||
"""
|
||||
if _IS_WIN:
|
||||
click.echo("self-restart is not supported on Windows; use `cow restart`.", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
root = get_project_root()
|
||||
app_py = os.path.join(root, "app.py")
|
||||
if not os.path.exists(app_py):
|
||||
click.echo("Error: app.py not found in project root.", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
python = sys.executable
|
||||
pid = _read_pid() or 0
|
||||
|
||||
subprocess.Popen(
|
||||
[
|
||||
python, "-c", _RELAY_SCRIPT,
|
||||
root, python, app_py, _get_pid_file(), _get_log_file(), str(pid),
|
||||
],
|
||||
cwd=root,
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
click.echo(click.style(
|
||||
"✓ Restart scheduled. The service will stop and come back in a few seconds.",
|
||||
fg="green",
|
||||
))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.pass_context
|
||||
def update(ctx):
|
||||
@@ -275,7 +389,7 @@ def update(ctx):
|
||||
def status():
|
||||
"""Show CowAgent running status."""
|
||||
from cli import __version__
|
||||
from cli.utils import load_config_json, get_cli_language
|
||||
from cli.utils import load_config_json, get_cli_language, get_project_root
|
||||
|
||||
# get_cli_language() calls ensure_sys_path(), which adds the project root
|
||||
# to sys.path. Import `common` only AFTER that, otherwise it fails with
|
||||
@@ -292,6 +406,11 @@ def status():
|
||||
|
||||
click.echo(_t(f" 版本: v{__version__}", f" Version: v{__version__}"))
|
||||
|
||||
# Project path bound to this `cow` CLI — disambiguates which checkout the
|
||||
# command actually controls when the user has multiple clones.
|
||||
project_root = get_project_root()
|
||||
click.echo(_t(f" 路径: {project_root}", f" Path: {project_root}"))
|
||||
|
||||
cfg = load_config_json()
|
||||
if cfg:
|
||||
channel = cfg.get("channel_type", "unknown")
|
||||
|
||||
@@ -21,7 +21,7 @@ from bridge.context import Context, ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from common.log import logger
|
||||
from linkai import LinkAIClient, PushMsg
|
||||
from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root
|
||||
from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root, get_weixin_credentials_path
|
||||
from plugins import PluginManager
|
||||
import threading
|
||||
import time
|
||||
@@ -34,7 +34,9 @@ chat_client: LinkAIClient
|
||||
|
||||
CHANNEL_ACTIONS = {"channel_create", "channel_update", "channel_delete"}
|
||||
|
||||
# channelType -> config key mapping for app credentials
|
||||
# channelType -> config key mapping for app credentials.
|
||||
# secret_key may be "" for single-token channels (e.g. telegram/discord).
|
||||
# For slack, appId carries bot_token and appSecret carries app_token.
|
||||
CREDENTIAL_MAP = {
|
||||
"feishu": ("feishu_app_id", "feishu_app_secret"),
|
||||
"dingtalk": ("dingtalk_client_id", "dingtalk_client_secret"),
|
||||
@@ -43,6 +45,9 @@ CREDENTIAL_MAP = {
|
||||
"wechatmp": ("wechatmp_app_id", "wechatmp_app_secret"),
|
||||
"wechatmp_service": ("wechatmp_app_id", "wechatmp_app_secret"),
|
||||
"wechatcom_app": ("wechatcomapp_agent_id", "wechatcomapp_secret"),
|
||||
"telegram": ("telegram_token", ""),
|
||||
"slack": ("slack_bot_token", "slack_app_token"),
|
||||
"discord": ("discord_token", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +172,11 @@ class CloudClient(LinkAIClient):
|
||||
if key in available_setting and config.get(key) is not None:
|
||||
local_config[key] = config.get(key)
|
||||
|
||||
# Self-evolution switch: normalize remote value (bool / "Y"/"N" / "true")
|
||||
# to a real bool so the evolution config parser reads it correctly.
|
||||
if config.get("self_evolution_enabled") is not None:
|
||||
local_config["self_evolution_enabled"] = self._to_bool(config.get("self_evolution_enabled"))
|
||||
|
||||
# Voice settings
|
||||
reply_voice_mode = config.get("reply_voice_mode")
|
||||
if reply_voice_mode:
|
||||
@@ -326,9 +336,7 @@ class CloudClient(LinkAIClient):
|
||||
@staticmethod
|
||||
def _remove_weixin_credentials():
|
||||
"""Remove the weixin token credentials file so next connect triggers QR login."""
|
||||
cred_path = os.path.expanduser(
|
||||
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
|
||||
)
|
||||
cred_path = get_weixin_credentials_path()
|
||||
try:
|
||||
if os.path.exists(cred_path):
|
||||
os.remove(cred_path)
|
||||
@@ -336,6 +344,20 @@ class CloudClient(LinkAIClient):
|
||||
except Exception as e:
|
||||
logger.warning(f"[CloudClient] Failed to remove weixin credentials: {e}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# value helpers
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _to_bool(value) -> bool:
|
||||
"""Normalize a remote config value to bool (bool / "Y"/"N" / "true"/"1")."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return value != 0
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ("y", "yes", "true", "1", "on")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# channel credentials helpers
|
||||
# ------------------------------------------------------------------
|
||||
@@ -357,7 +379,8 @@ class CloudClient(LinkAIClient):
|
||||
local_config[id_key] = app_id
|
||||
os.environ[id_key.upper()] = str(app_id)
|
||||
changed = True
|
||||
if app_secret is not None and local_config.get(secret_key) != app_secret:
|
||||
# secret_key may be empty for single-token channels (e.g. telegram/discord)
|
||||
if secret_key and app_secret is not None and local_config.get(secret_key) != app_secret:
|
||||
local_config[secret_key] = app_secret
|
||||
os.environ[secret_key.upper()] = str(app_secret)
|
||||
changed = True
|
||||
@@ -372,9 +395,10 @@ class CloudClient(LinkAIClient):
|
||||
return
|
||||
id_key, secret_key = cred
|
||||
local_config.pop(id_key, None)
|
||||
local_config.pop(secret_key, None)
|
||||
os.environ.pop(id_key.upper(), None)
|
||||
os.environ.pop(secret_key.upper(), None)
|
||||
if secret_key:
|
||||
local_config.pop(secret_key, None)
|
||||
os.environ.pop(secret_key.upper(), None)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# channel_type list helpers
|
||||
@@ -848,6 +872,10 @@ def _build_config():
|
||||
"agent_max_context_turns": local_conf.get("agent_max_context_turns"),
|
||||
"agent_max_context_tokens": local_conf.get("agent_max_context_tokens"),
|
||||
"agent_max_steps": local_conf.get("agent_max_steps"),
|
||||
# Self-evolution switch reported so the console can reflect state
|
||||
"self_evolution_enabled": "Y" if local_conf.get("self_evolution_enabled") else "N",
|
||||
"self_evolution_idle_minutes": local_conf.get("self_evolution_idle_minutes"),
|
||||
"self_evolution_min_turns": local_conf.get("self_evolution_min_turns"),
|
||||
"channelType": local_conf.get("channel_type"),
|
||||
}
|
||||
|
||||
@@ -862,25 +890,16 @@ def _build_config():
|
||||
if plugin_config.get("Godcmd"):
|
||||
config["admin_password"] = plugin_config.get("Godcmd").get("password")
|
||||
|
||||
# Add channel-specific app credentials
|
||||
# Add channel-specific app credentials based on CREDENTIAL_MAP.
|
||||
# For multi-channel channel_type (comma-separated), the first matched type wins.
|
||||
current_channel_type = local_conf.get("channel_type", "")
|
||||
if current_channel_type == "feishu":
|
||||
config["app_id"] = local_conf.get("feishu_app_id")
|
||||
config["app_secret"] = local_conf.get("feishu_app_secret")
|
||||
elif current_channel_type == "dingtalk":
|
||||
config["app_id"] = local_conf.get("dingtalk_client_id")
|
||||
config["app_secret"] = local_conf.get("dingtalk_client_secret")
|
||||
elif current_channel_type in ("wechatmp", "wechatmp_service"):
|
||||
config["app_id"] = local_conf.get("wechatmp_app_id")
|
||||
config["app_secret"] = local_conf.get("wechatmp_app_secret")
|
||||
elif current_channel_type == "wecom_bot":
|
||||
config["app_id"] = local_conf.get("wecom_bot_id")
|
||||
config["app_secret"] = local_conf.get("wecom_bot_secret")
|
||||
elif current_channel_type == "qq":
|
||||
config["app_id"] = local_conf.get("qq_app_id")
|
||||
config["app_secret"] = local_conf.get("qq_app_secret")
|
||||
elif current_channel_type == "wechatcom_app":
|
||||
config["app_id"] = local_conf.get("wechatcomapp_agent_id")
|
||||
config["app_secret"] = local_conf.get("wechatcomapp_secret")
|
||||
for ch_type in CloudClient._parse_channel_types({"channel_type": current_channel_type}):
|
||||
cred = CREDENTIAL_MAP.get(ch_type)
|
||||
if not cred:
|
||||
continue
|
||||
id_key, secret_key = cred
|
||||
config["app_id"] = local_conf.get(id_key)
|
||||
config["app_secret"] = local_conf.get(secret_key) if secret_key else ""
|
||||
break
|
||||
|
||||
return config
|
||||
|
||||
109
common/const.py
109
common/const.py
@@ -1,4 +1,4 @@
|
||||
# 厂商类型
|
||||
# Provider types
|
||||
OPEN_AI = "openAI"
|
||||
OPENAI = "openai"
|
||||
CHATGPT = "chatGPT" # legacy alias for OPENAI, kept for backward compatibility
|
||||
@@ -8,48 +8,49 @@ XUNFEI = "xunfei"
|
||||
CHATGPTONAZURE = "chatGPTOnAzure"
|
||||
LINKAI = "linkai"
|
||||
CLAUDEAPI= "claudeAPI"
|
||||
QWEN = "qwen" # 千问 (兼容旧配置,实际走 DashscopeBot)
|
||||
QWEN_DASHSCOPE = "dashscope" # 千问 DashScope 接入
|
||||
QWEN = "qwen" # legacy alias, actually routed to DashscopeBot
|
||||
QWEN_DASHSCOPE = "dashscope" # Qwen via DashScope
|
||||
GEMINI = "gemini"
|
||||
ZHIPU_AI = "zhipu"
|
||||
MOONSHOT = "moonshot"
|
||||
MiniMax = "minimax"
|
||||
DEEPSEEK = "deepseek"
|
||||
MIMO = "mimo" # 小米 MiMo 大模型
|
||||
MIMO = "mimo" # Xiaomi MiMo
|
||||
CUSTOM = "custom" # custom OpenAI-compatible API, bot_type won't auto-switch on model change
|
||||
MODELSCOPE = "modelscope"
|
||||
|
||||
# 模型列表
|
||||
# Model list
|
||||
# Claude (Anthropic)
|
||||
CLAUDE3 = "claude-3-opus-20240229"
|
||||
CLAUDE_3_OPUS = "claude-3-opus-latest"
|
||||
CLAUDE_3_OPUS_0229 = "claude-3-opus-20240229"
|
||||
CLAUDE_3_SONNET = "claude-3-sonnet-20240229"
|
||||
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
|
||||
CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # 带 latest 标签的模型名称,会不断更新指向最新发布的模型
|
||||
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # 带具体日期的模型名称,会固定为该日期发布的模型
|
||||
CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # "latest" tag always points to the newest release
|
||||
CLAUDE_35_SONNET_1022 = "claude-3-5-sonnet-20241022" # dated name pinned to a specific release
|
||||
CLAUDE_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
||||
CLAUDE_4_OPUS = "claude-opus-4-0"
|
||||
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent推荐模型
|
||||
CLAUDE_FABLE_5 = "claude-fable-5" # Claude Fable 5 (often restricted by policy)
|
||||
CLAUDE_4_8_OPUS = "claude-opus-4-8" # Claude Opus 4.8 - Agent recommended model
|
||||
CLAUDE_4_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
||||
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
||||
CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0
|
||||
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent推荐模型
|
||||
CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent推荐模型
|
||||
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent recommended model
|
||||
CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent recommended model
|
||||
|
||||
# Gemini (Google)
|
||||
GEMINI_PRO = "gemini-1.0-pro"
|
||||
GEMINI_15_flash = "gemini-1.5-flash"
|
||||
GEMINI_15_PRO = "gemini-1.5-pro"
|
||||
GEMINI_20_flash_exp = "gemini-2.0-flash-exp" # exp结尾为实验模型,会逐步不再支持
|
||||
GEMINI_20_FLASH = "gemini-2.0-flash" # 正式版模型
|
||||
GEMINI_20_flash_exp = "gemini-2.0-flash-exp" # "-exp" models are experimental and will be phased out
|
||||
GEMINI_20_FLASH = "gemini-2.0-flash" # stable release
|
||||
GEMINI_25_FLASH_PRE = "gemini-2.5-flash-preview-05-20"
|
||||
GEMINI_25_PRO_PRE = "gemini-2.5-pro-preview-05-06"
|
||||
GEMINI_3_FLASH_PRE = "gemini-3-flash-preview" # Gemini 3 Flash Preview - Agent推荐模型
|
||||
GEMINI_3_FLASH_PRE = "gemini-3-flash-preview" # Gemini 3 Flash Preview - Agent recommended model
|
||||
GEMINI_3_PRO_PRE = "gemini-3-pro-preview" # Gemini 3 Pro Preview
|
||||
GEMINI_31_PRO_PRE = "gemini-3.1-pro-preview" # Gemini 3.1 Pro Preview - Agent推荐模型
|
||||
GEMINI_31_FLASH_LITE_PRE = "gemini-3.1-flash-lite-preview" # Gemini 3.1 Flash Lite Preview - Agent推荐模型
|
||||
GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash - Agent推荐模型
|
||||
GEMINI_31_PRO_PRE = "gemini-3.1-pro-preview" # Gemini 3.1 Pro Preview - Agent recommended model
|
||||
GEMINI_31_FLASH_LITE_PRE = "gemini-3.1-flash-lite-preview" # Gemini 3.1 Flash Lite Preview - Agent recommended model
|
||||
GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash - Agent recommended model
|
||||
|
||||
# OpenAI
|
||||
GPT35 = "gpt-3.5-turbo"
|
||||
@@ -85,10 +86,10 @@ TTS_1 = "tts-1"
|
||||
TTS_1_HD = "tts-1-hd"
|
||||
|
||||
# DeepSeek
|
||||
DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3对话模型
|
||||
DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1模型
|
||||
DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - 默认推荐 (思考模式 + 工具调用)
|
||||
DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - 复杂任务更强 (思考模式 + 工具调用)
|
||||
DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3 chat model
|
||||
DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1 model
|
||||
DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - default recommendation (thinking + tool calls)
|
||||
DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - stronger on complex tasks (thinking + tool calls)
|
||||
|
||||
# Baidu Qianfan / ERNIE
|
||||
ERNIE_5_1 = "ernie-5.1" # ERNIE 5.1 - default recommendation, latest flagship
|
||||
@@ -100,32 +101,31 @@ ERNIE_4_TURBO_8K = "ERNIE-4.0-Turbo-8K"
|
||||
ERNIE_45_TURBO_VL = "ernie-4.5-turbo-vl"
|
||||
ERNIE_45_TURBO_VL_32K = "ernie-4.5-turbo-vl-32k"
|
||||
|
||||
# Qwen (通义千问 - 阿里云 DashScope)
|
||||
# Qwen (Alibaba Cloud DashScope)
|
||||
QWEN_TURBO = "qwen-turbo"
|
||||
QWEN_PLUS = "qwen-plus"
|
||||
QWEN_MAX = "qwen-max"
|
||||
QWEN_LONG = "qwen-long"
|
||||
QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent推荐模型
|
||||
QWEN3_MAX = "qwen3-max" # Qwen3 Max - Agent recommended model
|
||||
QWEN35_PLUS = "qwen3.5-plus" # Qwen3.5 Plus - Omni model (MultiModalConversation)
|
||||
QWEN36_PLUS = "qwen3.6-plus" # Qwen3.6 Plus - Omni model (MultiModalConversation)
|
||||
QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent推荐模型
|
||||
QWEN37_PLUS = "qwen3.7-plus" # Qwen3.7 Plus - Omni model (MultiModalConversation)
|
||||
QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent recommended model
|
||||
QWQ_PLUS = "qwq-plus"
|
||||
|
||||
# MiniMax
|
||||
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7 - Latest
|
||||
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax 多模态 (vision)
|
||||
MINIMAX_M3 = "MiniMax-M3" # MiniMax M3 - Latest (default)
|
||||
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7
|
||||
MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
|
||||
MINIMAX_M2_5 = "MiniMax-M2.5" # MiniMax M2.5
|
||||
MINIMAX_M2_1 = "MiniMax-M2.1" # MiniMax M2.1
|
||||
MINIMAX_M2_1_LIGHTNING = "MiniMax-M2.1-lightning" # MiniMax M2.1 极速版
|
||||
MINIMAX_M2 = "MiniMax-M2" # MiniMax M2
|
||||
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax multimodal (vision)
|
||||
MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
|
||||
|
||||
# GLM (智谱AI)
|
||||
GLM_5_1 = "glm-5.1" # 智谱 GLM-5.1 - Agent recommended model (default)
|
||||
GLM_5_TURBO = "glm-5-turbo" # 智谱 GLM-5-Turbo
|
||||
GLM_5 = "glm-5" # 智谱 GLM-5
|
||||
GLM_5V_TURBO = "glm-5v-turbo" # 智谱多模态 (vision)
|
||||
# GLM (Zhipu AI)
|
||||
GLM_5_2 = "glm-5.2" # GLM-5.2 - Agent recommended model (default)
|
||||
GLM_5_1 = "glm-5.1" # GLM-5.1
|
||||
GLM_5_TURBO = "glm-5-turbo" # GLM-5-Turbo
|
||||
GLM_5 = "glm-5" # GLM-5
|
||||
GLM_5V_TURBO = "glm-5v-turbo" # Zhipu multimodal (vision)
|
||||
GLM_4 = "glm-4"
|
||||
GLM_4_PLUS = "glm-4-plus"
|
||||
GLM_4_flash = "glm-4-flash"
|
||||
@@ -134,20 +134,22 @@ GLM_4_ALLTOOLS = "glm-4-alltools"
|
||||
GLM_4_0520 = "glm-4-0520"
|
||||
GLM_4_AIR = "glm-4-air"
|
||||
GLM_4_AIRX = "glm-4-airx"
|
||||
GLM_4_7 = "glm-4.7" # 智谱 GLM-4.7 - Agent推荐模型
|
||||
GLM_4_7 = "glm-4.7" # GLM-4.7 - Agent recommended model
|
||||
|
||||
# Kimi (Moonshot)
|
||||
MOONSHOT = "moonshot"
|
||||
KIMI_K2_7_CODE = "kimi-k2.7-code" # Kimi K2.7 Code - Agent recommended model (default)
|
||||
KIMI_K2_7_CODE_HIGHSPEED = "kimi-k2.7-code-highspeed" # Kimi K2.7 Code highspeed
|
||||
KIMI_K2 = "kimi-k2"
|
||||
KIMI_K2_5 = "kimi-k2.5"
|
||||
KIMI_K2_6 = "kimi-k2.6" # Kimi K2.6 - Agent recommended model (default)
|
||||
KIMI_K2_6 = "kimi-k2.6"
|
||||
|
||||
# 小米 MiMo
|
||||
MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - 旗舰,长上下文(默认推荐)
|
||||
MIMO_V2_5 = "mimo-v2.5" # MiMo V2.5 - 多模态(文/图/音/视频)
|
||||
# Xiaomi MiMo
|
||||
MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - flagship, long context (default recommendation)
|
||||
MIMO_V2_5 = "mimo-v2.5" # MiMo V2.5 - multimodal (text/image/audio/video)
|
||||
MIMO_V2_PRO = "mimo-v2-pro" # MiMo V2 Pro
|
||||
MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - 多模态
|
||||
MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - 极速版
|
||||
MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - multimodal
|
||||
MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - high-speed
|
||||
|
||||
# Doubao (Volcengine Ark)
|
||||
DOUBAO = "doubao"
|
||||
@@ -156,11 +158,11 @@ DOUBAO_SEED_2_PRO = "doubao-seed-2-0-pro-260215"
|
||||
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
|
||||
DOUBAO_SEED_2_MINI = "doubao-seed-2-0-mini-260215"
|
||||
|
||||
# ModelScope(魔搭社区)
|
||||
# ModelScope
|
||||
QWEN3_235B_A22B_INSTRUCT_2507 = "Qwen/Qwen3-235B-A22B-Instruct-2507"
|
||||
QWEN3_5_27B = "Qwen/Qwen3.5-27B"
|
||||
|
||||
# 其他模型
|
||||
# Other models
|
||||
WEN_XIN = "wenxin"
|
||||
WEN_XIN_4 = "wenxin-4"
|
||||
XUNFEI = "xunfei"
|
||||
@@ -189,13 +191,13 @@ MODEL_LIST = [
|
||||
ERNIE_45_TURBO_VL, ERNIE_45_TURBO_VL_32K,
|
||||
|
||||
# MiniMax
|
||||
MiniMax, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_M2_5, MINIMAX_M2_1, MINIMAX_M2_1_LIGHTNING, MINIMAX_M2, MINIMAX_ABAB6_5,
|
||||
MiniMax, MINIMAX_M3, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, MINIMAX_ABAB6_5,
|
||||
|
||||
# 小米 MiMo
|
||||
# Xiaomi MiMo
|
||||
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||
|
||||
# Claude
|
||||
CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
||||
CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_FABLE_5, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
||||
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
||||
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||
|
||||
@@ -213,19 +215,19 @@ MODEL_LIST = [
|
||||
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
||||
O1, O1_MINI,
|
||||
|
||||
# GLM (智谱AI)
|
||||
ZHIPU_AI, GLM_5_1, GLM_5_TURBO, GLM_5, GLM_4, GLM_4_PLUS, GLM_4_flash, GLM_4_LONG, GLM_4_ALLTOOLS,
|
||||
# GLM (Zhipu AI)
|
||||
ZHIPU_AI, GLM_5_2, GLM_5_1, GLM_5_TURBO, GLM_5, GLM_4, GLM_4_PLUS, GLM_4_flash, GLM_4_LONG, GLM_4_ALLTOOLS,
|
||||
GLM_4_0520, GLM_4_AIR, GLM_4_AIRX, GLM_4_7,
|
||||
|
||||
# Qwen (通义千问)
|
||||
QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
|
||||
# Qwen
|
||||
QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
|
||||
|
||||
# Doubao (豆包)
|
||||
# Doubao
|
||||
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
||||
|
||||
# Kimi (Moonshot)
|
||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
||||
KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
||||
KIMI_K2_7_CODE, KIMI_K2_7_CODE_HIGHSPEED, KIMI_K2_6, KIMI_K2_5, KIMI_K2,
|
||||
|
||||
# ModelScope
|
||||
MODELSCOPE,
|
||||
@@ -233,11 +235,12 @@ MODEL_LIST = [
|
||||
# LinkAI
|
||||
LINKAI_35, LINKAI_4_TURBO, LINKAI_4o,
|
||||
|
||||
# 其他模型
|
||||
# Other models
|
||||
WEN_XIN, WEN_XIN_4, XUNFEI,
|
||||
]
|
||||
|
||||
MODEL_LIST = MODEL_LIST + GITEE_AI_MODEL_LIST + MODELSCOPE_MODEL_LIST
|
||||
|
||||
# channel
|
||||
FEISHU = "feishu"
|
||||
DINGTALK = "dingtalk"
|
||||
|
||||
@@ -124,6 +124,8 @@ def detect_language():
|
||||
3. Python locale module
|
||||
4. default English
|
||||
"""
|
||||
if os.environ.get("CLOUD_DEPLOYMENT_ID"):
|
||||
return ZH
|
||||
return (
|
||||
_detect_from_macos()
|
||||
or _detect_from_env()
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import io
|
||||
|
||||
|
||||
def _log_path():
|
||||
# Mirror config.get_data_root() without importing config (avoids a circular
|
||||
# import, since config imports this module). The desktop build sets
|
||||
# COW_DATA_DIR (e.g. ~/.cow); source deployments fall back to CWD.
|
||||
data_dir = os.environ.get("COW_DATA_DIR")
|
||||
if data_dir:
|
||||
data_dir = os.path.expanduser(data_dir)
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
return os.path.join(data_dir, "run.log")
|
||||
return "run.log"
|
||||
|
||||
|
||||
def _reset_logger(log):
|
||||
for handler in log.handlers:
|
||||
handler.close()
|
||||
@@ -20,7 +33,7 @@ def _reset_logger(log):
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
file_handle = logging.FileHandler("run.log", encoding="utf-8")
|
||||
file_handle = logging.FileHandler(_log_path(), encoding="utf-8")
|
||||
file_handle.setFormatter(
|
||||
logging.Formatter(
|
||||
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from common.utils import expand_path
|
||||
from config import conf
|
||||
|
||||
|
||||
class TmpDir(object):
|
||||
"""A temporary directory that is deleted when the object is destroyed."""
|
||||
"""Temporary directory for transient artifacts (e.g. synthesized voice).
|
||||
|
||||
tmpFilePath = pathlib.Path("./tmp/")
|
||||
Resolves to ``<agent_workspace>/tmp`` (default ``~/cow/tmp``) so temp files
|
||||
land inside the agent workspace instead of a CWD-relative ``./tmp``, which
|
||||
is unreliable for the packaged desktop app where CWD is undefined.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pathExists = os.path.exists(self.tmpFilePath)
|
||||
if not pathExists:
|
||||
os.makedirs(self.tmpFilePath)
|
||||
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
self.tmpFilePath = os.path.join(ws_root, "tmp")
|
||||
os.makedirs(self.tmpFilePath, exist_ok=True)
|
||||
|
||||
def path(self):
|
||||
return str(self.tmpFilePath) + "/"
|
||||
|
||||
@@ -27,10 +27,14 @@ def compress_imgfile(file, max_size):
|
||||
img = Image.open(file)
|
||||
rgb_image = img.convert("RGB")
|
||||
quality = 95
|
||||
min_quality = 10
|
||||
while True:
|
||||
out_buf = io.BytesIO()
|
||||
rgb_image.save(out_buf, "JPEG", quality=quality)
|
||||
if fsize(out_buf) <= max_size:
|
||||
if fsize(out_buf) <= max_size or quality <= min_quality:
|
||||
# Stop at min_quality: further decrements would pass an invalid
|
||||
# quality (<1) to PIL and the loop would otherwise never terminate
|
||||
# for images that cannot be compressed below max_size.
|
||||
return out_buf
|
||||
quality -= 5
|
||||
|
||||
|
||||
@@ -40,5 +40,6 @@
|
||||
"agent_max_steps": 20,
|
||||
"enable_thinking": false,
|
||||
"reasoning_effort": "high",
|
||||
"knowledge": true
|
||||
"knowledge": true,
|
||||
"self_evolution_enabled": true
|
||||
}
|
||||
|
||||
108
config.py
108
config.py
@@ -5,6 +5,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import sys
|
||||
|
||||
from common.log import logger
|
||||
from common import i18n
|
||||
@@ -24,8 +25,11 @@ available_setting = {
|
||||
"open_ai_api_base": "https://api.openai.com/v1",
|
||||
"claude_api_base": "https://api.anthropic.com/v1", # claude api base
|
||||
"gemini_api_base": "https://generativelanguage.googleapis.com", # gemini api base
|
||||
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom")
|
||||
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom")
|
||||
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field
|
||||
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field
|
||||
# Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:<id>".
|
||||
# Each item: {"id": "3f2a9c1b", "name": "my-provider", "api_key": "sk-...", "api_base": "https://api.example.com/v1", "model": "model-name"}
|
||||
"custom_providers": [],
|
||||
"proxy": "", # proxy used by openai
|
||||
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
|
||||
"model": "gpt-3.5-turbo", # options: gpt-4o, gpt-4o-mini, gpt-4-turbo, claude-3-sonnet, wenxin, moonshot, qwen-turbo, xunfei, glm-4, minimax, gemini, etc. See common/const.py for the full list
|
||||
@@ -180,6 +184,11 @@ available_setting = {
|
||||
# WeCom smart bot config (long connection mode)
|
||||
"wecom_bot_id": "", # WeCom smart bot BotID
|
||||
"wecom_bot_secret": "", # WeCom smart bot long-connection secret
|
||||
# WeCom smart bot transport mode: "websocket" (long connection) or "webhook" (HTTP callback)
|
||||
"wecom_bot_mode": "websocket",
|
||||
"wecom_bot_token": "", # webhook mode: Token configured on the bot's receive-message URL
|
||||
"wecom_bot_encoding_aes_key": "", # webhook mode: EncodingAESKey configured on the bot's receive-message URL
|
||||
"wecom_bot_port": 9892, # webhook mode: local HTTP server port for the receive-message URL
|
||||
# Telegram config
|
||||
"telegram_token": "", # Bot token from @BotFather
|
||||
"telegram_proxy": "", # Optional HTTP/SOCKS5 proxy, e.g. http://127.0.0.1:7890 or socks5://127.0.0.1:1080 (empty falls back to env vars)
|
||||
@@ -251,6 +260,10 @@ available_setting = {
|
||||
"enable_thinking": False, # Enable deep-thinking mode for thinking-capable models
|
||||
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
|
||||
"knowledge": True, # whether to enable the knowledge base feature
|
||||
# Self-evolution: review idle conversations to learn memory/skills. Flat keys.
|
||||
"self_evolution_enabled": False, # switch to enable/disable self-evolution
|
||||
"self_evolution_idle_minutes": 10, # idle time before a session is reviewed
|
||||
"self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger
|
||||
"skill": {}, # Per-skill runtime config; nested keys flatten to SKILL_<NAME>_<KEY> env vars at startup
|
||||
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
|
||||
}
|
||||
@@ -317,24 +330,37 @@ class Config(dict):
|
||||
config = Config()
|
||||
|
||||
|
||||
def _mask_value(val):
|
||||
"""Mask a sensitive string value, keeping first 3 and last 3 chars."""
|
||||
if not isinstance(val, str) or len(val) <= 8:
|
||||
return val
|
||||
return val[0:3] + "*" * 5 + val[-3:]
|
||||
|
||||
|
||||
def _mask_sensitive_recursive(obj):
|
||||
"""Recursively mask values whose keys contain 'key' or 'secret'."""
|
||||
if isinstance(obj, dict):
|
||||
masked = {}
|
||||
for k, v in obj.items():
|
||||
if ("key" in k or "secret" in k) and isinstance(v, str):
|
||||
masked[k] = _mask_value(v)
|
||||
else:
|
||||
masked[k] = _mask_sensitive_recursive(v)
|
||||
return masked
|
||||
elif isinstance(obj, list):
|
||||
return [_mask_sensitive_recursive(item) for item in obj]
|
||||
return obj
|
||||
|
||||
|
||||
def drag_sensitive(config):
|
||||
try:
|
||||
if isinstance(config, str):
|
||||
conf_dict: dict = json.loads(config)
|
||||
conf_dict_copy = copy.deepcopy(conf_dict)
|
||||
for key in conf_dict_copy:
|
||||
if "key" in key or "secret" in key:
|
||||
if isinstance(conf_dict_copy[key], str):
|
||||
conf_dict_copy[key] = conf_dict_copy[key][0:3] + "*" * 5 + conf_dict_copy[key][-3:]
|
||||
conf_dict_copy = _mask_sensitive_recursive(conf_dict)
|
||||
return json.dumps(conf_dict_copy, indent=4)
|
||||
|
||||
elif isinstance(config, dict):
|
||||
config_copy = copy.deepcopy(config)
|
||||
for key in config:
|
||||
if "key" in key or "secret" in key:
|
||||
if isinstance(config_copy[key], str):
|
||||
config_copy[key] = config_copy[key][0:3] + "*" * 5 + config_copy[key][-3:]
|
||||
return config_copy
|
||||
return _mask_sensitive_recursive(config)
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
return config
|
||||
@@ -352,10 +378,16 @@ def load_config():
|
||||
logger.info(" \\____\\___/ \\_/\\_//_/ \\_\\__, |\\___|_| |_|\\__|")
|
||||
logger.info(" |___/ ")
|
||||
logger.info("")
|
||||
config_path = "./config.json"
|
||||
# User config lives in the data root: source deployments use CWD (./), while
|
||||
# the desktop build points COW_DATA_DIR at ~/.cow so config survives updates.
|
||||
config_path = os.path.join(get_data_root(), "config.json")
|
||||
if not os.path.exists(config_path):
|
||||
logger.info("config file not found, falling back to config-template.json")
|
||||
config_path = "./config-template.json"
|
||||
# Resolve the template via get_resource_root() so it works both from
|
||||
# source and from a frozen (PyInstaller) bundle, where the template
|
||||
# ships inside the bundle (sys._MEIPASS) and CWD may differ.
|
||||
template_path = os.path.join(get_resource_root(), "config-template.json")
|
||||
config_path = template_path if os.path.exists(template_path) else "./config-template.json"
|
||||
|
||||
config_str = read_file(config_path)
|
||||
logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str)))
|
||||
@@ -595,6 +627,34 @@ def get_root():
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_resource_root():
|
||||
"""Directory holding bundled read-only resources (e.g. config-template.json).
|
||||
|
||||
Under PyInstaller, data files live in sys._MEIPASS (the onedir _internal
|
||||
folder), which differs from get_root() — the latter is used for writable
|
||||
user data and should stay next to the executable, not inside the bundle.
|
||||
"""
|
||||
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
||||
return sys._MEIPASS
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_data_root():
|
||||
"""Directory for writable user data (config.json, user_datas.pkl, run.log).
|
||||
|
||||
The desktop build sets COW_DATA_DIR (e.g. ~/.cow) so data lives in the
|
||||
user's home rather than inside the read-only app bundle and survives app
|
||||
updates. When unset (source deployment), it falls back to get_root(), so
|
||||
existing behavior is unchanged.
|
||||
"""
|
||||
data_dir = os.environ.get("COW_DATA_DIR")
|
||||
if data_dir:
|
||||
data_dir = os.path.expanduser(data_dir)
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
return data_dir
|
||||
return get_root()
|
||||
|
||||
|
||||
def read_file(path):
|
||||
with open(path, mode="r", encoding="utf-8-sig") as f:
|
||||
return f.read()
|
||||
@@ -605,13 +665,29 @@ def conf():
|
||||
|
||||
|
||||
def get_appdata_dir():
|
||||
data_path = os.path.join(get_root(), conf().get("appdata_dir", ""))
|
||||
data_path = os.path.join(get_data_root(), conf().get("appdata_dir", ""))
|
||||
if not os.path.exists(data_path):
|
||||
logger.info("[INIT] data path not exists, create it: {}".format(data_path))
|
||||
os.makedirs(data_path)
|
||||
return data_path
|
||||
|
||||
|
||||
def get_weixin_credentials_path():
|
||||
"""Resolve the Weixin credentials (token) file path.
|
||||
|
||||
Honors an explicit ``weixin_credentials_path`` from config. Otherwise the
|
||||
packaged desktop build (COW_DATA_DIR set) keeps it under the data dir
|
||||
(~/.cow) so all user data stays together, while source deployments retain
|
||||
the legacy ~/.weixin_cow_credentials.json default unchanged.
|
||||
"""
|
||||
configured = conf().get("weixin_credentials_path")
|
||||
if configured:
|
||||
return os.path.expanduser(configured)
|
||||
if os.environ.get("COW_DATA_DIR"):
|
||||
return os.path.join(get_data_root(), "weixin_credentials.json")
|
||||
return os.path.expanduser("~/.weixin_cow_credentials.json")
|
||||
|
||||
|
||||
def subscribe_msg():
|
||||
trigger_prefix = conf().get("single_chat_prefix", [""])[0]
|
||||
msg = conf().get("subscribe_msg", "")
|
||||
|
||||
6
desktop/.gitignore
vendored
Normal file
6
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
81
desktop/README.md
Normal file
81
desktop/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# CowAgent Desktop
|
||||
|
||||
Cross-platform desktop client for CowAgent, built with Electron + React + TypeScript.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
- Python 3.7+ (for the backend)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
npm install
|
||||
```
|
||||
|
||||
### Run in Development
|
||||
|
||||
Start the renderer dev server and Electron together:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Or run them separately:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start Vite dev server
|
||||
npm run dev:renderer
|
||||
|
||||
# Terminal 2: Start Electron (after renderer is ready)
|
||||
npm run dev:main
|
||||
```
|
||||
|
||||
The app will automatically start the Python backend from the parent directory.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Build for current platform
|
||||
npm run dist
|
||||
|
||||
# Build for macOS only
|
||||
npm run dist:mac
|
||||
|
||||
# Build for Windows only
|
||||
npm run dist:win
|
||||
```
|
||||
|
||||
Build outputs are placed in the `release/` directory.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
desktop/
|
||||
├── src/
|
||||
│ ├── main/ # Electron main process
|
||||
│ │ ├── index.ts # Window management, IPC
|
||||
│ │ ├── python-manager.ts # Python backend lifecycle
|
||||
│ │ └── preload.ts # Context bridge for renderer
|
||||
│ └── renderer/ # React UI (Vite)
|
||||
│ └── src/
|
||||
│ ├── api/ # HTTP client for backend APIs
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── hooks/ # React hooks
|
||||
│ ├── pages/ # Page components
|
||||
│ └── types.ts # TypeScript types
|
||||
├── resources/ # App icons
|
||||
├── package.json # Dependencies and build config
|
||||
└── vite.config.ts # Vite config
|
||||
```
|
||||
|
||||
### How it Works
|
||||
|
||||
1. Electron main process starts and creates the app window
|
||||
2. It spawns the Python backend (`app.py`) as a child process
|
||||
3. The React UI communicates with the Python backend via HTTP APIs
|
||||
4. SSE (Server-Sent Events) is used for streaming chat responses and live logs
|
||||
79
desktop/build/build-backend.sh
Executable file
79
desktop/build/build-backend.sh
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the desktop backend into a self-contained onedir bundle via PyInstaller.
|
||||
# Run from anywhere; paths are resolved relative to the repo root.
|
||||
#
|
||||
# Usage:
|
||||
# bash desktop/build/build-backend.sh # build
|
||||
# PYTHON=python3.11 bash desktop/build/build-backend.sh # pick interpreter
|
||||
#
|
||||
# Output: desktop/build/dist/cowagent-backend/ (folder with the executable)
|
||||
set -euo pipefail
|
||||
|
||||
# --- resolve paths --------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR"
|
||||
VENV_DIR="$BUILD_DIR/.venv-build"
|
||||
|
||||
# Prefer Python 3.11 when available: on 3.13+ web.py must be installed from a
|
||||
# GitHub git source (the PyPI build fails), which is flaky on some networks.
|
||||
# 3.11 installs web.py straight from PyPI and has the best PyInstaller support.
|
||||
if [ -z "${PYTHON:-}" ]; then
|
||||
for cand in \
|
||||
"/Library/Frameworks/Python.framework/Versions/3.11/bin/python3.11" \
|
||||
"python3.11" \
|
||||
"python3.12" \
|
||||
"python3"; do
|
||||
if command -v "$cand" >/dev/null 2>&1; then
|
||||
PYTHON="$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
# Prefer Python 3.11: it installs web.py from PyPI (no GitHub clone) and avoids
|
||||
# 3.13's removed-cgi compatibility shims. Override with PYTHON=... if needed.
|
||||
pick_python() {
|
||||
if [ -n "${PYTHON:-}" ]; then echo "$PYTHON"; return; fi
|
||||
for c in python3.11 python3.12 python3.10 python3; do
|
||||
if command -v "$c" >/dev/null 2>&1; then echo "$c"; return; fi
|
||||
done
|
||||
echo python3
|
||||
}
|
||||
PYTHON="$(pick_python)"
|
||||
|
||||
echo "==> Repo root: $ROOT"
|
||||
echo "==> Using Python: $($PYTHON --version 2>&1) ($PYTHON)"
|
||||
|
||||
# --- isolated build venv --------------------------------------------------
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "==> Creating build venv at $VENV_DIR"
|
||||
"$PYTHON" -m venv "$VENV_DIR"
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
echo "==> Installing build dependencies"
|
||||
pip install -q --upgrade pip
|
||||
# Don't leave a half-populated venv behind if deps fail (e.g. flaky network):
|
||||
# the next run would otherwise reuse a broken venv.
|
||||
if ! pip install -q -r "$BUILD_DIR/requirements-desktop.txt"; then
|
||||
echo "!! Dependency install failed. Removing the build venv so a retry starts clean." >&2
|
||||
deactivate || true
|
||||
rm -rf "$VENV_DIR"
|
||||
exit 1
|
||||
fi
|
||||
pip install -q pyinstaller
|
||||
|
||||
# --- run pyinstaller from repo root so relative datas resolve -------------
|
||||
cd "$ROOT"
|
||||
echo "==> Running PyInstaller (onedir)"
|
||||
pyinstaller "$BUILD_DIR/cowagent-backend.spec" \
|
||||
--noconfirm \
|
||||
--distpath "$BUILD_DIR/dist" \
|
||||
--workpath "$BUILD_DIR/build-work"
|
||||
|
||||
echo ""
|
||||
echo "==> Done. Bundle at: $BUILD_DIR/dist/cowagent-backend/"
|
||||
du -sh "$BUILD_DIR/dist/cowagent-backend/" 2>/dev/null || true
|
||||
echo "==> Smoke test: COW_DESKTOP=1 \"$BUILD_DIR/dist/cowagent-backend/cowagent-backend\""
|
||||
145
desktop/build/cowagent-backend.spec
Normal file
145
desktop/build/cowagent-backend.spec
Normal file
@@ -0,0 +1,145 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""
|
||||
PyInstaller spec for the CowAgent desktop backend (onedir).
|
||||
|
||||
Produces a self-contained `cowagent-backend` folder that the Electron app
|
||||
spawns directly, so end users don't need Python installed.
|
||||
|
||||
onedir is chosen over onefile because the backend reads data files via paths
|
||||
relative to the source tree (e.g. config-template.json, skills/, chat.html);
|
||||
onedir preserves that layout, while onefile's temp-extraction would break it.
|
||||
|
||||
Build from the repo root:
|
||||
pyinstaller desktop/build/cowagent-backend.spec --noconfirm
|
||||
"""
|
||||
import os
|
||||
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
|
||||
|
||||
# Resolve the repo root from the spec's own location (desktop/build/ -> root),
|
||||
# independent of the current working directory. PyInstaller exposes SPECPATH.
|
||||
ROOT = os.path.abspath(os.path.join(SPECPATH, '..', '..'))
|
||||
|
||||
|
||||
def rp(*parts):
|
||||
"""Absolute path under the repo root."""
|
||||
return os.path.join(ROOT, *parts)
|
||||
|
||||
# --- Hidden imports -------------------------------------------------------
|
||||
# Channels are imported dynamically by channel_factory via string names, so
|
||||
# PyInstaller's static analysis can't see them. List every channel we ship
|
||||
# (Feishu is intentionally excluded — lark-oapi is dropped from the desktop
|
||||
# build to save ~116MB).
|
||||
hiddenimports = [
|
||||
# channels (dynamic import in channel/channel_factory.py)
|
||||
'channel.web.web_channel',
|
||||
'channel.terminal.terminal_channel',
|
||||
'channel.weixin.weixin_channel',
|
||||
'channel.wechatmp.wechatmp_channel',
|
||||
'channel.wechatcom.wechatcomapp_channel',
|
||||
'channel.wechat_kf.wechat_kf_channel',
|
||||
'channel.dingtalk.dingtalk_channel',
|
||||
'channel.wecom_bot.wecom_bot_channel',
|
||||
'channel.qq.qq_channel',
|
||||
'channel.telegram.telegram_channel',
|
||||
'channel.slack.slack_channel',
|
||||
'channel.discord.discord_channel',
|
||||
]
|
||||
|
||||
# Agent tools and model providers are imported lazily in places; collect their
|
||||
# submodules so nothing is missed at runtime.
|
||||
hiddenimports += collect_submodules('agent.tools')
|
||||
hiddenimports += collect_submodules('models')
|
||||
hiddenimports += collect_submodules('voice')
|
||||
hiddenimports += collect_submodules('bridge')
|
||||
|
||||
# Plugin framework: WebChannel -> ChatChannel imports `from plugins import *`,
|
||||
# so the framework package must be present even though desktop mode never loads
|
||||
# actual plugins (it's only ~tens of KB of code).
|
||||
hiddenimports += [
|
||||
'plugins',
|
||||
'plugins.event',
|
||||
'plugins.plugin',
|
||||
'plugins.plugin_manager',
|
||||
]
|
||||
|
||||
# Third-party SDKs that use lazy/conditional imports internally.
|
||||
hiddenimports += collect_submodules('dashscope')
|
||||
hiddenimports += [
|
||||
'tiktoken_ext',
|
||||
'tiktoken_ext.openai_public',
|
||||
]
|
||||
|
||||
# --- Data files -----------------------------------------------------------
|
||||
# Runtime-read files/dirs that must travel with the executable. Paths are
|
||||
# (source, dest_dir_in_bundle).
|
||||
datas = [
|
||||
(rp('config-template.json'), '.'),
|
||||
(rp('skills'), 'skills'),
|
||||
# Web console served on the backend port: ship chat.html plus its static
|
||||
# assets (~1.9MB) so the browser-based console works as a debug/fallback
|
||||
# entry alongside the Electron UI.
|
||||
(rp('channel', 'web', 'chat.html'), 'channel/web'),
|
||||
(rp('channel', 'web', 'static'), 'channel/web/static'),
|
||||
]
|
||||
|
||||
# Some libraries (tiktoken encodings, etc.) ship data files.
|
||||
datas += collect_data_files('tiktoken_ext', include_py_files=False)
|
||||
|
||||
# --- Excludes -------------------------------------------------------------
|
||||
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
|
||||
# mode), tests/docs, and dev-only packages.
|
||||
excludes = [
|
||||
'lark_oapi', # Feishu — ~116MB, excluded from desktop build
|
||||
'tests',
|
||||
'pip',
|
||||
'wheel',
|
||||
'pytest',
|
||||
'playwright', # browser tool is opt-in, not bundled
|
||||
]
|
||||
|
||||
block_cipher = None
|
||||
|
||||
a = Analysis(
|
||||
[rp('app.py')],
|
||||
pathex=[ROOT],
|
||||
binaries=[],
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=excludes,
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='cowagent-backend',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
name='cowagent-backend',
|
||||
)
|
||||
52
desktop/build/requirements-desktop.txt
Normal file
52
desktop/build/requirements-desktop.txt
Normal file
@@ -0,0 +1,52 @@
|
||||
# Desktop backend dependencies (slimmed down from the full requirements).
|
||||
#
|
||||
# Goal: keep the package light. The desktop client only needs the web channel
|
||||
# (which Electron talks to) plus the agent core; the remaining IM channels are
|
||||
# cheap (~27MB total) so we keep them, but Feishu's `lark-oapi` (~116MB) is
|
||||
# dropped — it is by far the heaviest dependency and not needed for a C-end
|
||||
# desktop app. Feishu is hidden in desktop mode (see COW_DESKTOP in app.py).
|
||||
|
||||
# ---- core ----
|
||||
numpy>=1.21
|
||||
aiohttp>=3.8.6,<3.10
|
||||
requests>=2.28.2
|
||||
chardet>=5.1.0
|
||||
Pillow
|
||||
python-dotenv>=1.0.0
|
||||
PyYAML>=6.0
|
||||
croniter>=2.0.0
|
||||
click>=8.0
|
||||
qrcode
|
||||
json-repair
|
||||
|
||||
# ---- web framework (web channel) ----
|
||||
# web.py 0.62 fails to build on Python 3.13+ (cgi removed); use the GitHub fix.
|
||||
web.py; python_version < "3.13"
|
||||
web.py @ git+https://github.com/webpy/webpy.git ; python_version >= "3.13"
|
||||
legacy-cgi; python_version >= "3.13"
|
||||
|
||||
# ---- AI model SDKs ----
|
||||
zai-sdk
|
||||
dashscope
|
||||
tenacity # used by some dashscope submodules (retry logic)
|
||||
google-generativeai
|
||||
tiktoken>=0.3.2
|
||||
|
||||
# ---- voice (TTS/ASR) — kept per product decision ----
|
||||
pydub>=0.25.1
|
||||
gTTS>=2.3.1
|
||||
|
||||
# ---- document parsing (web_fetch / knowledge) ----
|
||||
pypdf
|
||||
python-docx
|
||||
openpyxl
|
||||
python-pptx
|
||||
|
||||
# ---- IM channels (kept; lightweight). Feishu/lark-oapi intentionally excluded. ----
|
||||
wechatpy
|
||||
pycryptodome
|
||||
dingtalk_stream
|
||||
websocket-client>=1.4.0
|
||||
python-telegram-bot
|
||||
slack_bolt
|
||||
discord.py
|
||||
8300
desktop/package-lock.json
generated
Normal file
8300
desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
103
desktop/package.json
Normal file
103
desktop/package.json
Normal file
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"name": "cowagent-desktop",
|
||||
"version": "1.0.0",
|
||||
"description": "CowAgent Desktop Client - AI Agent on your desktop",
|
||||
"main": "dist/main/index.js",
|
||||
"author": "CowAgent",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "npm run build && electron .",
|
||||
"dev:hot": "concurrently \"npm run dev:renderer\" \"sleep 2 && npm run dev:main\"",
|
||||
"dev:main": "tsc -p tsconfig.main.json && electron .",
|
||||
"dev:renderer": "vite",
|
||||
"build": "npm run build:renderer && npm run build:main",
|
||||
"build:main": "tsc -p tsconfig.main.json",
|
||||
"build:renderer": "vite build",
|
||||
"dist": "npm run build && electron-builder",
|
||||
"dist:mac": "npm run build && electron-builder --mac",
|
||||
"dist:win": "npm run build && electron-builder --win"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"markdown-it": "^14.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.0",
|
||||
"electron": "^33.2.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.cowagent.desktop",
|
||||
"productName": "CowAgent",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"resources/**/*"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/dist/cowagent-backend",
|
||||
"to": "backend/cowagent-backend"
|
||||
},
|
||||
{
|
||||
"from": "resources",
|
||||
"to": ".",
|
||||
"filter": [
|
||||
"icon.png"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.productivity",
|
||||
"icon": "resources/icon.icns",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"arm64",
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"icon": "resources/icon.ico",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"owner": "zhayujie",
|
||||
"repo": "chatgpt-on-wechat"
|
||||
}
|
||||
}
|
||||
}
|
||||
6
desktop/postcss.config.js
Normal file
6
desktop/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
desktop/resources/icon.icns
Normal file
BIN
desktop/resources/icon.icns
Normal file
Binary file not shown.
BIN
desktop/resources/icon.ico
Normal file
BIN
desktop/resources/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
desktop/resources/icon.png
Normal file
BIN
desktop/resources/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
307
desktop/src/main/index.ts
Normal file
307
desktop/src/main/index.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { app, BrowserWindow, shell, ipcMain, dialog, nativeImage } from 'electron'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import http from 'http'
|
||||
import { PythonBackend } from './python-manager'
|
||||
import { buildAppMenu } from './menu'
|
||||
import { createTray, destroyTray } from './tray'
|
||||
import { initUpdater, checkForUpdates, startDownload, quitAndInstall } from './updater'
|
||||
|
||||
// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
|
||||
// where the default Electron binary would otherwise report "Electron".
|
||||
app.setName('CowAgent')
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let pythonBackend: PythonBackend | null = null
|
||||
// True once the user explicitly quits (menu/tray), so close-to-tray is bypassed.
|
||||
let isQuitting = false
|
||||
|
||||
const isDev = !app.isPackaged
|
||||
const VITE_DEV_PORTS = [5173, 5174, 5175, 5176]
|
||||
|
||||
function probePort(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`http://localhost:${port}`, (res) => {
|
||||
resolve(res.statusCode !== undefined)
|
||||
})
|
||||
req.on('error', () => resolve(false))
|
||||
req.setTimeout(500, () => { req.destroy(); resolve(false) })
|
||||
})
|
||||
}
|
||||
|
||||
async function findViteDevServer(): Promise<string | null> {
|
||||
for (const port of VITE_DEV_PORTS) {
|
||||
if (await probePort(port)) {
|
||||
return `http://localhost:${port}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getIconPath(ext: string = 'png'): string | undefined {
|
||||
const iconFile = `icon.${ext}`
|
||||
const iconPath = isDev
|
||||
? path.resolve(__dirname, '../../resources', iconFile)
|
||||
: path.join(process.resourcesPath, iconFile)
|
||||
if (fs.existsSync(iconPath)) return iconPath
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isMac = process.platform === 'darwin'
|
||||
const isWin = process.platform === 'win32'
|
||||
|
||||
// Persisted window bounds
|
||||
const windowStateFile = () => path.join(app.getPath('userData'), 'window-state.json')
|
||||
|
||||
function loadWindowState(): { width: number; height: number; x?: number; y?: number } {
|
||||
try {
|
||||
const raw = fs.readFileSync(windowStateFile(), 'utf-8')
|
||||
const s = JSON.parse(raw)
|
||||
if (typeof s.width === 'number' && typeof s.height === 'number') return s
|
||||
} catch {
|
||||
/* first run or unreadable */
|
||||
}
|
||||
return { width: 1280, height: 800 }
|
||||
}
|
||||
|
||||
function saveWindowState() {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
if (mainWindow.isMinimized() || mainWindow.isFullScreen()) return
|
||||
const b = mainWindow.getBounds()
|
||||
try {
|
||||
fs.writeFileSync(windowStateFile(), JSON.stringify(b))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const state = loadWindowState()
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
// macOS: native traffic lights inset into our custom titlebar.
|
||||
// Windows: fully frameless; we render custom window controls in-app.
|
||||
titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
|
||||
trafficLightPosition: isMac ? { x: 14, y: 16 } : undefined,
|
||||
frame: isMac ? undefined : false,
|
||||
backgroundColor: '#0e0e10',
|
||||
icon: getIconPath(),
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
})
|
||||
|
||||
const persist = () => saveWindowState()
|
||||
mainWindow.on('resize', persist)
|
||||
mainWindow.on('move', persist)
|
||||
mainWindow.on('maximize', emitMaximizeState)
|
||||
mainWindow.on('unmaximize', emitMaximizeState)
|
||||
|
||||
const rendererHtml = path.join(__dirname, '../renderer/index.html')
|
||||
|
||||
if (isDev) {
|
||||
findViteDevServer().then((devUrl) => {
|
||||
if (devUrl) {
|
||||
console.log(`[Electron] Loading Vite dev server: ${devUrl}`)
|
||||
mainWindow?.loadURL(devUrl)
|
||||
mainWindow?.webContents.openDevTools()
|
||||
} else if (fs.existsSync(rendererHtml)) {
|
||||
console.log('[Electron] Vite dev server not found, loading built files')
|
||||
mainWindow?.loadFile(rendererHtml)
|
||||
} else {
|
||||
console.error('[Electron] No renderer available. Run "npm run build:renderer" first.')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
mainWindow.loadFile(rendererHtml)
|
||||
}
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// Close-to-tray: hide the window instead of destroying it, so the tray's
|
||||
// "Show" can bring it back. Only a real Quit (menu/tray/Cmd+Q) destroys it.
|
||||
mainWindow.on('close', (e) => {
|
||||
if (!isQuitting) {
|
||||
e.preventDefault()
|
||||
mainWindow?.hide()
|
||||
}
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
function getBackendPath(): string {
|
||||
if (isDev) {
|
||||
return path.resolve(__dirname, '../../..')
|
||||
}
|
||||
return path.join(process.resourcesPath, 'backend')
|
||||
}
|
||||
|
||||
async function startBackend() {
|
||||
const backendPath = getBackendPath()
|
||||
pythonBackend = new PythonBackend(backendPath)
|
||||
|
||||
pythonBackend.on('ready', (port: number) => {
|
||||
mainWindow?.webContents.send('backend-status', { status: 'ready', port })
|
||||
})
|
||||
|
||||
pythonBackend.on('error', (error: string) => {
|
||||
mainWindow?.webContents.send('backend-status', { status: 'error', error })
|
||||
})
|
||||
|
||||
pythonBackend.on('log', (line: string) => {
|
||||
mainWindow?.webContents.send('backend-log', line)
|
||||
})
|
||||
|
||||
await pythonBackend.start()
|
||||
}
|
||||
|
||||
function setupIPC() {
|
||||
ipcMain.handle('get-backend-port', () => {
|
||||
return pythonBackend?.getPort() ?? null
|
||||
})
|
||||
|
||||
ipcMain.handle('get-backend-status', () => {
|
||||
return pythonBackend?.getStatus() ?? 'stopped'
|
||||
})
|
||||
|
||||
ipcMain.handle('restart-backend', async () => {
|
||||
await pythonBackend?.restart()
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('select-directory', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
})
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle('select-file', async (_event, filters?: Electron.FileFilter[]) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: filters || [{ name: 'All Files', extensions: ['*'] }],
|
||||
})
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
})
|
||||
|
||||
// Custom window controls (used by Windows frameless titlebar)
|
||||
ipcMain.handle('window-minimize', () => mainWindow?.minimize())
|
||||
ipcMain.handle('window-maximize', () => {
|
||||
if (!mainWindow) return false
|
||||
if (mainWindow.isMaximized()) mainWindow.unmaximize()
|
||||
else mainWindow.maximize()
|
||||
return mainWindow.isMaximized()
|
||||
})
|
||||
ipcMain.handle('window-close', () => mainWindow?.close())
|
||||
ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false)
|
||||
|
||||
// Auto-update controls (renderer-driven: check, then opt-in download/install)
|
||||
ipcMain.handle('update-check', () => checkForUpdates())
|
||||
ipcMain.handle('update-download', () => startDownload())
|
||||
ipcMain.handle('update-install', () => quitAndInstall())
|
||||
|
||||
// Synchronous OS locale lookup (e.g. "zh-CN", "en-US"). Used by the renderer
|
||||
// to pick a sensible default UI language on first run before any paint.
|
||||
ipcMain.on('get-system-locale', (event) => {
|
||||
event.returnValue = app.getLocale() || app.getSystemLocale?.() || ''
|
||||
})
|
||||
}
|
||||
|
||||
function emitMaximizeState() {
|
||||
const max = mainWindow?.isMaximized() ?? false
|
||||
mainWindow?.webContents.send('window-maximize-changed', max)
|
||||
}
|
||||
|
||||
// Single-instance lock: focus the existing window instead of opening a second app.
|
||||
const gotTheLock = app.requestSingleInstanceLock()
|
||||
if (!gotTheLock) {
|
||||
app.quit()
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Set Dock icon on macOS (PNG is most reliable for nativeImage)
|
||||
if (process.platform === 'darwin') {
|
||||
const pngPath = getIconPath('png')
|
||||
if (pngPath) {
|
||||
const icon = nativeImage.createFromPath(pngPath)
|
||||
if (!icon.isEmpty()) {
|
||||
app.dock.setIcon(icon)
|
||||
console.log('[Electron] Dock icon set:', pngPath)
|
||||
} else {
|
||||
console.warn('[Electron] Dock icon loaded but empty:', pngPath)
|
||||
}
|
||||
} else {
|
||||
console.warn('[Electron] Dock icon not found in resources/')
|
||||
}
|
||||
}
|
||||
|
||||
setupIPC()
|
||||
createWindow()
|
||||
buildAppMenu(() => mainWindow)
|
||||
// No menu-bar tray on macOS — the Dock + window controls are enough there.
|
||||
// Keep the tray on Windows/Linux where minimizing to a tray icon is expected.
|
||||
if (!isMac) {
|
||||
createTray({
|
||||
getWindow: () => mainWindow,
|
||||
iconPath: getIconPath('png'),
|
||||
onQuit: () => {
|
||||
isQuitting = true
|
||||
app.quit()
|
||||
},
|
||||
})
|
||||
}
|
||||
await startBackend()
|
||||
|
||||
// Wire auto-update and do a first silent check a few seconds after launch so
|
||||
// it doesn't compete with backend startup for resources.
|
||||
initUpdater(() => mainWindow)
|
||||
setTimeout(() => checkForUpdates(), 5000)
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
} else {
|
||||
mainWindow?.show()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true
|
||||
saveWindowState()
|
||||
destroyTray()
|
||||
pythonBackend?.stop()
|
||||
})
|
||||
112
desktop/src/main/menu.ts
Normal file
112
desktop/src/main/menu.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { app, Menu, BrowserWindow, shell } from 'electron'
|
||||
import type { MenuItemConstructorOptions } from 'electron'
|
||||
|
||||
const isMac = process.platform === 'darwin'
|
||||
const SKILL_HUB_URL = 'https://skills.cowagent.ai/'
|
||||
const DOCS_URL = 'https://docs.cowagent.ai'
|
||||
|
||||
// Send a menu-triggered action to the renderer (e.g. new chat, open settings).
|
||||
function emit(win: BrowserWindow | null, action: string) {
|
||||
win?.webContents.send('menu-action', action)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal, purpose-built application menu. We intentionally drop most of
|
||||
* Electron's verbose defaults and keep only items that are actually useful for
|
||||
* this app, plus the shortcuts users expect (New Chat, Settings, Reload, etc).
|
||||
*/
|
||||
export function buildAppMenu(getWindow: () => BrowserWindow | null) {
|
||||
const win = () => getWindow()
|
||||
|
||||
const appMenu: MenuItemConstructorOptions[] = isMac
|
||||
? [
|
||||
{
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: 'about' },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Settings…', accelerator: 'Cmd+,', click: () => emit(win(), 'open-settings') },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const fileMenu: MenuItemConstructorOptions = {
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{ label: 'New Chat', accelerator: 'CmdOrCtrl+N', click: () => emit(win(), 'new-chat') },
|
||||
...(!isMac
|
||||
? ([
|
||||
{ label: 'Settings', accelerator: 'Ctrl+,', click: () => emit(win(), 'open-settings') },
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' },
|
||||
] as MenuItemConstructorOptions[])
|
||||
: []),
|
||||
],
|
||||
}
|
||||
|
||||
const editMenu: MenuItemConstructorOptions = {
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{ role: 'undo' },
|
||||
{ role: 'redo' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
{ role: 'selectAll' },
|
||||
],
|
||||
}
|
||||
|
||||
const viewMenu: MenuItemConstructorOptions = {
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
],
|
||||
}
|
||||
|
||||
const windowMenu: MenuItemConstructorOptions = {
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
...(isMac ? ([{ role: 'zoom' }] as MenuItemConstructorOptions[]) : []),
|
||||
{ type: 'separator' },
|
||||
// Explicit Close so Cmd/Ctrl+W reliably triggers our close-to-tray hide.
|
||||
{ label: 'Close Window', accelerator: 'CmdOrCtrl+W', click: () => win()?.close() },
|
||||
],
|
||||
}
|
||||
|
||||
const helpMenu: MenuItemConstructorOptions = {
|
||||
label: 'Help',
|
||||
submenu: [
|
||||
{ label: 'View Logs', click: () => emit(win(), 'view-logs') },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Documentation', click: () => shell.openExternal(DOCS_URL) },
|
||||
{ label: 'Skill Hub', click: () => shell.openExternal(SKILL_HUB_URL) },
|
||||
],
|
||||
}
|
||||
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
...appMenu,
|
||||
fileMenu,
|
||||
editMenu,
|
||||
viewMenu,
|
||||
windowMenu,
|
||||
helpMenu,
|
||||
]
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
62
desktop/src/main/preload.ts
Normal file
62
desktop/src/main/preload.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getBackendPort: () => ipcRenderer.invoke('get-backend-port'),
|
||||
getBackendStatus: () => ipcRenderer.invoke('get-backend-status'),
|
||||
restartBackend: () => ipcRenderer.invoke('restart-backend'),
|
||||
selectDirectory: () => ipcRenderer.invoke('select-directory'),
|
||||
selectFile: (filters?: Electron.FileFilter[]) => ipcRenderer.invoke('select-file', filters),
|
||||
|
||||
// Each listener registrar returns an unsubscribe fn so renderers can clean
|
||||
// up on unmount / effect re-run and avoid accumulating duplicate handlers.
|
||||
onBackendStatus: (callback: (data: { status: string; port?: number; error?: string }) => void) => {
|
||||
const handler = (_event: unknown, data: { status: string; port?: number; error?: string }) => callback(data)
|
||||
ipcRenderer.on('backend-status', handler)
|
||||
return () => ipcRenderer.removeListener('backend-status', handler)
|
||||
},
|
||||
|
||||
onBackendLog: (callback: (line: string) => void) => {
|
||||
const handler = (_event: unknown, line: string) => callback(line)
|
||||
ipcRenderer.on('backend-log', handler)
|
||||
return () => ipcRenderer.removeListener('backend-log', handler)
|
||||
},
|
||||
|
||||
// Window controls (custom titlebar on Windows)
|
||||
windowMinimize: () => ipcRenderer.invoke('window-minimize'),
|
||||
windowMaximize: () => ipcRenderer.invoke('window-maximize'),
|
||||
windowClose: () => ipcRenderer.invoke('window-close'),
|
||||
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
|
||||
onMaximizeChange: (callback: (maximized: boolean) => void) => {
|
||||
const handler = (_event: unknown, max: boolean) => callback(max)
|
||||
ipcRenderer.on('window-maximize-changed', handler)
|
||||
return () => ipcRenderer.removeListener('window-maximize-changed', handler)
|
||||
},
|
||||
|
||||
// App menu / shortcut actions forwarded from the main process.
|
||||
onMenuAction: (callback: (action: string) => void) => {
|
||||
const handler = (_event: unknown, action: string) => callback(action)
|
||||
ipcRenderer.on('menu-action', handler)
|
||||
return () => ipcRenderer.removeListener('menu-action', handler)
|
||||
},
|
||||
|
||||
// Auto-update: trigger checks/download/install and subscribe to status.
|
||||
checkForUpdate: () => ipcRenderer.invoke('update-check'),
|
||||
downloadUpdate: () => ipcRenderer.invoke('update-download'),
|
||||
installUpdate: () => ipcRenderer.invoke('update-install'),
|
||||
onUpdateStatus: (callback: (status: unknown) => void) => {
|
||||
const handler = (_event: unknown, status: unknown) => callback(status)
|
||||
ipcRenderer.on('update-status', handler)
|
||||
return () => ipcRenderer.removeListener('update-status', handler)
|
||||
},
|
||||
|
||||
platform: process.platform,
|
||||
// OS UI language (e.g. "zh-CN"), read synchronously so the renderer can pick
|
||||
// a default language on first run. Falls back to '' if unavailable.
|
||||
systemLocale: (() => {
|
||||
try {
|
||||
return ipcRenderer.sendSync('get-system-locale') as string
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})(),
|
||||
})
|
||||
255
desktop/src/main/python-manager.ts
Normal file
255
desktop/src/main/python-manager.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { ChildProcess, spawn } from 'child_process'
|
||||
import { EventEmitter } from 'events'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import fs from 'fs'
|
||||
import http from 'http'
|
||||
|
||||
// Writable data dir for the packaged app (config.json, run.log, user data).
|
||||
// Lives in the user's home so it survives app updates and avoids writing into
|
||||
// the read-only app bundle. Source/dev runs keep using the repo CWD instead.
|
||||
const COW_DATA_DIR = path.join(os.homedir(), '.cow')
|
||||
|
||||
export class PythonBackend extends EventEmitter {
|
||||
private process: ChildProcess | null = null
|
||||
private backendPath: string
|
||||
private port: number = 9899
|
||||
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
|
||||
|
||||
constructor(backendPath: string) {
|
||||
super()
|
||||
this.backendPath = backendPath
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return this.port
|
||||
}
|
||||
|
||||
getStatus(): string {
|
||||
return this.status
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the packaged onedir backend executable shipped with the app.
|
||||
* Returns null when not present (e.g. during local development), so we can
|
||||
* fall back to running app.py with a system/venv Python.
|
||||
*/
|
||||
private findBundledBackend(): string | null {
|
||||
const exeName = process.platform === 'win32' ? 'cowagent-backend.exe' : 'cowagent-backend'
|
||||
const candidates = [
|
||||
path.join(this.backendPath, 'cowagent-backend', exeName),
|
||||
path.join(this.backendPath, exeName),
|
||||
]
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private findPython(): string {
|
||||
const venvPaths = [
|
||||
path.join(this.backendPath, '.venv', 'bin', 'python'),
|
||||
path.join(this.backendPath, '.venv', 'Scripts', 'python.exe'),
|
||||
path.join(this.backendPath, 'venv', 'bin', 'python'),
|
||||
path.join(this.backendPath, 'venv', 'Scripts', 'python.exe'),
|
||||
]
|
||||
|
||||
for (const p of venvPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
return process.platform === 'win32' ? 'python' : 'python3'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve config.json from the given data dir to read the web port. The
|
||||
* packaged build keeps config in COW_DATA_DIR (~/.cow); dev reads it from the
|
||||
* repo path. Returns the default port when no config (or web_port) is found.
|
||||
*/
|
||||
private readPort(dataDir: string): number {
|
||||
try {
|
||||
const configPath = path.join(dataDir, 'config.json')
|
||||
if (fs.existsSync(configPath)) {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
if (config.web_port) {
|
||||
return config.web_port
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 9899
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.status === 'ready' || this.status === 'starting') {
|
||||
return
|
||||
}
|
||||
|
||||
this.status = 'starting'
|
||||
|
||||
// Prefer the packaged self-contained backend (production); fall back to
|
||||
// running app.py with a Python interpreter (local development).
|
||||
const bundled = this.findBundledBackend()
|
||||
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
|
||||
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
|
||||
this.port = this.readPort(dataDir)
|
||||
|
||||
const alreadyRunning = await this.probeHealth()
|
||||
if (alreadyRunning) {
|
||||
this.status = 'ready'
|
||||
this.emit('log', `Backend already running on port ${this.port}`)
|
||||
this.emit('ready', this.port)
|
||||
return
|
||||
}
|
||||
|
||||
let command: string
|
||||
let args: string[]
|
||||
let cwd: string
|
||||
|
||||
if (bundled) {
|
||||
command = bundled
|
||||
args = []
|
||||
// The onedir bundle reads data files relative to the executable's dir.
|
||||
cwd = path.dirname(bundled)
|
||||
this.emit('log', `Starting bundled backend: ${bundled}`)
|
||||
} else {
|
||||
const pythonPath = this.findPython()
|
||||
const appPath = path.join(this.backendPath, 'app.py')
|
||||
if (!fs.existsSync(appPath)) {
|
||||
this.status = 'error'
|
||||
this.emit('error', `app.py not found at ${appPath}`)
|
||||
return
|
||||
}
|
||||
command = pythonPath
|
||||
args = [appPath]
|
||||
cwd = this.backendPath
|
||||
this.emit('log', `Starting Python backend: ${pythonPath} ${appPath}`)
|
||||
}
|
||||
|
||||
this.process = spawn(command, args, {
|
||||
cwd,
|
||||
// COW_DESKTOP enables the lighter desktop runtime (no plugins, no MCP).
|
||||
// COW_DATA_DIR (packaged only) redirects writable data to ~/.cow so the
|
||||
// app bundle stays read-only; dev runs omit it and keep using the repo.
|
||||
env: {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
COW_DESKTOP: '1',
|
||||
...(bundled ? { COW_DATA_DIR } : {}),
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
this.process.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
this.emit('log', line)
|
||||
}
|
||||
})
|
||||
|
||||
this.process.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
this.emit('log', line)
|
||||
}
|
||||
})
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
this.status = 'stopped'
|
||||
this.emit('log', `Python process exited with code ${code}`)
|
||||
if (code !== 0 && code !== null) {
|
||||
this.emit('error', `Python process exited with code ${code}`)
|
||||
}
|
||||
})
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
this.status = 'error'
|
||||
this.emit('error', `Failed to start Python: ${err.message}`)
|
||||
})
|
||||
|
||||
await this.waitForReady()
|
||||
}
|
||||
|
||||
private probeHealth(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
|
||||
resolve(res.statusCode === 200)
|
||||
})
|
||||
req.on('error', () => resolve(false))
|
||||
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
|
||||
})
|
||||
}
|
||||
|
||||
private waitForReady(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// Wall-clock deadline rather than an attempt counter: if the machine
|
||||
// sleeps/suspends, the 1s timers stretch out and a counter would give up
|
||||
// far too early. Time-based bounding tracks real elapsed time instead.
|
||||
const timeoutMs = 120_000
|
||||
const startedAt = Date.now()
|
||||
|
||||
const check = () => {
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
this.status = 'ready'
|
||||
this.emit('log', `Backend ready on port ${this.port}`)
|
||||
this.emit('ready', this.port)
|
||||
resolve()
|
||||
} else {
|
||||
retry()
|
||||
}
|
||||
})
|
||||
|
||||
req.on('error', () => retry())
|
||||
req.setTimeout(2000, () => {
|
||||
req.destroy()
|
||||
retry()
|
||||
})
|
||||
}
|
||||
|
||||
const retry = () => {
|
||||
if (this.status === 'stopped' || this.status === 'ready') {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
this.status = 'error'
|
||||
this.emit('error', `Backend failed to start within ${Math.round(timeoutMs / 1000)} seconds`)
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
setTimeout(check, 1000)
|
||||
}
|
||||
|
||||
setTimeout(check, 2000)
|
||||
})
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
const proc = this.process
|
||||
if (proc) {
|
||||
proc.kill('SIGTERM')
|
||||
// Keep a local ref so the SIGKILL fallback can still reach the process
|
||||
// even after we clear `this.process`; otherwise a stuck backend would
|
||||
// never be force-killed and leak as a zombie.
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
proc.kill('SIGKILL')
|
||||
}
|
||||
}, 5000)
|
||||
this.process = null
|
||||
}
|
||||
this.status = 'stopped'
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
this.stop()
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
await this.start()
|
||||
}
|
||||
}
|
||||
59
desktop/src/main/tray.ts
Normal file
59
desktop/src/main/tray.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { app, Tray, Menu, BrowserWindow, nativeImage } from 'electron'
|
||||
|
||||
let tray: Tray | null = null
|
||||
|
||||
interface TrayDeps {
|
||||
getWindow: () => BrowserWindow | null
|
||||
// Colored icon used on Windows/Linux trays.
|
||||
iconPath?: string
|
||||
// Called when the user picks "Quit" so the app can fully exit.
|
||||
onQuit: () => void
|
||||
}
|
||||
|
||||
// Build a system tray icon with a minimal menu (Windows/Linux only — macOS
|
||||
// uses the Dock instead). Lets users restore the window after closing it to the
|
||||
// background and start a new chat quickly.
|
||||
export function createTray({ getWindow, iconPath, onQuit }: TrayDeps): Tray | null {
|
||||
if (tray) return tray
|
||||
if (!iconPath) return null
|
||||
|
||||
let image = nativeImage.createFromPath(iconPath)
|
||||
if (image.isEmpty()) return null
|
||||
// Tray icons render small; resize to avoid an oversized image on some platforms.
|
||||
image = image.resize({ width: 18, height: 18 })
|
||||
|
||||
tray = new Tray(image)
|
||||
tray.setToolTip(app.name)
|
||||
|
||||
const showWindow = () => {
|
||||
const win = getWindow()
|
||||
if (!win) return
|
||||
if (win.isMinimized()) win.restore()
|
||||
win.show()
|
||||
win.focus()
|
||||
}
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{ label: 'Show CowAgent', click: showWindow },
|
||||
{
|
||||
label: 'New Chat',
|
||||
click: () => {
|
||||
showWindow()
|
||||
getWindow()?.webContents.send('menu-action', 'new-chat')
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit', click: onQuit },
|
||||
])
|
||||
tray.setContextMenu(contextMenu)
|
||||
|
||||
// Single click restores the window (common Windows/Linux behavior).
|
||||
tray.on('click', showWindow)
|
||||
|
||||
return tray
|
||||
}
|
||||
|
||||
export function destroyTray() {
|
||||
tray?.destroy()
|
||||
tray = null
|
||||
}
|
||||
73
desktop/src/main/updater.ts
Normal file
73
desktop/src/main/updater.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
// electron-updater is CommonJS: its members live on module.exports, with no
|
||||
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
||||
// import compiles to `electron_updater_1.autoUpdater` and resolves correctly,
|
||||
// whereas `import pkg from 'electron-updater'` yields undefined.
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
|
||||
// Status payloads pushed to the renderer over the 'update-status' channel.
|
||||
// The renderer drives the NavRail badge + update panel from these.
|
||||
export type UpdateStatus =
|
||||
| { state: 'checking' }
|
||||
| { state: 'available'; version: string; notes?: string }
|
||||
| { state: 'not-available' }
|
||||
| { state: 'downloading'; percent: number }
|
||||
| { state: 'downloaded'; version: string }
|
||||
| { state: 'error'; message: string }
|
||||
|
||||
let getWindow: () => BrowserWindow | null = () => null
|
||||
|
||||
function send(status: UpdateStatus) {
|
||||
getWindow()?.webContents.send('update-status', status)
|
||||
}
|
||||
|
||||
export function initUpdater(windowGetter: () => BrowserWindow | null): void {
|
||||
getWindow = windowGetter
|
||||
|
||||
// In dev (not packaged) there's no update feed; skip wiring entirely so
|
||||
// electron-updater doesn't throw on the missing app-update.yml.
|
||||
if (!app.isPackaged) {
|
||||
return
|
||||
}
|
||||
|
||||
// User-driven flow: we surface "available" and let the user opt in to the
|
||||
// download, rather than pulling bytes silently in the background.
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
|
||||
autoUpdater.on('checking-for-update', () => send({ state: 'checking' }))
|
||||
autoUpdater.on('update-available', (info) =>
|
||||
send({ state: 'available', version: info.version, notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined })
|
||||
)
|
||||
autoUpdater.on('update-not-available', () => send({ state: 'not-available' }))
|
||||
autoUpdater.on('download-progress', (p) =>
|
||||
send({ state: 'downloading', percent: Math.round(p.percent) })
|
||||
)
|
||||
autoUpdater.on('update-downloaded', (info) =>
|
||||
send({ state: 'downloaded', version: info.version })
|
||||
)
|
||||
autoUpdater.on('error', (err) =>
|
||||
send({ state: 'error', message: err == null ? 'unknown' : (err.message || String(err)) })
|
||||
)
|
||||
}
|
||||
|
||||
// Silent check shortly after launch; safe to call when not packaged (no-op).
|
||||
export function checkForUpdates(): void {
|
||||
if (!app.isPackaged) return
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
send({ state: 'error', message: err?.message || String(err) })
|
||||
})
|
||||
}
|
||||
|
||||
export function startDownload(): void {
|
||||
if (!app.isPackaged) return
|
||||
autoUpdater.downloadUpdate().catch((err) => {
|
||||
send({ state: 'error', message: err?.message || String(err) })
|
||||
})
|
||||
}
|
||||
|
||||
export function quitAndInstall(): void {
|
||||
if (!app.isPackaged) return
|
||||
// isSilent=false (show installer), isForceRunAfter=true (relaunch after).
|
||||
autoUpdater.quitAndInstall(false, true)
|
||||
}
|
||||
31
desktop/src/renderer/index.html
Normal file
31
desktop/src/renderer/index.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data: blob: http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:*; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*;" />
|
||||
<title>CowAgent</title>
|
||||
<!-- Local fonts & icons (offline, no CDN) served from publicDir -->
|
||||
<link rel="stylesheet" href="./vendor/fonts/inter/inter.css" />
|
||||
<link rel="stylesheet" href="./vendor/fontawesome/css/all.min.css" />
|
||||
<script>
|
||||
// Resolve theme before first paint to avoid flash-of-wrong-theme.
|
||||
(function () {
|
||||
try {
|
||||
var pref = localStorage.getItem('cow_theme') || 'dark';
|
||||
var resolved = pref === 'system'
|
||||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: pref;
|
||||
if (resolved === 'dark') document.documentElement.classList.add('dark');
|
||||
else document.documentElement.classList.remove('dark');
|
||||
} catch (e) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="h-screen overflow-hidden">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
140
desktop/src/renderer/src/App.tsx
Normal file
140
desktop/src/renderer/src/App.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { PanelLeftOpen } from 'lucide-react'
|
||||
import NavRail from './layout/NavRail'
|
||||
import SessionList from './layout/SessionList'
|
||||
import WindowControls from './layout/WindowControls'
|
||||
import StatusScreen from './components/StatusScreen'
|
||||
import { useBackend } from './hooks/useBackend'
|
||||
import { usePlatform } from './hooks/usePlatform'
|
||||
import { useUIStore } from './store/uiStore'
|
||||
import { useSessionStore } from './store/sessionStore'
|
||||
import { initUpdateListener } from './store/updateStore'
|
||||
import { useOnboardingStore } from './store/onboardingStore'
|
||||
import OnboardingWizard from './components/OnboardingWizard'
|
||||
import apiClient from './api/client'
|
||||
import { t } from './i18n'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import KnowledgePage from './pages/KnowledgePage'
|
||||
import SkillsPage from './pages/SkillsPage'
|
||||
import MemoryPage from './pages/MemoryPage'
|
||||
import ChannelsPage from './pages/ChannelsPage'
|
||||
import TasksPage from './pages/TasksPage'
|
||||
import LogsPage from './pages/LogsPage'
|
||||
|
||||
const App: React.FC = () => {
|
||||
const backend = useBackend()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { isWin } = usePlatform()
|
||||
const { sessionsCollapsed, toggleSessions } = useUIStore()
|
||||
const onboardingOpen = useOnboardingStore((s) => s.open)
|
||||
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
|
||||
const [, forceUpdate] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
||||
}, [backend.status, backend.baseUrl])
|
||||
|
||||
// First-run check: once the backend is ready, decide whether to show the
|
||||
// onboarding wizard. It's config-driven — shown whenever the chat model isn't
|
||||
// configured (and not dismissed earlier this session); no persisted flag.
|
||||
useEffect(() => {
|
||||
if (backend.status !== 'ready') return
|
||||
let cancelled = false
|
||||
apiClient
|
||||
.getModels()
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
const chat = data.capabilities?.chat
|
||||
// "Configured" needs a chat provider+model AND that provider's API key
|
||||
// set. A default config can ship a model name with no key, which
|
||||
// shouldn't count as ready — otherwise we'd skip onboarding for users
|
||||
// who still need to enter a key.
|
||||
const providerId = chat?.current_provider
|
||||
const provider = data.providers?.find((p) => p.id === providerId)
|
||||
const keyReady = !!provider && (provider.configured || (provider.is_custom && !!provider.custom_name))
|
||||
const configured = !!providerId && !!chat?.current_model && keyReady
|
||||
maybeOpenOnboarding(configured)
|
||||
})
|
||||
.catch(() => {
|
||||
// If models can't be loaded, fall back to the flag-only decision.
|
||||
if (!cancelled) maybeOpenOnboarding(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backend.status, maybeOpenOnboarding])
|
||||
|
||||
// Subscribe to auto-update status from the main process (no-op in dev).
|
||||
useEffect(() => initUpdateListener(), [])
|
||||
|
||||
// Handle app-menu / shortcut actions forwarded from the main process.
|
||||
useEffect(() => {
|
||||
const off = window.electronAPI?.onMenuAction?.((action) => {
|
||||
if (action === 'new-chat') {
|
||||
useSessionStore.getState().newSession()
|
||||
navigate('/')
|
||||
} else if (action === 'open-settings') {
|
||||
navigate('/settings')
|
||||
} else if (action === 'view-logs') {
|
||||
navigate('/logs')
|
||||
}
|
||||
})
|
||||
return off
|
||||
}, [navigate])
|
||||
|
||||
const handleLangChange = useCallback(() => forceUpdate((n) => n + 1), [])
|
||||
|
||||
if (backend.status !== 'ready') {
|
||||
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
|
||||
}
|
||||
|
||||
const isChat = location.pathname === '/'
|
||||
const showSessions = isChat && !sessionsCollapsed
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-base text-content">
|
||||
{onboardingOpen && <OnboardingWizard onDone={handleLangChange} />}
|
||||
<NavRail onLangChange={handleLangChange} />
|
||||
|
||||
{showSessions && <SessionList />}
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0 h-screen">
|
||||
{/* Top titlebar strip — drag region + Windows controls */}
|
||||
<header className="h-[44px] flex items-center gap-1 px-2 flex-shrink-0 titlebar-drag bg-base border-b border-default">
|
||||
{isChat && sessionsCollapsed && (
|
||||
<button
|
||||
onClick={toggleSessions}
|
||||
title={t('nav_expand')}
|
||||
className="titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<PanelLeftOpen size={16} />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 min-w-0" />
|
||||
{isWin && <WindowControls />}
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-base">
|
||||
<Routes>
|
||||
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/memory" element={<MemoryPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/skills" element={<SkillsPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/channels" element={<ChannelsPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/tasks" element={<TasksPage baseUrl={backend.baseUrl} />} />
|
||||
<Route path="/settings" element={<SettingsPage baseUrl={backend.baseUrl} onLangChange={handleLangChange} />} />
|
||||
{/* Legacy /models route now lives as a tab inside settings */}
|
||||
<Route path="/models" element={<SettingsPage baseUrl={backend.baseUrl} onLangChange={handleLangChange} />} />
|
||||
<Route path="/logs" element={<LogsPage baseUrl={backend.baseUrl} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
404
desktop/src/renderer/src/api/client.ts
Normal file
404
desktop/src/renderer/src/api/client.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import type {
|
||||
ConfigData,
|
||||
ChannelInfo,
|
||||
ChannelAction,
|
||||
SkillInfo,
|
||||
ToolInfo,
|
||||
MemoryItem,
|
||||
MemoryCategory,
|
||||
MemoryPage,
|
||||
SchedulerTask,
|
||||
Attachment,
|
||||
SessionsPage,
|
||||
HistoryPage,
|
||||
ModelsData,
|
||||
ModelsAction,
|
||||
KnowledgeList,
|
||||
KnowledgeGraph,
|
||||
KnowledgeAction,
|
||||
} from '../types'
|
||||
|
||||
interface ApiResult {
|
||||
status: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl = 'http://127.0.0.1:9899'
|
||||
|
||||
setBaseUrl(url: string) {
|
||||
this.baseUrl = url
|
||||
}
|
||||
|
||||
getBaseUrl() {
|
||||
return this.baseUrl
|
||||
}
|
||||
|
||||
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...options,
|
||||
// Send cookies for future web_password auth support
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Chat / messages
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async sendMessage(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
opts?: { stream?: boolean; attachments?: Attachment[]; isVoice?: boolean; lang?: string }
|
||||
): Promise<{ status: string; request_id: string; stream: boolean; inline_reply?: string }> {
|
||||
return this.request('/message', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
message,
|
||||
stream: opts?.stream ?? true,
|
||||
attachments: opts?.attachments,
|
||||
is_voice: opts?.isVoice ?? false,
|
||||
lang: opts?.lang,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async poll(sessionId: string): Promise<{
|
||||
status: string
|
||||
has_content: boolean
|
||||
content?: string
|
||||
request_id?: string
|
||||
timestamp?: number
|
||||
}> {
|
||||
return this.request('/poll', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
})
|
||||
}
|
||||
|
||||
async cancel(opts: { requestId?: string; sessionId?: string; lang?: string }): Promise<{ status: string; cancelled: number }> {
|
||||
return this.request('/cancel', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ request_id: opts.requestId, session_id: opts.sessionId, lang: opts.lang }),
|
||||
})
|
||||
}
|
||||
|
||||
createSSEStream(requestId: string): EventSource {
|
||||
return new EventSource(`${this.baseUrl}/stream?request_id=${requestId}`)
|
||||
}
|
||||
|
||||
async deleteMessage(opts: {
|
||||
sessionId: string
|
||||
userSeq: number
|
||||
deleteUser?: boolean
|
||||
cascade?: boolean
|
||||
}): Promise<{ status: string; deleted: number }> {
|
||||
return this.request('/api/messages/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
session_id: opts.sessionId,
|
||||
user_seq: opts.userSeq,
|
||||
delete_user: opts.deleteUser ?? true,
|
||||
cascade: opts.cascade ?? false,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Upload / files
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async uploadFile(file: File, sessionId?: string): Promise<{
|
||||
status: string
|
||||
file_path: string
|
||||
file_name: string
|
||||
file_type: string
|
||||
preview_url: string
|
||||
}> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
if (sessionId) formData.append('session_id', sessionId)
|
||||
const res = await fetch(`${this.baseUrl}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
getFileUrl(previewUrl: string): string {
|
||||
if (/^https?:\/\//.test(previewUrl)) return previewUrl
|
||||
return `${this.baseUrl}${previewUrl}`
|
||||
}
|
||||
|
||||
getServeFileUrl(absPath: string): string {
|
||||
return `${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Sessions
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async getSessions(page = 1, pageSize = 50): Promise<SessionsPage> {
|
||||
return this.request<{ status: string } & SessionsPage>(`/api/sessions?page=${page}&page_size=${pageSize}`)
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string): Promise<ApiResult> {
|
||||
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
async renameSession(sessionId: string, title: string): Promise<ApiResult> {
|
||||
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ title }),
|
||||
})
|
||||
}
|
||||
|
||||
async generateSessionTitle(sessionId: string, userMessage: string, assistantReply?: string): Promise<{ status: string; title: string }> {
|
||||
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/generate_title`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user_message: userMessage, assistant_reply: assistantReply }),
|
||||
})
|
||||
}
|
||||
|
||||
async clearContext(sessionId: string): Promise<{ status: string; context_start_seq: number }> {
|
||||
return this.request(`/api/sessions/${encodeURIComponent(sessionId)}/clear_context`, { method: 'POST' })
|
||||
}
|
||||
|
||||
async getHistory(sessionId: string, page = 1, pageSize = 20): Promise<HistoryPage> {
|
||||
return this.request<{ status: string } & HistoryPage>(
|
||||
`/api/history?session_id=${encodeURIComponent(sessionId)}&page=${page}&page_size=${pageSize}`
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async getConfig(): Promise<ConfigData> {
|
||||
return this.request<{ status: string } & ConfigData>('/config')
|
||||
}
|
||||
|
||||
async updateConfig(updates: Record<string, unknown>): Promise<{ status: string; applied: Record<string, unknown> }> {
|
||||
return this.request('/config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ updates }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Models console
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async getModels(): Promise<ModelsData> {
|
||||
return this.request<{ status: string } & ModelsData>('/api/models')
|
||||
}
|
||||
|
||||
async modelsAction(action: ModelsAction): Promise<Record<string, unknown> & { status: string }> {
|
||||
return this.request('/api/models', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(action),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Channels
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async getChannels(): Promise<ChannelInfo[]> {
|
||||
const data = await this.request<{ status: string; channels: ChannelInfo[] }>('/api/channels')
|
||||
return data.channels
|
||||
}
|
||||
|
||||
async channelAction(
|
||||
action: ChannelAction,
|
||||
channel: string,
|
||||
config?: Record<string, unknown>
|
||||
): Promise<Record<string, unknown> & { status: string }> {
|
||||
return this.request('/api/channels', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, channel, config }),
|
||||
})
|
||||
}
|
||||
|
||||
// Weixin QR login
|
||||
async getWeixinQr(): Promise<{ status: string; qrcode_url?: string; qr_image?: string; source?: string; message?: string }> {
|
||||
return this.request('/api/weixin/qrlogin')
|
||||
}
|
||||
|
||||
async weixinQrAction(action: 'poll' | 'refresh'): Promise<Record<string, unknown> & { status: string }> {
|
||||
return this.request('/api/weixin/qrlogin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
}
|
||||
|
||||
// Feishu one-click register
|
||||
async getFeishuRegister(): Promise<{ status: string; qrcode_url?: string; qr_image?: string; expire_in?: number; message?: string }> {
|
||||
return this.request('/api/feishu/register')
|
||||
}
|
||||
|
||||
async feishuRegisterPoll(): Promise<Record<string, unknown> & { status: string }> {
|
||||
return this.request('/api/feishu/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action: 'poll' }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Tools & skills
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async getTools(): Promise<ToolInfo[]> {
|
||||
const data = await this.request<{ status: string; tools: ToolInfo[] }>('/api/tools')
|
||||
return data.tools
|
||||
}
|
||||
|
||||
async getSkills(): Promise<SkillInfo[]> {
|
||||
const data = await this.request<{ status: string; skills: SkillInfo[] }>('/api/skills')
|
||||
return data.skills
|
||||
}
|
||||
|
||||
async toggleSkill(name: string, action: 'open' | 'close'): Promise<ApiResult> {
|
||||
return this.request('/api/skills', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, name }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Memory
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async getMemoryList(page = 1, pageSize = 20, category: MemoryCategory = 'memory'): Promise<MemoryPage> {
|
||||
return this.request<{ status: string } & MemoryPage>(
|
||||
`/api/memory?page=${page}&page_size=${pageSize}&category=${category}`
|
||||
)
|
||||
}
|
||||
|
||||
async getMemoryContent(filename: string, category: MemoryCategory = 'memory'): Promise<string> {
|
||||
const data = await this.request<{ status: string; content: string }>(
|
||||
`/api/memory/content?filename=${encodeURIComponent(filename)}&category=${category}`
|
||||
)
|
||||
return data.content
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Knowledge
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async getKnowledgeList(): Promise<KnowledgeList> {
|
||||
return this.request<{ status: string } & KnowledgeList>('/api/knowledge/list')
|
||||
}
|
||||
|
||||
async readKnowledge(path: string): Promise<{ status: string; content: string; path: string }> {
|
||||
return this.request(`/api/knowledge/read?path=${encodeURIComponent(path)}`)
|
||||
}
|
||||
|
||||
async getKnowledgeGraph(): Promise<KnowledgeGraph> {
|
||||
return this.request<KnowledgeGraph>('/api/knowledge/graph')
|
||||
}
|
||||
|
||||
async knowledgeAction(req: KnowledgeAction): Promise<Record<string, unknown> & { status: string }> {
|
||||
return this.request('/api/knowledge/action', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async getSchedulerTasks(): Promise<SchedulerTask[]> {
|
||||
const data = await this.request<{ status: string; tasks: SchedulerTask[] }>('/api/scheduler')
|
||||
return data.tasks
|
||||
}
|
||||
|
||||
async toggleTask(taskId: string, enabled: boolean): Promise<{ status: string; task: SchedulerTask }> {
|
||||
return this.request('/api/scheduler/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ task_id: taskId, enabled }),
|
||||
})
|
||||
}
|
||||
|
||||
async updateTask(taskId: string, updates: Partial<Pick<SchedulerTask, 'name' | 'enabled' | 'schedule' | 'action'>>): Promise<{ status: string; task: SchedulerTask }> {
|
||||
return this.request('/api/scheduler/update', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ task_id: taskId, ...updates }),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteTask(taskId: string): Promise<ApiResult> {
|
||||
return this.request('/api/scheduler/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ task_id: taskId }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Voice
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async voiceAsr(audio: File | Blob): Promise<{ status: string; text?: string; audio_url?: string; message?: string }> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', audio, 'recording.webm')
|
||||
const res = await fetch(`${this.baseUrl}/api/voice/asr`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async voiceTts(text: string, sessionId?: string): Promise<{ status: string; audio_url?: string; message?: string }> {
|
||||
return this.request('/api/voice/tts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text, session_id: sessionId }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Logs / version
|
||||
// ---------------------------------------------------------
|
||||
|
||||
createLogStream(): EventSource {
|
||||
return new EventSource(`${this.baseUrl}/api/logs`)
|
||||
}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
const data = await this.request<{ version: string }>('/api/version')
|
||||
return data.version
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Auth (web_password) — placeholder for future use
|
||||
// ---------------------------------------------------------
|
||||
|
||||
async authCheck(): Promise<{ status: string; auth_required: boolean; authenticated?: boolean }> {
|
||||
return this.request('/auth/check')
|
||||
}
|
||||
|
||||
async authLogin(password: string): Promise<ApiResult> {
|
||||
return this.request('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
}
|
||||
|
||||
async authLogout(): Promise<ApiResult> {
|
||||
return this.request('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient()
|
||||
export default apiClient
|
||||
326
desktop/src/renderer/src/components/ChatInput.tsx
Normal file
326
desktop/src/renderer/src/components/ChatInput.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import React, { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||
import { Plus, Paperclip, Send, Square, X, File as FileIcon, Loader2 } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import type { Attachment } from '../types'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
|
||||
|
||||
interface SlashCommand {
|
||||
cmd: string
|
||||
desc: string
|
||||
action: 'new' | 'clear'
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (message: string, attachments: Attachment[]) => void
|
||||
onNewChat: () => void
|
||||
onStop: () => void
|
||||
onClearContext: () => void
|
||||
isStreaming: boolean
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(
|
||||
{ onSend, onNewChat, onStop, onClearContext, isStreaming, sessionId },
|
||||
ref
|
||||
) {
|
||||
const [text, setText] = useState('')
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [slashOpen, setSlashOpen] = useState(false)
|
||||
const [slashIndex, setSlashIndex] = useState(0)
|
||||
const composingRef = useRef(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const slashCommands: SlashCommand[] = [
|
||||
{ cmd: '/new', desc: t('session_new'), action: 'new' },
|
||||
{ cmd: '/clear', desc: t('chat_clear_context'), action: 'clear' },
|
||||
]
|
||||
const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase()))
|
||||
|
||||
const resetHeight = () => {
|
||||
if (textareaRef.current) textareaRef.current.style.height = '42px'
|
||||
}
|
||||
|
||||
// Allow the parent to load a draft (e.g. when editing a past user message).
|
||||
useImperativeHandle(ref, () => (draft: string, atts: Attachment[]) => {
|
||||
setText(draft)
|
||||
setAttachments(atts)
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current
|
||||
if (el) {
|
||||
el.focus()
|
||||
el.style.height = '42px'
|
||||
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const runSlash = (c: SlashCommand) => {
|
||||
setText('')
|
||||
setSlashOpen(false)
|
||||
resetHeight()
|
||||
if (c.action === 'new') onNewChat()
|
||||
else if (c.action === 'clear') onClearContext()
|
||||
}
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed && attachments.length === 0) return
|
||||
if (isStreaming) return
|
||||
onSend(trimmed, attachments)
|
||||
setText('')
|
||||
setAttachments([])
|
||||
setSlashOpen(false)
|
||||
resetHeight()
|
||||
}, [text, attachments, isStreaming, onSend])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Slash menu navigation
|
||||
if (slashOpen && filtered.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSlashIndex((i) => (i + 1) % filtered.length)
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSlashIndex((i) => (i - 1 + filtered.length) % filtered.length)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
runSlash(filtered[slashIndex])
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setSlashOpen(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
// Don't submit while IME is composing (Chinese input)
|
||||
if (e.key === 'Enter' && !e.shiftKey && !composingRef.current) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const v = e.target.value
|
||||
setText(v)
|
||||
const el = e.target
|
||||
el.style.height = '42px'
|
||||
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
|
||||
// open slash menu when the input starts with "/" and has no space
|
||||
setSlashOpen(v.startsWith('/') && !v.includes(' '))
|
||||
setSlashIndex(0)
|
||||
}
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
if (!files.length) return
|
||||
setUploading(true)
|
||||
try {
|
||||
for (const file of files) {
|
||||
const result = await apiClient.uploadFile(file, sessionId)
|
||||
if (result.status === 'success') {
|
||||
setAttachments((prev) => [
|
||||
...prev,
|
||||
{
|
||||
file_path: result.file_path,
|
||||
file_name: result.file_name,
|
||||
file_type: result.file_type as Attachment['file_type'],
|
||||
preview_url: result.preview_url,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (files) await uploadFiles(Array.from(files))
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const files = Array.from(e.dataTransfer.files || [])
|
||||
if (files.length) uploadFiles(files)
|
||||
}
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
const files: File[] = []
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
const f = item.getAsFile()
|
||||
if (f) files.push(f)
|
||||
}
|
||||
}
|
||||
if (files.length) {
|
||||
e.preventDefault()
|
||||
uploadFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
// keep slash index in range
|
||||
useEffect(() => {
|
||||
if (slashIndex >= filtered.length) setSlashIndex(0)
|
||||
}, [filtered.length, slashIndex])
|
||||
|
||||
const canSend = !isStreaming && (!!text.trim() || attachments.length > 0)
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 border-t border-default bg-surface px-4 py-3">
|
||||
<div
|
||||
className={`max-w-3xl mx-auto relative rounded-2xl transition-all ${
|
||||
dragOver ? 'ring-2 ring-accent ring-offset-2 ring-offset-surface' : ''
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-2xl bg-accent-soft text-accent text-sm font-medium pointer-events-none">
|
||||
{t('input_placeholder')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slash command menu */}
|
||||
{slashOpen && filtered.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 rounded-xl border border-default bg-elevated shadow-lg overflow-hidden z-30">
|
||||
{filtered.map((c, i) => (
|
||||
<button
|
||||
key={c.cmd}
|
||||
onMouseEnter={() => setSlashIndex(i)}
|
||||
onClick={() => runSlash(c)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 text-left cursor-pointer transition-colors ${
|
||||
i === slashIndex ? 'bg-accent-soft' : 'hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-accent">{c.cmd}</span>
|
||||
<span className="text-xs text-content-tertiary">{c.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachment preview */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{attachments.map((att, i) => (
|
||||
<div key={i} className="relative">
|
||||
{att.file_type === 'image' && att.preview_url ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={apiClient.getFileUrl(att.preview_url)}
|
||||
alt={att.file_name}
|
||||
className="w-16 h-16 rounded-lg object-cover border border-default"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeAttachment(i)}
|
||||
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-danger text-white flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-inset border border-default rounded-lg text-xs text-content-secondary max-w-[180px] relative pr-7">
|
||||
<FileIcon size={12} />
|
||||
<span className="truncate">{att.file_name}</span>
|
||||
<button
|
||||
onClick={() => removeAttachment(i)}
|
||||
className="absolute -top-1 -right-1 w-[18px] h-[18px] rounded-full bg-danger text-white flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex items-center flex-shrink-0 gap-0.5 pb-0.5">
|
||||
<button
|
||||
onClick={onNewChat}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-btn text-content-secondary hover:text-accent hover:bg-accent-soft cursor-pointer transition-colors"
|
||||
title={t('session_new')}
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-btn text-content-secondary hover:text-accent hover:bg-accent-soft cursor-pointer transition-colors disabled:opacity-50"
|
||||
title={t('chat_attach')}
|
||||
>
|
||||
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Paperclip size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.json,.xml,.zip,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.md"
|
||||
/>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="chat-input"
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={() => (composingRef.current = true)}
|
||||
onCompositionEnd={() => (composingRef.current = false)}
|
||||
placeholder={t('input_placeholder')}
|
||||
rows={1}
|
||||
className="flex-1 min-w-0 px-4 py-[10px] rounded-xl border border-strong bg-inset text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent text-sm leading-relaxed transition-colors resize-none"
|
||||
/>
|
||||
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={onStop}
|
||||
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-btn bg-surface-2 text-content hover:bg-inset cursor-pointer transition-colors"
|
||||
title={t('msg_stop')}
|
||||
>
|
||||
<Square size={15} className="fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSend}
|
||||
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
||||
title={t('chat_send')}
|
||||
>
|
||||
<Send size={17} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default ChatInput
|
||||
407
desktop/src/renderer/src/components/KnowledgeGraph.tsx
Normal file
407
desktop/src/renderer/src/components/KnowledgeGraph.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { KnowledgeGraph as KnowledgeGraphData } from '../types'
|
||||
|
||||
interface SimNode {
|
||||
id: string
|
||||
label: string
|
||||
category: string
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
fx: number | null
|
||||
fy: number | null
|
||||
degree: number
|
||||
}
|
||||
|
||||
interface KnowledgeGraphProps {
|
||||
data: KnowledgeGraphData
|
||||
onSelect: (id: string, label: string) => void
|
||||
}
|
||||
|
||||
// d3.schemeTableau10 — keep the web client's palette for visual parity.
|
||||
const TABLEAU10 = [
|
||||
'#4e79a7',
|
||||
'#f28e2c',
|
||||
'#e15759',
|
||||
'#76b7b2',
|
||||
'#59a14f',
|
||||
'#edc949',
|
||||
'#af7aa1',
|
||||
'#ff9da7',
|
||||
'#9c755f',
|
||||
'#bab0ab',
|
||||
]
|
||||
|
||||
const nodeRadius = (degree: number) => Math.max(4, Math.min(12, 4 + degree * 1.4))
|
||||
|
||||
// A dependency-free force-directed graph with wheel zoom, canvas pan and node
|
||||
// drag. The physics loop writes positions DIRECTLY to the DOM (like d3) instead
|
||||
// of calling setState per frame, so React never re-renders during the
|
||||
// simulation — this is what keeps it from flickering.
|
||||
const KnowledgeGraph: React.FC<KnowledgeGraphProps> = ({ data, onSelect }) => {
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const sizeRef = useRef({ w: 800, h: 560 })
|
||||
const [hover, setHover] = useState<string | null>(null)
|
||||
// Bumping this re-arms the physics loop (used while dragging) without
|
||||
// rebuilding the model, preserving current node positions.
|
||||
const [warmTick, setWarmTick] = useState(0)
|
||||
|
||||
// View transform (pan + zoom).
|
||||
const viewRef = useRef({ k: 1, x: 0, y: 0 })
|
||||
|
||||
// Build the immutable model once per data change.
|
||||
const model = useMemo(() => {
|
||||
const degree = new Map<string, number>()
|
||||
data.links.forEach((l) => {
|
||||
degree.set(l.source, (degree.get(l.source) || 0) + 1)
|
||||
degree.set(l.target, (degree.get(l.target) || 0) + 1)
|
||||
})
|
||||
const categories = Array.from(new Set(data.nodes.map((n) => n.category || 'default')))
|
||||
const colorOf = (cat: string) => TABLEAU10[categories.indexOf(cat) % TABLEAU10.length]
|
||||
|
||||
const n = data.nodes.length || 1
|
||||
const { w, h } = sizeRef.current
|
||||
const cx = w / 2
|
||||
const cy = h / 2
|
||||
const nodes: SimNode[] = data.nodes.map((nd, i) => {
|
||||
const angle = (i / n) * Math.PI * 2
|
||||
const radius = Math.min(w, h) * 0.32
|
||||
return {
|
||||
id: nd.id,
|
||||
label: nd.label,
|
||||
category: nd.category || 'default',
|
||||
x: cx + Math.cos(angle) * radius,
|
||||
y: cy + Math.sin(angle) * radius,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
fx: null,
|
||||
fy: null,
|
||||
degree: degree.get(nd.id) || 0,
|
||||
}
|
||||
})
|
||||
const valid = new Set(nodes.map((x) => x.id))
|
||||
const byId = new Map(nodes.map((x) => [x.id, x]))
|
||||
const links = data.links
|
||||
.filter((l) => valid.has(l.source) && valid.has(l.target))
|
||||
.map((l, i) => ({ key: i, a: byId.get(l.source)!, b: byId.get(l.target)! }))
|
||||
const adjacency = new Map<string, Set<string>>()
|
||||
data.links.forEach((l) => {
|
||||
if (!valid.has(l.source) || !valid.has(l.target)) return
|
||||
if (!adjacency.has(l.source)) adjacency.set(l.source, new Set())
|
||||
if (!adjacency.has(l.target)) adjacency.set(l.target, new Set())
|
||||
adjacency.get(l.source)!.add(l.target)
|
||||
adjacency.get(l.target)!.add(l.source)
|
||||
})
|
||||
return { nodes, links, adjacency, categories, colorOf, byId }
|
||||
}, [data])
|
||||
|
||||
// DOM refs for imperative position updates.
|
||||
const rootRef = useRef<SVGGElement>(null)
|
||||
const lineEls = useRef(new Map<number, SVGLineElement>())
|
||||
const groupEls = useRef(new Map<string, SVGGElement>())
|
||||
|
||||
// Track container size in a ref; never triggers a re-render on its own.
|
||||
useEffect(() => {
|
||||
const el = wrapRef.current
|
||||
if (!el) return
|
||||
const apply = () => {
|
||||
sizeRef.current = { w: el.clientWidth || 800, h: el.clientHeight || 560 }
|
||||
}
|
||||
apply()
|
||||
const ro = new ResizeObserver(apply)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// Physics loop. Restarts only when the model (data) changes. Writes to DOM.
|
||||
// Uses d3-style alpha cooling so it always settles and stops the rAF.
|
||||
useEffect(() => {
|
||||
const { nodes, links } = model
|
||||
if (nodes.length === 0) return
|
||||
let raf = 0
|
||||
let alive = true
|
||||
// Global cooling factor; decays toward 0 and scales how far nodes move.
|
||||
let alpha = 1
|
||||
const alphaDecay = 0.018
|
||||
const alphaMin = 0.005
|
||||
|
||||
const paint = () => {
|
||||
links.forEach(({ key, a, b }) => {
|
||||
const el = lineEls.current.get(key)
|
||||
if (!el) return
|
||||
el.setAttribute('x1', String(a.x))
|
||||
el.setAttribute('y1', String(a.y))
|
||||
el.setAttribute('x2', String(b.x))
|
||||
el.setAttribute('y2', String(b.y))
|
||||
})
|
||||
nodes.forEach((node) => {
|
||||
const el = groupEls.current.get(node.id)
|
||||
if (el) el.setAttribute('transform', `translate(${node.x},${node.y})`)
|
||||
})
|
||||
}
|
||||
|
||||
const step = () => {
|
||||
if (!alive) return
|
||||
const { w, h } = sizeRef.current
|
||||
const cx = w / 2
|
||||
const cy = h / 2
|
||||
const repulsion = 9000
|
||||
const springLen = 80
|
||||
const spring = 0.04
|
||||
const centering = 0.012
|
||||
const dragging = nodes.some((node) => node.fx != null)
|
||||
|
||||
// Reset accumulated velocity each tick (alpha-scaled displacement) so the
|
||||
// system can't accumulate energy and oscillate.
|
||||
nodes.forEach((node) => {
|
||||
node.vx = 0
|
||||
node.vy = 0
|
||||
})
|
||||
|
||||
// Repulsion + collision: nodes push apart, never overlap their radii.
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const a = nodes[i]
|
||||
const ra = nodeRadius(a.degree)
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const b = nodes[j]
|
||||
let dx = a.x - b.x
|
||||
let dy = a.y - b.y
|
||||
let d2 = dx * dx + dy * dy
|
||||
if (d2 < 0.01) {
|
||||
dx = Math.random() - 0.5
|
||||
dy = Math.random() - 0.5
|
||||
d2 = 0.01
|
||||
}
|
||||
let d = Math.sqrt(d2)
|
||||
let f = repulsion / d2
|
||||
// Hard collision: strongly separate if closer than combined radii.
|
||||
const minDist = ra + nodeRadius(b.degree) + 14
|
||||
if (d < minDist) f += (minDist - d) * 0.6
|
||||
a.vx += (dx / d) * f
|
||||
a.vy += (dy / d) * f
|
||||
b.vx -= (dx / d) * f
|
||||
b.vy -= (dy / d) * f
|
||||
}
|
||||
}
|
||||
links.forEach(({ a, b }) => {
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const d = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
const f = (d - springLen) * spring
|
||||
a.vx += (dx / d) * f
|
||||
a.vy += (dy / d) * f
|
||||
b.vx -= (dx / d) * f
|
||||
b.vy -= (dy / d) * f
|
||||
})
|
||||
// Weak centering so the whole graph stays in view without collapsing.
|
||||
nodes.forEach((node) => {
|
||||
node.vx += (cx - node.x) * centering
|
||||
node.vy += (cy - node.y) * centering
|
||||
})
|
||||
|
||||
// Apply alpha-scaled displacement; pinned nodes stay put. Cap per-tick
|
||||
// movement so strong initial forces don't fling nodes off-screen.
|
||||
const maxStep = 30
|
||||
nodes.forEach((node) => {
|
||||
if (node.fx != null) {
|
||||
node.x = node.fx
|
||||
node.y = node.fy as number
|
||||
return
|
||||
}
|
||||
let dx = node.vx * alpha
|
||||
let dy = node.vy * alpha
|
||||
const m = Math.hypot(dx, dy)
|
||||
if (m > maxStep) {
|
||||
dx = (dx / m) * maxStep
|
||||
dy = (dy / m) * maxStep
|
||||
}
|
||||
node.x += dx
|
||||
node.y += dy
|
||||
})
|
||||
|
||||
paint()
|
||||
alpha += (0 - alpha) * alphaDecay
|
||||
// Keep running while cooling, or while a node is being dragged.
|
||||
if (alpha > alphaMin || dragging) {
|
||||
raf = requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
raf = requestAnimationFrame(step)
|
||||
return () => {
|
||||
alive = false
|
||||
cancelAnimationFrame(raf)
|
||||
}
|
||||
// warmTick re-arms the loop on demand (e.g. while dragging) without
|
||||
// rebuilding the model, so positions are preserved.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [model, warmTick])
|
||||
|
||||
// Apply the view transform imperatively (no re-render needed).
|
||||
const applyView = () => {
|
||||
const v = viewRef.current
|
||||
if (rootRef.current) rootRef.current.setAttribute('transform', `translate(${v.x},${v.y}) scale(${v.k})`)
|
||||
}
|
||||
useEffect(applyView)
|
||||
|
||||
// Convert a pointer event to graph (pre-transform) coordinates.
|
||||
const toGraph = (clientX: number, clientY: number) => {
|
||||
const rect = svgRef.current!.getBoundingClientRect()
|
||||
const v = viewRef.current
|
||||
return { x: (clientX - rect.left - v.x) / v.k, y: (clientY - rect.top - v.y) / v.k }
|
||||
}
|
||||
|
||||
// Wheel zoom centered on the cursor (matches d3.zoom scaleExtent [0.2, 5]).
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault()
|
||||
const v = viewRef.current
|
||||
const rect = svgRef.current!.getBoundingClientRect()
|
||||
const px = e.clientX - rect.left
|
||||
const py = e.clientY - rect.top
|
||||
const factor = Math.exp(-e.deltaY * 0.0015)
|
||||
const k = Math.min(5, Math.max(0.2, v.k * factor))
|
||||
viewRef.current = { k, x: px - ((px - v.x) / v.k) * k, y: py - ((py - v.y) / v.k) * k }
|
||||
applyView()
|
||||
}
|
||||
|
||||
// Drag: on a node moves the node, on background pans the canvas.
|
||||
const dragRef = useRef<
|
||||
| { mode: 'node'; node: SimNode; moved: boolean }
|
||||
| { mode: 'pan'; startX: number; startY: number; ox: number; oy: number }
|
||||
| null
|
||||
>(null)
|
||||
const kick = () => setWarmTick((v) => v + 1)
|
||||
|
||||
const onPointerDownNode = (e: React.PointerEvent, node: SimNode) => {
|
||||
e.stopPropagation()
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
const p = toGraph(e.clientX, e.clientY)
|
||||
node.fx = p.x
|
||||
node.fy = p.y
|
||||
dragRef.current = { mode: 'node', node, moved: false }
|
||||
kick()
|
||||
}
|
||||
|
||||
const onPointerDownBg = (e: React.PointerEvent) => {
|
||||
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
|
||||
const v = viewRef.current
|
||||
dragRef.current = { mode: 'pan', startX: e.clientX, startY: e.clientY, ox: v.x, oy: v.y }
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag) return
|
||||
if (drag.mode === 'node') {
|
||||
const p = toGraph(e.clientX, e.clientY)
|
||||
drag.node.fx = p.x
|
||||
drag.node.fy = p.y
|
||||
drag.moved = true
|
||||
// Keep the loop warm for live dragging.
|
||||
const el = groupEls.current.get(drag.node.id)
|
||||
if (el) el.setAttribute('transform', `translate(${p.x},${p.y})`)
|
||||
} else {
|
||||
viewRef.current = { k: viewRef.current.k, x: drag.ox + (e.clientX - drag.startX), y: drag.oy + (e.clientY - drag.startY) }
|
||||
applyView()
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent, node?: SimNode) => {
|
||||
const drag = dragRef.current
|
||||
if (drag?.mode === 'node') {
|
||||
drag.node.fx = null
|
||||
drag.node.fy = null
|
||||
if (node && !drag.moved) onSelect(node.id, node.label)
|
||||
kick()
|
||||
}
|
||||
dragRef.current = null
|
||||
try {
|
||||
;(e.target as Element).releasePointerCapture(e.pointerId)
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
const { nodes, links, adjacency, categories, colorOf } = model
|
||||
const { w, h } = sizeRef.current
|
||||
const isDimmed = (id: string) => hover != null && hover !== id && !adjacency.get(hover)?.has(id)
|
||||
const isLinkActive = (aId: string, bId: string) => hover === aId || hover === bId
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="w-full h-full relative overflow-hidden">
|
||||
<svg
|
||||
ref={svgRef}
|
||||
width={w}
|
||||
height={h}
|
||||
className="select-none block cursor-grab active:cursor-grabbing"
|
||||
onWheel={onWheel}
|
||||
onPointerDown={onPointerDownBg}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={(e) => onPointerUp(e)}
|
||||
>
|
||||
<g ref={rootRef}>
|
||||
{links.map(({ key, a, b }) => {
|
||||
const active = isLinkActive(a.id, b.id)
|
||||
return (
|
||||
<line
|
||||
key={key}
|
||||
ref={(el) => {
|
||||
if (el) lineEls.current.set(key, el)
|
||||
else lineEls.current.delete(key)
|
||||
}}
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
stroke="#94a3b8"
|
||||
strokeOpacity={hover ? (active ? 0.8 : 0.1) : 0.3}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{nodes.map((n) => {
|
||||
const r = nodeRadius(n.degree)
|
||||
const dim = isDimmed(n.id)
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
ref={(el) => {
|
||||
if (el) groupEls.current.set(n.id, el)
|
||||
else groupEls.current.delete(n.id)
|
||||
}}
|
||||
transform={`translate(${n.x},${n.y})`}
|
||||
className="cursor-pointer"
|
||||
opacity={dim ? 0.2 : 1}
|
||||
onMouseEnter={() => setHover(n.id)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onPointerDown={(e) => onPointerDownNode(e, n)}
|
||||
onPointerUp={(e) => onPointerUp(e, n)}
|
||||
>
|
||||
<circle r={r} fill={colorOf(n.category)} stroke="#fff" strokeWidth={1.5} />
|
||||
{(hover === n.id || n.degree >= 3) && (
|
||||
<text x={r + 4} y={3} className="fill-content-secondary" fontSize={9} style={{ pointerEvents: 'none' }}>
|
||||
{n.label.length > 15 ? n.label.slice(0, 14) + '…' : n.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Category legend, mirrors the web client. */}
|
||||
{categories.length > 0 && (
|
||||
<div className="absolute bottom-3 left-3 flex flex-wrap gap-x-3 gap-y-1 max-w-[60%] rounded-lg bg-surface px-3 py-2 border border-subtle shadow-sm">
|
||||
{categories.map((cat) => (
|
||||
<span key={cat} className="inline-flex items-center gap-1.5 text-[11px] text-content-secondary">
|
||||
<span className="w-2.5 h-2.5 rounded-full" style={{ background: colorOf(cat) }} />
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default KnowledgeGraph
|
||||
109
desktop/src/renderer/src/components/Markdown.tsx
Normal file
109
desktop/src/renderer/src/components/Markdown.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { useMemo, useRef, useCallback } from 'react'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import hljs from 'highlight.js'
|
||||
import { t } from '../i18n'
|
||||
|
||||
/**
|
||||
* Markdown renderer aligned 1:1 with the web console (markdown-it + highlight.js
|
||||
* + GitHub themes). Using the same engine guarantees identical line-break,
|
||||
* linkify and code-highlight behavior across web and desktop.
|
||||
*/
|
||||
|
||||
const md: MarkdownIt = new MarkdownIt({
|
||||
html: false,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
highlight(str, lang) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return hljs.highlight(str, { language: lang }).value
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
try {
|
||||
return hljs.highlightAuto(str).value
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Open links in a new tab safely.
|
||||
const defaultLinkOpen =
|
||||
md.renderer.rules.link_open ||
|
||||
function (tokens, idx, options, _env, self) {
|
||||
return self.renderToken(tokens, idx, options)
|
||||
}
|
||||
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
||||
tokens[idx].attrPush(['target', '_blank'])
|
||||
tokens[idx].attrPush(['rel', 'noopener noreferrer'])
|
||||
return defaultLinkOpen(tokens, idx, options, env, self)
|
||||
}
|
||||
|
||||
// Wrap fenced code blocks so we can render a header (lang + copy button).
|
||||
const defaultFence =
|
||||
md.renderer.rules.fence ||
|
||||
function (tokens, idx, options, _env, self) {
|
||||
return self.renderToken(tokens, idx, options)
|
||||
}
|
||||
md.renderer.rules.fence = function (tokens, idx, options, env, self) {
|
||||
const token = tokens[idx]
|
||||
const info = token.info ? token.info.trim().split(/\s+/)[0] : ''
|
||||
// Ensure the `hljs` class is present so the GitHub theme background/base
|
||||
// color applies (markdown-it only adds language-* by default).
|
||||
let rendered = defaultFence(tokens, idx, options, env, self)
|
||||
if (rendered.includes('<code class="')) {
|
||||
rendered = rendered.replace('<code class="', '<code class="hljs ')
|
||||
} else {
|
||||
rendered = rendered.replace('<code>', '<code class="hljs">')
|
||||
}
|
||||
return (
|
||||
`<div class="code-block-wrapper">` +
|
||||
`<div class="code-block-header">` +
|
||||
`<span class="code-block-lang">${info || 'text'}</span>` +
|
||||
`<button type="button" class="code-copy-btn" data-code-id="cb-${idx}" aria-label="Copy code">${t('msg_copy')}</button>` +
|
||||
`</div>` +
|
||||
rendered +
|
||||
`</div>`
|
||||
)
|
||||
}
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string
|
||||
}
|
||||
|
||||
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
|
||||
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)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="msg-content text-sm text-content leading-relaxed break-words"
|
||||
onClick={handleClick}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default Markdown
|
||||
156
desktop/src/renderer/src/components/MessageBubble.tsx
Normal file
156
desktop/src/renderer/src/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Copy, Check, RefreshCw, Pencil, Trash2, File as FileIcon, Sprout } from 'lucide-react'
|
||||
import type { ChatMessage } from '../types'
|
||||
import { t } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
import Markdown from './Markdown'
|
||||
import MessageSteps, { ThinkingStep } from './MessageSteps'
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage
|
||||
onRegenerate?: (id: string) => void
|
||||
onEdit?: (id: string) => void
|
||||
onDelete?: (msg: ChatMessage) => void
|
||||
}
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
if (!ts) return ''
|
||||
const d = new Date(ts * 1000)
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const HoverAction: React.FC<{ onClick: () => void; title: string; danger?: boolean; children: React.ReactNode }> = ({
|
||||
onClick,
|
||||
title,
|
||||
danger,
|
||||
children,
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`inline-flex items-center justify-center w-7 h-7 rounded-md cursor-pointer transition-colors text-content-tertiary ${
|
||||
danger ? 'hover:text-danger hover:bg-danger-soft' : 'hover:text-content hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete }) => {
|
||||
const isUser = message.role === 'user'
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(message.content)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
}
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="group flex flex-col items-end px-4 sm:px-6 py-2">
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-1.5 justify-end max-w-[75%]">
|
||||
{message.attachments.map((att, i) =>
|
||||
att.file_type === 'image' && att.preview_url ? (
|
||||
<img
|
||||
key={i}
|
||||
src={apiClient.getFileUrl(att.preview_url)}
|
||||
alt={att.file_name}
|
||||
className="max-w-[180px] max-h-[150px] rounded-xl object-cover border border-default"
|
||||
/>
|
||||
) : (
|
||||
<div key={i} className="flex items-center gap-1.5 px-3 py-2 bg-surface-2 rounded-xl text-xs text-content-secondary">
|
||||
<FileIcon size={13} />
|
||||
{att.file_name}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-[75%] rounded-2xl rounded-br-md px-4 py-2.5 bg-[var(--user-bubble-bg)] text-content">
|
||||
<div className="text-sm whitespace-pre-wrap break-words">{message.content}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
|
||||
{onEdit && message.userSeq != null && (
|
||||
<HoverAction onClick={() => onEdit(message.id)} title={t('msg_edit')}>
|
||||
<Pencil size={13} />
|
||||
</HoverAction>
|
||||
)}
|
||||
{onDelete && message.userSeq != null && (
|
||||
<HoverAction onClick={() => onDelete(message)} title={t('msg_delete')} danger>
|
||||
<Trash2 size={13} />
|
||||
</HoverAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Assistant
|
||||
const showCursor = message.isStreaming && !message.content && (!message.steps || message.steps.length === 0)
|
||||
|
||||
const hasSteps = !!(message.steps && message.steps.length > 0)
|
||||
const hasLiveReasoning = !!(message.reasoning && message.isStreaming)
|
||||
|
||||
return (
|
||||
<div className="group flex gap-3 px-4 sm:px-6 py-2">
|
||||
<img src="./logo.jpg" alt="CowAgent" className="w-7 h-7 rounded-lg flex-shrink-0 mt-1" />
|
||||
<div className="flex-1 min-w-0 max-w-[calc(100%-2.5rem)]">
|
||||
<div className="inline-block w-full rounded-2xl border border-default bg-surface px-4 py-3">
|
||||
{message.kind === 'evolution' && (
|
||||
<div className="inline-flex items-center gap-1 mb-1.5 text-[11px] text-content-tertiary">
|
||||
<Sprout size={11} />
|
||||
{t('msg_self_learned')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steps area (thinking / tools / intermediate content), web-aligned:
|
||||
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!} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Final answer */}
|
||||
{message.content && <Markdown content={message.content} />}
|
||||
|
||||
{showCursor && (
|
||||
<div className="flex items-center gap-1 py-0.5">
|
||||
<span className="typing-dot" />
|
||||
<span className="typing-dot" />
|
||||
<span className="typing-dot" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.isStreaming && message.content && (
|
||||
<span className="inline-block w-[6px] h-[14px] bg-accent ml-0.5 align-middle animate-blink" />
|
||||
)}
|
||||
|
||||
{message.isCancelled && <div className="text-xs text-warning mt-1">{t('msg_cancelled')}</div>}
|
||||
{message.error && <div className="text-xs text-danger mt-1">{message.error}</div>}
|
||||
</div>
|
||||
|
||||
{/* Hover actions (only when finished) */}
|
||||
{!message.isStreaming && (message.content || message.error) && (
|
||||
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
|
||||
<HoverAction onClick={copy} title={t('msg_copy')}>
|
||||
{copied ? <Check size={13} /> : <Copy size={13} />}
|
||||
</HoverAction>
|
||||
{onRegenerate && (
|
||||
<HoverAction onClick={() => onRegenerate(message.id)} title={t('msg_regenerate')}>
|
||||
<RefreshCw size={13} />
|
||||
</HoverAction>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageBubble
|
||||
110
desktop/src/renderer/src/components/MessageSteps.tsx
Normal file
110
desktop/src/renderer/src/components/MessageSteps.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronRight, Loader2, Check, X, Brain, Wrench } from 'lucide-react'
|
||||
import type { MessageStep } from '../types'
|
||||
import Markdown from './Markdown'
|
||||
|
||||
/**
|
||||
* Assistant reasoning / tool steps, styled to match the web console: small,
|
||||
* muted, collapsible rows with an indented detail panel.
|
||||
*/
|
||||
|
||||
const ThinkingStep: React.FC<{ content: string; streaming?: boolean }> = ({ content, streaming }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
return (
|
||||
<div className="text-xs text-content-tertiary mb-1 last:mb-0">
|
||||
<div
|
||||
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>
|
||||
<ChevronRight size={11} className={`transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||
</div>
|
||||
{expanded && (
|
||||
<pre className="mt-1.5 ml-4 p-2 rounded-md bg-inset border border-subtle whitespace-pre-wrap leading-relaxed max-h-[260px] overflow-y-auto font-sans text-content-tertiary">
|
||||
{content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ToolStep: React.FC<{ step: MessageStep }> = ({ step }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const running = step.status === 'running'
|
||||
const isError = step.is_error || (!!step.status && step.status !== 'success' && !running)
|
||||
|
||||
const icon = running ? (
|
||||
<Loader2 size={12} className="text-accent animate-spin" />
|
||||
) : isError ? (
|
||||
<X size={12} className="text-danger" />
|
||||
) : (
|
||||
<Check size={12} className="text-accent" />
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="text-xs text-content-tertiary mb-1 last:mb-0">
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer hover:text-content-secondary select-none transition-colors"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<Wrench size={11} className="flex-shrink-0 opacity-70" />
|
||||
<span className={`font-medium ${isError ? 'text-danger' : ''}`}>{step.name}</span>
|
||||
{step.execution_time !== undefined && (
|
||||
<span className="opacity-60">{step.execution_time}s</span>
|
||||
)}
|
||||
<ChevronRight size={11} className={`ml-auto transition-transform opacity-50 ${expanded ? 'rotate-90' : ''}`} />
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="mt-1.5 ml-4 p-2 rounded-md bg-inset border border-subtle space-y-2">
|
||||
{step.arguments && Object.keys(step.arguments).length > 0 && (
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60 mb-1">Input</div>
|
||||
<pre className="font-mono text-[11px] whitespace-pre-wrap break-all max-h-[200px] overflow-y-auto leading-relaxed">
|
||||
{JSON.stringify(step.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{step.result && (
|
||||
<div>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide opacity-60 mb-1">
|
||||
{isError ? 'Error' : 'Output'}
|
||||
</div>
|
||||
<pre
|
||||
className={`font-mono text-[11px] whitespace-pre-wrap break-all max-h-[240px] overflow-y-auto leading-relaxed ${
|
||||
isError ? 'text-danger' : ''
|
||||
}`}
|
||||
>
|
||||
{step.result.length > 4000 ? step.result.slice(0, 4000) + '\n… (truncated)' : step.result}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Renders an ordered list of assistant steps (thinking / content / tool). */
|
||||
const MessageSteps: React.FC<{ steps: MessageStep[] }> = ({ steps }) => {
|
||||
if (!steps.length) return null
|
||||
return (
|
||||
<div>
|
||||
{steps.map((step, i) => {
|
||||
if (step.type === 'thinking') return <ThinkingStep key={i} content={step.content || ''} />
|
||||
if (step.type === 'tool') return <ToolStep key={i} step={step} />
|
||||
if (step.type === 'content' && step.content)
|
||||
return (
|
||||
<div key={i} className="mb-2 pb-2 border-b border-dashed border-default last:border-0 last:mb-0 last:pb-0">
|
||||
<Markdown content={step.content} />
|
||||
</div>
|
||||
)
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ThinkingStep, ToolStep }
|
||||
export default MessageSteps
|
||||
293
desktop/src/renderer/src/components/OnboardingWizard.tsx
Normal file
293
desktop/src/renderer/src/components/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Sparkles, KeyRound, Loader2, ArrowRight, ArrowLeft, ExternalLink } from 'lucide-react'
|
||||
import { t, getLang, setLang, type Lang } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
import type { ModelsData } from '../types'
|
||||
import { Field, Dropdown, TextInput, type DropdownOption } from '../pages/settings/primitives'
|
||||
import { resolveModels, providerLabel } from '../pages/settings/modelsHelpers'
|
||||
import { useOnboardingStore } from '../store/onboardingStore'
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
// Called after the wizard finishes so the host can refresh language/state.
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
const TOTAL_STEPS = 2
|
||||
|
||||
// Optional "where to get an API key" console link, per provider.
|
||||
const PROVIDER_KEY_CONSOLE: Record<string, string> = {
|
||||
linkai: 'https://link-ai.tech/console/interface',
|
||||
}
|
||||
|
||||
// First-run guided setup: language -> chat model (provider + key + model).
|
||||
// After saving the model the user goes straight into the chat (no extra
|
||||
// confirmation step). Rendered as a full-screen overlay above the main UI;
|
||||
// reuses the same models API and primitives as the settings page.
|
||||
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onDone }) => {
|
||||
const finish = useOnboardingStore((s) => s.finish)
|
||||
|
||||
const [step, setStep] = useState(1)
|
||||
const [lang, setLangState] = useState<Lang>(getLang())
|
||||
const [models, setModels] = useState<ModelsData | null>(null)
|
||||
|
||||
// Step 2 form state.
|
||||
const [provider, setProvider] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [apiBase, setApiBase] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Load the models console data once for the provider/model dropdowns.
|
||||
useEffect(() => {
|
||||
apiClient
|
||||
.getModels()
|
||||
.then(setModels)
|
||||
.catch(() => setError(t('onboarding_save_failed')))
|
||||
}, [])
|
||||
|
||||
// Persist the auto-detected default language on first show so the pre-selected
|
||||
// option (driven by OS locale) also reaches the backend, even if the user
|
||||
// doesn't tap the language buttons.
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem('cow_lang')) switchLang(lang)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const providerOptions: DropdownOption[] = useMemo(() => {
|
||||
const chat = models?.capabilities?.chat
|
||||
const ids = chat?.providers || []
|
||||
return ids.map((id) => ({ value: id, label: providerLabel(models, id) }))
|
||||
}, [models])
|
||||
|
||||
const modelOptions: DropdownOption[] = useMemo(() => {
|
||||
return resolveModels(models, provider, models?.capabilities?.chat?.provider_models).map((o) => ({
|
||||
value: o.value,
|
||||
label: o.value,
|
||||
hint: o.hint,
|
||||
}))
|
||||
}, [models, provider])
|
||||
|
||||
// The currently selected provider's api_base placeholder/default, if any.
|
||||
const providerMeta = models?.providers?.find((p) => p.id === provider)
|
||||
const apiBasePlaceholder = providerMeta?.api_base_placeholder || providerMeta?.api_base_default
|
||||
|
||||
const handleProvider = (id: string) => {
|
||||
setProvider(id)
|
||||
setApiBase('')
|
||||
const first = resolveModels(models, id, models?.capabilities?.chat?.provider_models)[0]
|
||||
setModel(first?.value || '')
|
||||
}
|
||||
|
||||
const switchLang = (next: Lang) => {
|
||||
setLang(next)
|
||||
setLangState(next)
|
||||
// Mirror the choice to the backend so the agent/logs use the same language
|
||||
// (matches BasicSettings). Non-blocking: the UI already switched locally.
|
||||
apiClient.updateConfig({ cow_lang: next }).catch(() => {})
|
||||
}
|
||||
|
||||
// Step 1 (language) can always advance; step 2 needs a provider, key, model.
|
||||
const canNext = step === 1 || (!!provider && !!apiKey.trim() && !!model)
|
||||
|
||||
const goNext = async () => {
|
||||
setError('')
|
||||
// Step 1 (language) just advances to the model step.
|
||||
if (step === 1) {
|
||||
setStep(2)
|
||||
return
|
||||
}
|
||||
// Step 2 is the last step: persist the provider credentials, point the chat
|
||||
// capability at it, then finish straight into the chat (no extra step).
|
||||
setSaving(true)
|
||||
try {
|
||||
await apiClient.modelsAction({
|
||||
action: 'set_provider',
|
||||
provider_id: provider,
|
||||
api_key: apiKey.trim(),
|
||||
...(apiBase.trim() ? { api_base: apiBase.trim() } : {}),
|
||||
})
|
||||
await apiClient.modelsAction({
|
||||
action: 'set_capability',
|
||||
capability: 'chat',
|
||||
provider_id: provider,
|
||||
model,
|
||||
})
|
||||
} catch {
|
||||
setSaving(false)
|
||||
setError(t('onboarding_save_failed'))
|
||||
return
|
||||
}
|
||||
setSaving(false)
|
||||
complete()
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
setError('')
|
||||
setStep((s) => Math.max(1, s - 1))
|
||||
}
|
||||
|
||||
const complete = () => {
|
||||
finish()
|
||||
onDone()
|
||||
}
|
||||
|
||||
const stepLabel = t('onboarding_step').replace('{n}', String(step)).replace('{total}', String(TOTAL_STEPS))
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-base">
|
||||
<div className="w-full max-w-lg px-8">
|
||||
{/* Progress dots */}
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{Array.from({ length: TOTAL_STEPS }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
i + 1 === step ? 'w-8 bg-accent' : i + 1 < step ? 'w-4 bg-accent/50' : 'w-4 bg-surface-2'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="w-16 h-16 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mx-auto">
|
||||
<Sparkles size={30} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-content">{t('onboarding_welcome_title')}</h1>
|
||||
<p className="text-sm text-content-secondary">{t('onboarding_welcome_desc')}</p>
|
||||
</div>
|
||||
<div className="max-w-xs mx-auto text-left">
|
||||
<Field label={t('onboarding_lang_label')}>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(['zh', 'en'] as Lang[]).map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => switchLang(l)}
|
||||
className={`px-4 py-2.5 rounded-btn border text-sm font-medium cursor-pointer transition-colors ${
|
||||
lang === l
|
||||
? 'border-accent bg-accent-soft text-accent'
|
||||
: 'border-strong text-content-secondary hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{l === 'zh' ? '简体中文' : 'English'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="w-16 h-16 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mx-auto">
|
||||
<KeyRound size={28} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-content">{t('onboarding_model_title')}</h1>
|
||||
<p className="text-sm text-content-secondary">{t('onboarding_model_desc')}</p>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Field label={t('onboarding_provider')}>
|
||||
<Dropdown
|
||||
value={provider}
|
||||
options={providerOptions}
|
||||
placeholder={t('onboarding_select_provider')}
|
||||
onChange={handleProvider}
|
||||
/>
|
||||
</Field>
|
||||
{provider && (
|
||||
<>
|
||||
<Field label={t('onboarding_apikey')}>
|
||||
<TextInput
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={t('onboarding_apikey_placeholder')}
|
||||
className="font-mono"
|
||||
/>
|
||||
{PROVIDER_KEY_CONSOLE[provider] && (
|
||||
<a
|
||||
href={PROVIDER_KEY_CONSOLE[provider]}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-1.5 inline-flex items-center gap-1 text-xs text-accent hover:underline"
|
||||
>
|
||||
{t('onboarding_key_guide')}
|
||||
<ExternalLink size={11} />
|
||||
</a>
|
||||
)}
|
||||
</Field>
|
||||
{providerMeta?.api_base_field && (
|
||||
<Field label={t('onboarding_apibase')}>
|
||||
<TextInput
|
||||
value={apiBase}
|
||||
onChange={(e) => setApiBase(e.target.value)}
|
||||
placeholder={apiBasePlaceholder || ''}
|
||||
className="font-mono"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<Field label={t('onboarding_model')}>
|
||||
<Dropdown
|
||||
value={model}
|
||||
options={modelOptions}
|
||||
placeholder={t('onboarding_select_model')}
|
||||
onChange={setModel}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer controls */}
|
||||
<div className="mt-10 flex items-center justify-between">
|
||||
<div className="text-xs text-content-tertiary">{stepLabel}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Step 2: back to language. */}
|
||||
{step === 2 && (
|
||||
<button
|
||||
onClick={goBack}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-btn border border-strong text-content-secondary hover:bg-surface-2 text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-1.5"
|
||||
>
|
||||
<ArrowLeft size={15} />
|
||||
{t('onboarding_back')}
|
||||
</button>
|
||||
)}
|
||||
{/* Skip is available on every step: dismiss and go straight to chat. */}
|
||||
<button
|
||||
onClick={complete}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 rounded-btn text-sm font-medium text-content-tertiary hover:text-content cursor-pointer transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t('onboarding_skip')}
|
||||
</button>
|
||||
{/* Primary action: advance on step 1, save + finish on the last step. */}
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!canNext || saving}
|
||||
className="px-5 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
|
||||
>
|
||||
{saving && <Loader2 size={15} className="animate-spin" />}
|
||||
{saving
|
||||
? t('onboarding_saving')
|
||||
: step === TOTAL_STEPS
|
||||
? t('onboarding_finish')
|
||||
: t('onboarding_next')}
|
||||
{!saving && <ArrowRight size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OnboardingWizard
|
||||
222
desktop/src/renderer/src/components/QrLoginModal.tsx
Normal file
222
desktop/src/renderer/src/components/QrLoginModal.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Loader2, Check, RotateCcw } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
import { Modal } from '../pages/settings/primitives'
|
||||
|
||||
type Provider = 'weixin' | 'feishu'
|
||||
type Phase = 'loading' | 'waiting' | 'scanned' | 'success' | 'error'
|
||||
|
||||
interface QrLoginModalProps {
|
||||
provider: Provider
|
||||
onClose: () => void
|
||||
// Fired once the channel is connected so the page can refresh.
|
||||
onConnected: () => void
|
||||
}
|
||||
|
||||
const POLL_INTERVAL = 2000
|
||||
|
||||
// Shared QR-login / QR-register modal for WeChat and Feishu. Mirrors the web
|
||||
// console flow: fetch a QR, poll status, then connect the channel on success.
|
||||
const QrLoginModal: React.FC<QrLoginModalProps> = ({ provider, onClose, onConnected }) => {
|
||||
const [phase, setPhase] = useState<Phase>('loading')
|
||||
const [qr, setQr] = useState('')
|
||||
const [openLink, setOpenLink] = useState('')
|
||||
const [errMsg, setErrMsg] = useState('')
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const aliveRef = useRef(true)
|
||||
|
||||
const stopPoll = () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const fail = (msg: string) => {
|
||||
if (!aliveRef.current) return
|
||||
setPhase('error')
|
||||
setErrMsg(msg)
|
||||
}
|
||||
|
||||
// ---- WeChat: GET qr, POST poll {scaned|confirmed|expired} -----------------
|
||||
const pollWeixin = () => {
|
||||
timerRef.current = setTimeout(async () => {
|
||||
if (!aliveRef.current) return
|
||||
try {
|
||||
const data = await apiClient.weixinQrAction('poll')
|
||||
if (!aliveRef.current) return
|
||||
if (data.status !== 'success') return pollWeixin()
|
||||
const s = data.qr_status as string
|
||||
if (s === 'confirmed') {
|
||||
setPhase('success')
|
||||
await apiClient.channelAction('connect', 'weixin', {})
|
||||
if (aliveRef.current) onConnected()
|
||||
} else if (s === 'expired' && (data.qr_image || data.qrcode_url)) {
|
||||
setQr((data.qr_image as string) || (data.qrcode_url as string))
|
||||
setPhase('waiting')
|
||||
pollWeixin()
|
||||
} else if (s === 'scaned') {
|
||||
setPhase('scanned')
|
||||
pollWeixin()
|
||||
} else {
|
||||
pollWeixin()
|
||||
}
|
||||
} catch {
|
||||
pollWeixin()
|
||||
}
|
||||
}, POLL_INTERVAL)
|
||||
}
|
||||
|
||||
const startWeixin = async () => {
|
||||
setPhase('loading')
|
||||
try {
|
||||
const data = await apiClient.getWeixinQr()
|
||||
if (!aliveRef.current) return
|
||||
if (data.status !== 'success') return fail(data.message || t('weixin_scan_fail'))
|
||||
setQr(data.qr_image || data.qrcode_url || '')
|
||||
setPhase('waiting')
|
||||
pollWeixin()
|
||||
} catch {
|
||||
fail(t('weixin_scan_fail'))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Feishu: GET qr, POST poll {done|expired|denied|error} ----------------
|
||||
const pollFeishu = () => {
|
||||
timerRef.current = setTimeout(async () => {
|
||||
if (!aliveRef.current) return
|
||||
try {
|
||||
const data = await apiClient.feishuRegisterPoll()
|
||||
if (!aliveRef.current) return
|
||||
if (data.status !== 'success') return fail((data.message as string) || t('feishu_scan_fail'))
|
||||
const rs = data.register_status as string
|
||||
if (rs === 'done') {
|
||||
setPhase('success')
|
||||
await apiClient.channelAction('connect', 'feishu', {
|
||||
feishu_app_id: data.app_id,
|
||||
feishu_app_secret: data.app_secret,
|
||||
})
|
||||
if (aliveRef.current) onConnected()
|
||||
} else if (rs === 'expired') {
|
||||
fail(t('feishu_scan_expired'))
|
||||
} else if (rs === 'denied') {
|
||||
fail(t('feishu_scan_denied'))
|
||||
} else if (rs === 'error') {
|
||||
fail((data.message as string) || t('feishu_scan_fail'))
|
||||
} else {
|
||||
pollFeishu()
|
||||
}
|
||||
} catch {
|
||||
pollFeishu()
|
||||
}
|
||||
}, POLL_INTERVAL)
|
||||
}
|
||||
|
||||
const startFeishu = async () => {
|
||||
setPhase('loading')
|
||||
try {
|
||||
const data = await apiClient.getFeishuRegister()
|
||||
if (!aliveRef.current) return
|
||||
if (data.status !== 'success') return fail(data.message || t('feishu_scan_fail'))
|
||||
setQr(data.qr_image || data.qrcode_url || '')
|
||||
setOpenLink(data.qrcode_url || '')
|
||||
setPhase('waiting')
|
||||
pollFeishu()
|
||||
} catch {
|
||||
fail(t('feishu_scan_fail'))
|
||||
}
|
||||
}
|
||||
|
||||
const start = () => {
|
||||
stopPoll()
|
||||
if (provider === 'weixin') void startWeixin()
|
||||
else void startFeishu()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
aliveRef.current = true
|
||||
start()
|
||||
return () => {
|
||||
aliveRef.current = false
|
||||
stopPoll()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [provider])
|
||||
|
||||
const title = provider === 'weixin' ? t('weixin_scan_title') : t('feishu_scan_title')
|
||||
const desc = provider === 'weixin' ? t('weixin_scan_desc') : t('feishu_scan_desc')
|
||||
const tip = provider === 'weixin' ? t('weixin_qr_tip') : t('feishu_scan_tip')
|
||||
|
||||
const statusText = (): string => {
|
||||
if (provider === 'weixin') {
|
||||
if (phase === 'scanned') return t('weixin_scan_scanned')
|
||||
return t('weixin_scan_waiting')
|
||||
}
|
||||
return t('feishu_scan_waiting')
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open title={title} onClose={onClose}>
|
||||
<div className="flex flex-col items-center py-2">
|
||||
{phase === 'loading' && (
|
||||
<div className="flex items-center text-content-tertiary py-10">
|
||||
<Loader2 size={18} className="animate-spin mr-2" />
|
||||
{provider === 'weixin' ? t('weixin_scan_loading') : t('feishu_scan_loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(phase === 'waiting' || phase === 'scanned') && (
|
||||
<>
|
||||
<p className="text-sm text-content-secondary mb-4 text-center">{desc}</p>
|
||||
<div className="bg-white p-3 rounded-card border border-subtle mb-3">
|
||||
{qr ? (
|
||||
<img src={qr} alt="QR" className="w-48 h-48" style={{ imageRendering: 'pixelated' }} />
|
||||
) : (
|
||||
<div className="w-48 h-48 flex items-center justify-center text-content-tertiary text-xs">QR</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-xs mb-1 ${phase === 'scanned' ? 'text-accent' : 'text-warning'}`}>{statusText()}</p>
|
||||
<p className="text-xs text-content-tertiary">{tip}</p>
|
||||
{openLink && (
|
||||
<a
|
||||
href={openLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-info hover:underline mt-2"
|
||||
>
|
||||
{t('feishu_scan_open_link')}
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{phase === 'success' && (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<div className="w-12 h-12 rounded-full bg-accent-soft flex items-center justify-center mb-3">
|
||||
<Check size={22} className="text-accent" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-accent">
|
||||
{provider === 'weixin' ? t('weixin_scan_success') : t('feishu_scan_success')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'error' && (
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<p className="text-sm text-danger text-center mb-3">{errMsg}</p>
|
||||
<button
|
||||
onClick={start}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-1.5 rounded-btn border border-strong text-sm text-content-secondary hover:bg-inset cursor-pointer transition-colors"
|
||||
>
|
||||
<RotateCcw size={13} />
|
||||
{t('feishu_scan_retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default QrLoginModal
|
||||
58
desktop/src/renderer/src/components/StatusScreen.tsx
Normal file
58
desktop/src/renderer/src/components/StatusScreen.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
import { t } from '../i18n'
|
||||
|
||||
interface StatusScreenProps {
|
||||
status: 'connecting' | 'error'
|
||||
error?: string
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
const StatusScreen: React.FC<StatusScreenProps> = ({ status, error, onRetry }) => {
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-[#111111]">
|
||||
<div className="text-center space-y-6 max-w-md px-8">
|
||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mx-auto shadow-lg shadow-primary-500/20" />
|
||||
|
||||
{status === 'connecting' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{t('status_starting')}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('status_starting_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style={{ animationDelay: '0s' }} />
|
||||
<span className="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style={{ animationDelay: '0.2s' }} />
|
||||
<span className="w-2 h-2 rounded-full bg-primary-400 animate-pulse-dot" style={{ animationDelay: '0.4s' }} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{t('status_error')}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{error || t('status_error_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors text-sm font-medium cursor-pointer"
|
||||
>
|
||||
<i className="fas fa-rotate-right text-xs" />
|
||||
{t('status_retry')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StatusScreen
|
||||
97
desktop/src/renderer/src/components/UpdateBanner.tsx
Normal file
97
desktop/src/renderer/src/components/UpdateBanner.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Download, RefreshCw, X, Loader2 } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import { useUpdateStore, hasPendingUpdate } from '../store/updateStore'
|
||||
|
||||
// Compact update panel anchored to the NavRail footer. Only mounts content
|
||||
// when there's a pending update; otherwise renders nothing so it stays out of
|
||||
// the way until electron-updater reports a new version.
|
||||
const UpdateBanner: React.FC = () => {
|
||||
const state = useUpdateStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const pending = hasPendingUpdate(state)
|
||||
const status = state.status
|
||||
|
||||
// Auto-open the panel the moment a new version is first detected.
|
||||
useEffect(() => {
|
||||
if (status?.state === 'available') setOpen(true)
|
||||
}, [status?.state])
|
||||
|
||||
if (!pending) return null
|
||||
|
||||
const version = state.version
|
||||
const downloading = status?.state === 'downloading'
|
||||
const downloaded = status?.state === 'downloaded'
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-14 left-2 right-2 z-40">
|
||||
{/* Collapsed pill: a red-dotted button that re-opens the panel. */}
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="relative w-full flex items-center gap-2 rounded-btn bg-accent-soft text-accent px-3 py-2 text-[13px] font-medium cursor-pointer hover:bg-accent-soft/80 transition-colors"
|
||||
>
|
||||
<span className="absolute -top-1 -left-1 h-2 w-2 rounded-full bg-danger" />
|
||||
<Download size={15} />
|
||||
<span className="truncate">{t('update_available')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="rounded-lg border border-default bg-elevated shadow-lg p-3 space-y-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold text-content">{t('update_available')}</p>
|
||||
{version && <p className="text-xs text-content-tertiary mt-0.5">v{version}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
state.dismiss()
|
||||
}}
|
||||
className="text-content-tertiary hover:text-content cursor-pointer flex-shrink-0"
|
||||
title={t('update_later')}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{downloading && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-xs text-content-secondary">
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
<span>{t('update_downloading')} {state.percent}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-surface-2 overflow-hidden">
|
||||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${state.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!downloading && !downloaded && (
|
||||
<button
|
||||
onClick={() => state.download()}
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<Download size={15} />
|
||||
{t('update_download')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloaded && (
|
||||
<button
|
||||
onClick={() => state.install()}
|
||||
className="w-full inline-flex items-center justify-center gap-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover px-3 py-2 text-[13px] font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
{t('update_restart')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpdateBanner
|
||||
189
desktop/src/renderer/src/highlight.css
Normal file
189
desktop/src/renderer/src/highlight.css
Normal file
@@ -0,0 +1,189 @@
|
||||
/* highlight.js github themes, scoped for light/dark. Generated; do not edit. */
|
||||
pre code.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em
|
||||
}
|
||||
code.hljs {
|
||||
padding: 3px 5px
|
||||
}
|
||||
/*!
|
||||
Theme: GitHub
|
||||
Description: Light theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-light
|
||||
Current colors taken from GitHub's CSS
|
||||
*/
|
||||
.hljs {
|
||||
color: #24292e;
|
||||
background: #ffffff
|
||||
}
|
||||
.hljs-doctag,
|
||||
.hljs-keyword,
|
||||
.hljs-meta .hljs-keyword,
|
||||
.hljs-template-tag,
|
||||
.hljs-template-variable,
|
||||
.hljs-type,
|
||||
.hljs-variable.language_ {
|
||||
/* prettylights-syntax-keyword */
|
||||
color: #d73a49
|
||||
}
|
||||
.hljs-title,
|
||||
.hljs-title.class_,
|
||||
.hljs-title.class_.inherited__,
|
||||
.hljs-title.function_ {
|
||||
/* prettylights-syntax-entity */
|
||||
color: #6f42c1
|
||||
}
|
||||
.hljs-attr,
|
||||
.hljs-attribute,
|
||||
.hljs-literal,
|
||||
.hljs-meta,
|
||||
.hljs-number,
|
||||
.hljs-operator,
|
||||
.hljs-variable,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-id {
|
||||
/* prettylights-syntax-constant */
|
||||
color: #005cc5
|
||||
}
|
||||
.hljs-regexp,
|
||||
.hljs-string,
|
||||
.hljs-meta .hljs-string {
|
||||
/* prettylights-syntax-string */
|
||||
color: #032f62
|
||||
}
|
||||
.hljs-built_in,
|
||||
.hljs-symbol {
|
||||
/* prettylights-syntax-variable */
|
||||
color: #e36209
|
||||
}
|
||||
.hljs-comment,
|
||||
.hljs-code,
|
||||
.hljs-formula {
|
||||
/* prettylights-syntax-comment */
|
||||
color: #6a737d
|
||||
}
|
||||
.hljs-name,
|
||||
.hljs-quote,
|
||||
.hljs-selector-tag,
|
||||
.hljs-selector-pseudo {
|
||||
/* prettylights-syntax-entity-tag */
|
||||
color: #22863a
|
||||
}
|
||||
.hljs-subst {
|
||||
/* prettylights-syntax-storage-modifier-import */
|
||||
color: #24292e
|
||||
}
|
||||
.hljs-section {
|
||||
/* prettylights-syntax-markup-heading */
|
||||
color: #005cc5;
|
||||
font-weight: bold
|
||||
}
|
||||
.hljs-bullet {
|
||||
/* prettylights-syntax-markup-list */
|
||||
color: #735c0f
|
||||
}
|
||||
.hljs-emphasis {
|
||||
/* prettylights-syntax-markup-italic */
|
||||
color: #24292e;
|
||||
font-style: italic
|
||||
}
|
||||
.hljs-strong {
|
||||
/* prettylights-syntax-markup-bold */
|
||||
color: #24292e;
|
||||
font-weight: bold
|
||||
}
|
||||
.hljs-addition {
|
||||
/* prettylights-syntax-markup-inserted */
|
||||
color: #22863a;
|
||||
background-color: #f0fff4
|
||||
}
|
||||
.hljs-deletion {
|
||||
/* prettylights-syntax-markup-deleted */
|
||||
color: #b31d28;
|
||||
background-color: #ffeef0
|
||||
}
|
||||
.hljs-char.escape_,
|
||||
.hljs-link,
|
||||
.hljs-params,
|
||||
.hljs-property,
|
||||
.hljs-punctuation,
|
||||
.hljs-tag {
|
||||
/* purposely ignored */
|
||||
|
||||
}
|
||||
.dark pre code.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em
|
||||
}.dark code.hljs {
|
||||
padding: 3px 5px
|
||||
}.dark /*!
|
||||
Theme: GitHub Dark
|
||||
Description: Dark theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-dark
|
||||
Current colors taken from GitHub's CSS
|
||||
*/
|
||||
.hljs {
|
||||
color: #c9d1d9;
|
||||
background: #0d1117
|
||||
}.dark .hljs-doctag, .dark .hljs-keyword, .dark .hljs-meta .hljs-keyword, .dark .hljs-template-tag, .dark .hljs-template-variable, .dark .hljs-type, .dark .hljs-variable.language_ {
|
||||
/* prettylights-syntax-keyword */
|
||||
color: #ff7b72
|
||||
}.dark .hljs-title, .dark .hljs-title.class_, .dark .hljs-title.class_.inherited__, .dark .hljs-title.function_ {
|
||||
/* prettylights-syntax-entity */
|
||||
color: #d2a8ff
|
||||
}.dark .hljs-attr, .dark .hljs-attribute, .dark .hljs-literal, .dark .hljs-meta, .dark .hljs-number, .dark .hljs-operator, .dark .hljs-variable, .dark .hljs-selector-attr, .dark .hljs-selector-class, .dark .hljs-selector-id {
|
||||
/* prettylights-syntax-constant */
|
||||
color: #79c0ff
|
||||
}.dark .hljs-regexp, .dark .hljs-string, .dark .hljs-meta .hljs-string {
|
||||
/* prettylights-syntax-string */
|
||||
color: #a5d6ff
|
||||
}.dark .hljs-built_in, .dark .hljs-symbol {
|
||||
/* prettylights-syntax-variable */
|
||||
color: #ffa657
|
||||
}.dark .hljs-comment, .dark .hljs-code, .dark .hljs-formula {
|
||||
/* prettylights-syntax-comment */
|
||||
color: #8b949e
|
||||
}.dark .hljs-name, .dark .hljs-quote, .dark .hljs-selector-tag, .dark .hljs-selector-pseudo {
|
||||
/* prettylights-syntax-entity-tag */
|
||||
color: #7ee787
|
||||
}.dark .hljs-subst {
|
||||
/* prettylights-syntax-storage-modifier-import */
|
||||
color: #c9d1d9
|
||||
}.dark .hljs-section {
|
||||
/* prettylights-syntax-markup-heading */
|
||||
color: #1f6feb;
|
||||
font-weight: bold
|
||||
}.dark .hljs-bullet {
|
||||
/* prettylights-syntax-markup-list */
|
||||
color: #f2cc60
|
||||
}.dark .hljs-emphasis {
|
||||
/* prettylights-syntax-markup-italic */
|
||||
color: #c9d1d9;
|
||||
font-style: italic
|
||||
}.dark .hljs-strong {
|
||||
/* prettylights-syntax-markup-bold */
|
||||
color: #c9d1d9;
|
||||
font-weight: bold
|
||||
}.dark .hljs-addition {
|
||||
/* prettylights-syntax-markup-inserted */
|
||||
color: #aff5b4;
|
||||
background-color: #033a16
|
||||
}.dark .hljs-deletion {
|
||||
/* prettylights-syntax-markup-deleted */
|
||||
color: #ffdcd7;
|
||||
background-color: #67060c
|
||||
}.dark .hljs-char.escape_, .dark .hljs-link, .dark .hljs-params, .dark .hljs-property, .dark .hljs-punctuation, .dark .hljs-tag {
|
||||
/* purposely ignored */
|
||||
|
||||
}
|
||||
137
desktop/src/renderer/src/hooks/useBackend.ts
Normal file
137
desktop/src/renderer/src/hooks/useBackend.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
interface BackendState {
|
||||
status: 'connecting' | 'ready' | 'error'
|
||||
port: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function useBackend() {
|
||||
const [state, setState] = useState<BackendState>({
|
||||
status: 'connecting',
|
||||
port: 9899,
|
||||
})
|
||||
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/config`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// True once the backend has answered at least once. After this we never flip
|
||||
// back to "error" from polling — a hidden/backgrounded window throttles JS
|
||||
// timers, so attempt counters are unreliable and would otherwise produce a
|
||||
// false "failed to start" even though the backend is alive.
|
||||
const readyRef = useRef(false)
|
||||
// Holds the latest resolved port so the visibility handler (registered once)
|
||||
// always probes the correct port without re-running the effect.
|
||||
const portRef = useRef(9899)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let offStatus: (() => void) | undefined
|
||||
const api = window.electronAPI
|
||||
|
||||
// Use a wall-clock deadline instead of an attempt counter so timer
|
||||
// throttling (when the window is in the background) can't fast-forward us
|
||||
// into a false failure. Only give up if we genuinely can't reach the
|
||||
// backend for this long.
|
||||
const startPolling = async (port: number) => {
|
||||
portRef.current = port
|
||||
const deadline = Date.now() + 90_000
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelled) return
|
||||
|
||||
const ready = await probeBackend(port)
|
||||
if (cancelled) return
|
||||
|
||||
if (ready) {
|
||||
readyRef.current = true
|
||||
setState({ status: 'ready', port })
|
||||
return
|
||||
}
|
||||
|
||||
// Backend already answered before but is briefly unreachable (e.g.
|
||||
// window was asleep): keep retrying, never surface an error.
|
||||
if (!readyRef.current && Date.now() >= deadline) {
|
||||
// Leave error undefined so StatusScreen shows the localized,
|
||||
// user-friendly message instead of a raw technical string.
|
||||
setState({ status: 'error', port })
|
||||
return
|
||||
}
|
||||
|
||||
pollingRef.current = setTimeout(poll, 1000)
|
||||
}
|
||||
|
||||
await poll()
|
||||
}
|
||||
|
||||
if (api) {
|
||||
api.getBackendPort().then((port) => {
|
||||
const p = port || 9899
|
||||
portRef.current = p
|
||||
setState((prev) => ({ ...prev, port: p }))
|
||||
startPolling(p)
|
||||
})
|
||||
|
||||
offStatus = api.onBackendStatus((data) => {
|
||||
if (data.status === 'ready' && data.port) {
|
||||
readyRef.current = true
|
||||
portRef.current = data.port
|
||||
setState({ status: 'ready', port: data.port })
|
||||
if (pollingRef.current) {
|
||||
clearTimeout(pollingRef.current)
|
||||
pollingRef.current = null
|
||||
}
|
||||
} else if (data.status === 'error' && !readyRef.current) {
|
||||
// Ignore late "error" from the main process once we've been ready —
|
||||
// it usually means the window was backgrounded, not a real failure.
|
||||
// Drop the raw technical message; StatusScreen shows a localized one.
|
||||
setState((prev) => ({ ...prev, status: 'error' }))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
startPolling(9899)
|
||||
}
|
||||
|
||||
// When the window comes back to the foreground, re-probe immediately so a
|
||||
// user returning after a while sees the real (ready) state right away
|
||||
// instead of waiting for the throttled timer to catch up.
|
||||
const onVisible = () => {
|
||||
if (cancelled || document.visibilityState !== 'visible') return
|
||||
probeBackend(portRef.current).then((ready) => {
|
||||
if (cancelled || !ready) return
|
||||
readyRef.current = true
|
||||
setState((prev) => ({ ...prev, status: 'ready' }))
|
||||
})
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (pollingRef.current) {
|
||||
clearTimeout(pollingRef.current)
|
||||
}
|
||||
offStatus?.()
|
||||
document.removeEventListener('visibilitychange', onVisible)
|
||||
}
|
||||
}, [probeBackend])
|
||||
|
||||
const restart = useCallback(async () => {
|
||||
setState((prev) => ({ ...prev, status: 'connecting', error: undefined }))
|
||||
if (window.electronAPI) {
|
||||
await window.electronAPI.restartBackend()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${state.port}`
|
||||
|
||||
return { ...state, baseUrl, restart }
|
||||
}
|
||||
29
desktop/src/renderer/src/hooks/usePlatform.ts
Normal file
29
desktop/src/renderer/src/hooks/usePlatform.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export type Platform = 'mac' | 'win' | 'linux'
|
||||
|
||||
function detectPlatform(): Platform {
|
||||
const p = window.electronAPI?.platform
|
||||
if (p === 'darwin') return 'mac'
|
||||
if (p === 'win32') return 'win'
|
||||
if (p === 'linux') return 'linux'
|
||||
// Fallback for browser dev without electron
|
||||
if (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform)) return 'mac'
|
||||
return 'win'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the host platform and applies a `.platform-*` class on <html>
|
||||
* so CSS can branch on platform (titlebar layout, scrollbars, etc.).
|
||||
*/
|
||||
export function usePlatform(): { platform: Platform; isMac: boolean; isWin: boolean } {
|
||||
const [platform] = useState<Platform>(detectPlatform)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.classList.remove('platform-mac', 'platform-win', 'platform-linux')
|
||||
root.classList.add(`platform-${platform}`)
|
||||
}, [platform])
|
||||
|
||||
return { platform, isMac: platform === 'mac', isWin: platform === 'win' }
|
||||
}
|
||||
57
desktop/src/renderer/src/hooks/useTheme.ts
Normal file
57
desktop/src/renderer/src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export type ThemePref = 'light' | 'dark' | 'system'
|
||||
export type ResolvedTheme = 'light' | 'dark'
|
||||
|
||||
const STORAGE_KEY = 'cow_theme'
|
||||
|
||||
function getSystemTheme(): ResolvedTheme {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function readStored(): ThemePref {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
|
||||
// Default to dark to match the app's flagship look
|
||||
return 'dark'
|
||||
}
|
||||
|
||||
function applyTheme(resolved: ResolvedTheme) {
|
||||
const root = document.documentElement
|
||||
root.classList.toggle('dark', resolved === 'dark')
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [pref, setPref] = useState<ThemePref>(readStored)
|
||||
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
|
||||
readStored() === 'system' ? getSystemTheme() : (readStored() as ResolvedTheme)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const next: ResolvedTheme = pref === 'system' ? getSystemTheme() : pref
|
||||
setResolved(next)
|
||||
applyTheme(next)
|
||||
localStorage.setItem(STORAGE_KEY, pref)
|
||||
}, [pref])
|
||||
|
||||
// Follow system changes only when preference is "system"
|
||||
useEffect(() => {
|
||||
if (pref !== 'system') return
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => {
|
||||
const next = getSystemTheme()
|
||||
setResolved(next)
|
||||
applyTheme(next)
|
||||
}
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}, [pref])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setPref(resolved === 'dark' ? 'light' : 'dark')
|
||||
}, [resolved])
|
||||
|
||||
const setTheme = useCallback((next: ThemePref) => setPref(next), [])
|
||||
|
||||
return { theme: resolved, pref, toggleTheme, setTheme }
|
||||
}
|
||||
627
desktop/src/renderer/src/i18n.ts
Normal file
627
desktop/src/renderer/src/i18n.ts
Normal file
@@ -0,0 +1,627 @@
|
||||
const translations: Record<string, Record<string, string>> = {
|
||||
zh: {
|
||||
console: '控制台',
|
||||
nav_chat: '对话',
|
||||
nav_manage: '管理',
|
||||
nav_monitor: '监控',
|
||||
menu_chat: '对话',
|
||||
menu_config: '配置',
|
||||
menu_skills: '技能',
|
||||
menu_memory: '记忆',
|
||||
menu_channels: '通道',
|
||||
menu_tasks: '定时',
|
||||
menu_logs: '日志',
|
||||
menu_models: '模型',
|
||||
menu_knowledge: '知识',
|
||||
menu_settings: '设置',
|
||||
// knowledge
|
||||
knowledge_title: '知识库',
|
||||
knowledge_desc: '浏览和探索你的知识库',
|
||||
knowledge_tab_docs: '文档',
|
||||
knowledge_tab_graph: '图谱',
|
||||
knowledge_search: '搜索文档...',
|
||||
knowledge_stats: '{pages} 篇 · {size}',
|
||||
knowledge_select_hint: '从左侧选择一个文档查看',
|
||||
knowledge_empty: '知识库还是空的',
|
||||
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent,它会自动整理到你的知识库中',
|
||||
knowledge_go_chat: '开始对话',
|
||||
knowledge_loading: '加载知识库中...',
|
||||
knowledge_graph_empty: '暂无关联图谱',
|
||||
knowledge_disabled: '知识库未启用',
|
||||
knowledge_doc_load_error: '文档加载失败',
|
||||
nav_expand: '展开侧栏',
|
||||
nav_collapse: '收起侧栏',
|
||||
update_available: '发现新版本',
|
||||
update_download: '下载更新',
|
||||
update_downloading: '正在下载',
|
||||
update_restart: '重启以更新',
|
||||
update_later: '稍后',
|
||||
update_latest: '已是最新版本',
|
||||
// onboarding
|
||||
onboarding_welcome_title: '欢迎使用 CowAgent',
|
||||
onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
|
||||
onboarding_lang_label: '界面语言',
|
||||
onboarding_model_title: '配置对话模型',
|
||||
onboarding_model_desc: '选择模型厂商并填入 API Key,即可开始使用。',
|
||||
onboarding_provider: '模型厂商',
|
||||
onboarding_select_provider: '选择厂商',
|
||||
onboarding_apikey: 'API Key',
|
||||
onboarding_apikey_placeholder: '输入 API 密钥',
|
||||
onboarding_key_guide: '没有秘钥?前往创建',
|
||||
onboarding_apibase: 'API 地址(可选)',
|
||||
onboarding_model: '模型',
|
||||
onboarding_select_model: '选择模型',
|
||||
onboarding_done_title: '一切就绪',
|
||||
onboarding_done_desc: '配置完成,开始你的第一次对话吧。',
|
||||
onboarding_next: '下一步',
|
||||
onboarding_back: '上一步',
|
||||
onboarding_skip: '跳过',
|
||||
onboarding_finish: '开始对话',
|
||||
onboarding_step: '第 {n} / {total} 步',
|
||||
onboarding_saving: '保存中...',
|
||||
onboarding_save_failed: '保存失败,请检查后重试',
|
||||
sessions_title: '会话',
|
||||
session_new: '新对话',
|
||||
session_rename: '重命名',
|
||||
session_delete: '删除',
|
||||
session_empty: '暂无会话',
|
||||
session_today: '今天',
|
||||
session_yesterday: '昨天',
|
||||
session_earlier: '更早',
|
||||
msg_copy: '复制',
|
||||
msg_copied: '已复制',
|
||||
msg_regenerate: '重新生成',
|
||||
msg_edit: '编辑',
|
||||
msg_delete: '删除',
|
||||
msg_cancelled: '已中止',
|
||||
msg_self_learned: '自主学习',
|
||||
msg_stop: '停止',
|
||||
chat_clear_context: '清除上下文',
|
||||
chat_load_earlier: '加载更早的消息',
|
||||
chat_send: '发送',
|
||||
chat_attach: '添加附件',
|
||||
slash_hint: '输入 / 查看命令',
|
||||
chat_welcome: '有什么可以帮你的?',
|
||||
chat_empty_hint: '发送一条消息开始对话',
|
||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||
example_sys_title: '系统管理',
|
||||
example_sys_text: '帮我查看工作空间里有哪些文件',
|
||||
example_task_title: '技能系统',
|
||||
example_task_text: '查看所有支持的工具和技能',
|
||||
example_code_title: '编程助手',
|
||||
example_code_text: '帮我编写一个Python爬虫脚本',
|
||||
input_placeholder: '输入消息...',
|
||||
config_title: '配置管理',
|
||||
config_desc: '管理模型和 Agent 配置',
|
||||
config_model: '模型配置',
|
||||
config_agent: 'Agent 配置',
|
||||
config_provider: '模型厂商',
|
||||
config_model_name: '模型',
|
||||
config_custom_model_hint: '输入自定义模型名称',
|
||||
config_save: '保存',
|
||||
config_saved: '已保存',
|
||||
config_save_error: '保存失败',
|
||||
config_custom_option: '自定义',
|
||||
config_max_tokens: '最大上下文 Token',
|
||||
config_max_turns: '最大记忆轮次',
|
||||
config_max_steps: '最大执行步数',
|
||||
config_max_tokens_hint: '对话中 Agent 能输入的最大 Token 长度,超过后会智能压缩处理',
|
||||
config_max_turns_hint: '一问一答为一轮,超过后会智能压缩处理',
|
||||
config_max_steps_hint: '单次对话中 Agent 最多调用工具的次数',
|
||||
config_thinking: '深度思考',
|
||||
config_thinking_hint: '是否启用深度思考模式',
|
||||
config_evolution: '自主进化',
|
||||
config_evolution_hint: '会话空闲后自动复盘,沉淀记忆、优化技能、处理未完成事项',
|
||||
config_security: '安全设置',
|
||||
config_password: '访问密码',
|
||||
config_password_hint: '留空则不启用密码保护',
|
||||
config_password_placeholder: '留空表示不设密码',
|
||||
config_password_saved: '密码已更新,请重新登录',
|
||||
config_password_cleared: '密码已清除',
|
||||
config_language: '语言',
|
||||
config_language_hint: '界面与回复语言',
|
||||
config_credentials_link: 'API Key 与接口地址请在「模型配置」中设置',
|
||||
config_goto_models: '前往配置',
|
||||
config_provider_unconfigured: '未配置',
|
||||
config_provider_unconfigured_hint: '该厂商尚未配置 API Key,请先前往配置',
|
||||
config_cancel: '取消',
|
||||
// settings tabs
|
||||
settings_tab_basic: '基础配置',
|
||||
settings_tab_models: '模型配置',
|
||||
// models tab
|
||||
models_vendors: '厂商凭据',
|
||||
models_vendors_sub: '一处配置,多个模型能力共享',
|
||||
models_configured: '已配置',
|
||||
models_no_vendor: '尚未配置任何厂商',
|
||||
models_add_vendor: '添加厂商',
|
||||
models_custom_vendor: '自定义',
|
||||
models_add_custom: '添加自定义厂商',
|
||||
models_add_custom_hint: '接口需遵循 OpenAI API 协议',
|
||||
models_edit_custom: '编辑自定义厂商',
|
||||
models_custom_name: '名称',
|
||||
models_custom_base_hint: '接口需遵循 OpenAI API 协议',
|
||||
models_clear: '清除凭据',
|
||||
models_delete: '删除',
|
||||
models_clear_confirm: '确认清除该厂商的 API Key 与 Base URL 吗?相关能力将不再可用。',
|
||||
models_delete_confirm: '确定删除该自定义厂商吗?此操作无法撤销。',
|
||||
models_provider: '厂商',
|
||||
models_model: '模型',
|
||||
models_voice: '音色',
|
||||
models_select_provider: '待选择',
|
||||
models_select_model: '请选择模型',
|
||||
models_select_voice: '请选择音色',
|
||||
models_no_options: '暂无可选项',
|
||||
models_auto: '自动',
|
||||
models_asr_auto: '自动(跟随主模型)',
|
||||
models_disabled: '不启用',
|
||||
models_fallback: '兜底',
|
||||
models_cap_chat: '主模型',
|
||||
models_cap_chat_sub: '用于基础对话和 Agent 推理',
|
||||
models_cap_vision: '图像理解',
|
||||
models_cap_vision_sub: '识别图片内容,用于图像识别工具',
|
||||
models_cap_image: '图像生成',
|
||||
models_cap_image_sub: '生成图片,用于图像生成技能',
|
||||
models_cap_asr: '语音识别',
|
||||
models_cap_asr_sub: '语音转文字',
|
||||
models_cap_tts: '语音合成',
|
||||
models_cap_tts_sub: '文字转语音',
|
||||
models_cap_embedding: '向量',
|
||||
models_cap_embedding_sub: '用于记忆与知识的向量化检索',
|
||||
models_cap_search: '联网搜索',
|
||||
models_cap_search_sub: '实时网页检索能力,用于搜索工具',
|
||||
models_tts_reply_mode: '语音回复模式',
|
||||
models_tts_reply_mode_hint: '决定何时以语音回复用户',
|
||||
models_tts_mode_off: '关闭',
|
||||
models_tts_mode_if_voice: '仅当用户发语音时',
|
||||
models_tts_mode_always: '始终语音回复',
|
||||
models_embedding_dim: '维度',
|
||||
models_embedding_rebuild_hint: '切换向量模型后,已有索引将失效,需要重建',
|
||||
models_search_strategy: '策略',
|
||||
models_search_auto: '自动',
|
||||
models_search_fixed: '指定',
|
||||
models_search_provider: '搜索厂商',
|
||||
models_search_bocha_key: '配置博查 API Key',
|
||||
models_search_bocha_hint: '前往博查开放平台创建 API Key',
|
||||
skills_title: '技能管理',
|
||||
skills_desc: '查看、启用或禁用 Agent 工具和技能',
|
||||
skills_hub_btn: '探索技能广场',
|
||||
tools_section_title: '内置工具',
|
||||
skills_section_title: '技能',
|
||||
tools_loading: '加载工具中...',
|
||||
skills_loading: '加载技能中...',
|
||||
skills_loading_desc: '技能将在加载后显示',
|
||||
skill_enabled: '已启用',
|
||||
skill_disabled: '已禁用',
|
||||
tools_empty: '暂无内置工具',
|
||||
skills_empty: '暂无技能',
|
||||
memory_title: '记忆管理',
|
||||
memory_desc: '查看 Agent 记忆文件和内容',
|
||||
memory_tab_files: '记忆文件',
|
||||
memory_tab_dreams: '自主进化',
|
||||
memory_loading: '加载记忆文件中...',
|
||||
memory_loading_desc: '记忆文件将在加载后显示',
|
||||
memory_col_name: '文件名',
|
||||
memory_col_type: '类型',
|
||||
memory_col_size: '大小',
|
||||
memory_col_updated: '更新时间',
|
||||
memory_back: '返回列表',
|
||||
memory_empty_files: '暂无记忆文件',
|
||||
memory_empty_evolution: '暂无进化记录',
|
||||
memory_type_global: '全局',
|
||||
memory_type_daily: '每日',
|
||||
memory_type_evolution: '进化',
|
||||
memory_type_dream: '梦境',
|
||||
memory_doc_load_error: '内容加载失败',
|
||||
memory_prev: '上一页',
|
||||
memory_next: '下一页',
|
||||
channels_title: '通道管理',
|
||||
channels_desc: '查看和管理消息通道',
|
||||
channels_add: '添加通道',
|
||||
channels_connected: '已连接',
|
||||
channels_disconnected: '未连接',
|
||||
channels_connect: '连接',
|
||||
channels_disconnect: '断开',
|
||||
channels_save: '保存',
|
||||
channels_loading: '加载通道中...',
|
||||
channels_connected_section: '已连接',
|
||||
channels_available_section: '可添加',
|
||||
channels_empty_connected: '暂无已连接的通道',
|
||||
channels_qr_hint: '该通道通过扫码登录,请前往 Web 控制台完成扫码连接',
|
||||
channels_save_ok: '已保存',
|
||||
channels_save_error: '保存失败',
|
||||
channels_connect_error: '连接失败',
|
||||
channels_scan_login: '扫码登录',
|
||||
channels_scan_register: '扫码注册',
|
||||
weixin_scan_title: '微信扫码登录',
|
||||
weixin_scan_desc: '使用微信扫描下方二维码登录',
|
||||
weixin_scan_loading: '正在获取二维码...',
|
||||
weixin_scan_waiting: '等待扫码',
|
||||
weixin_scan_scanned: '已扫描,请在手机上确认',
|
||||
weixin_scan_success: '登录成功',
|
||||
weixin_scan_expired: '二维码已过期,正在刷新...',
|
||||
weixin_scan_fail: '获取二维码失败',
|
||||
weixin_qr_tip: '请使用登录的微信扫码',
|
||||
feishu_scan_title: '飞书扫码注册',
|
||||
feishu_scan_desc: '扫码后将自动创建并连接飞书机器人',
|
||||
feishu_scan_loading: '正在生成二维码...',
|
||||
feishu_scan_waiting: '请使用飞书扫码授权',
|
||||
feishu_scan_tip: '扫码后在飞书中确认授权',
|
||||
feishu_scan_open_link: '打开授权链接',
|
||||
feishu_scan_success: '注册成功',
|
||||
feishu_scan_expired: '二维码已过期',
|
||||
feishu_scan_denied: '授权被拒绝',
|
||||
feishu_scan_fail: '注册失败',
|
||||
feishu_scan_retry: '重试',
|
||||
tasks_title: '定时任务',
|
||||
tasks_desc: '查看和管理定时任务',
|
||||
tasks_active: '运行中',
|
||||
tasks_paused: '已暂停',
|
||||
tasks_empty: '暂无定时任务',
|
||||
tasks_empty_guide: '在对话中告诉 Agent「每天 9 点提醒我…」即可创建定时任务',
|
||||
tasks_go_chat: '去对话创建',
|
||||
tasks_next_run: '下次执行',
|
||||
tasks_loading: '加载定时任务中...',
|
||||
task_edit_title: '编辑任务',
|
||||
task_name: '名称',
|
||||
task_enabled: '启用',
|
||||
task_schedule_type: '调度类型',
|
||||
task_type_cron: 'Cron 表达式',
|
||||
task_type_interval: '固定间隔',
|
||||
task_type_once: '单次执行',
|
||||
task_cron_expr: 'Cron 表达式',
|
||||
task_cron_hint: '如 0 9 * * * 表示每天 9 点',
|
||||
task_interval_seconds: '间隔(秒)',
|
||||
task_once_time: '执行时间',
|
||||
task_action_type: '动作类型',
|
||||
task_action_send: '发送消息',
|
||||
task_action_agent: 'Agent 任务',
|
||||
task_message_content: '消息内容',
|
||||
task_task_description: '任务描述',
|
||||
task_channel: '通道',
|
||||
task_receiver: '接收者',
|
||||
task_channel_locked: '通道与接收者创建后不可修改',
|
||||
task_save: '保存',
|
||||
task_cancel: '取消',
|
||||
task_delete: '删除',
|
||||
task_delete_confirm: '确定删除该任务吗?此操作不可撤销。',
|
||||
task_save_error: '保存失败',
|
||||
logs_title: '日志',
|
||||
logs_desc: '实时日志输出 (run.log)',
|
||||
logs_live: '实时',
|
||||
logs_connecting: '日志流将在连接后显示...',
|
||||
status_starting: '正在启动 CowAgent...',
|
||||
status_starting_desc: '正在初始化客户端,请稍候',
|
||||
status_error: '初始化失败',
|
||||
status_error_desc: '客户端初始化失败,请重试',
|
||||
status_retry: '重试',
|
||||
},
|
||||
en: {
|
||||
console: 'Console',
|
||||
nav_chat: 'Chat',
|
||||
nav_manage: 'Management',
|
||||
nav_monitor: 'Monitor',
|
||||
menu_chat: 'Chat',
|
||||
menu_config: 'Config',
|
||||
menu_skills: 'Skills',
|
||||
menu_memory: 'Memory',
|
||||
menu_channels: 'Channels',
|
||||
menu_tasks: 'Tasks',
|
||||
menu_logs: 'Logs',
|
||||
menu_models: 'Models',
|
||||
menu_knowledge: 'Knowledge',
|
||||
// knowledge
|
||||
knowledge_title: 'Knowledge Base',
|
||||
knowledge_desc: 'Browse and explore your knowledge base',
|
||||
knowledge_tab_docs: 'Documents',
|
||||
knowledge_tab_graph: 'Graph',
|
||||
knowledge_search: 'Search documents...',
|
||||
knowledge_stats: '{pages} pages · {size}',
|
||||
knowledge_select_hint: 'Select a document to view',
|
||||
knowledge_empty: 'Your knowledge base is empty',
|
||||
knowledge_empty_guide: 'Send documents, links or topics to the agent in chat — it will organize them into your knowledge base',
|
||||
knowledge_go_chat: 'Start chatting',
|
||||
knowledge_loading: 'Loading knowledge base...',
|
||||
knowledge_graph_empty: 'No graph available',
|
||||
knowledge_disabled: 'Knowledge base is disabled',
|
||||
knowledge_doc_load_error: 'Failed to load document',
|
||||
menu_settings: 'Settings',
|
||||
nav_expand: 'Expand sidebar',
|
||||
nav_collapse: 'Collapse sidebar',
|
||||
update_available: 'New version available',
|
||||
update_download: 'Download update',
|
||||
update_downloading: 'Downloading',
|
||||
update_restart: 'Restart to update',
|
||||
update_later: 'Later',
|
||||
update_latest: 'You are up to date',
|
||||
// onboarding
|
||||
onboarding_welcome_title: 'Welcome to CowAgent',
|
||||
onboarding_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.',
|
||||
onboarding_lang_label: 'Language',
|
||||
onboarding_model_title: 'Set up your chat model',
|
||||
onboarding_model_desc: 'Pick a provider and paste its API key to get started.',
|
||||
onboarding_provider: 'Provider',
|
||||
onboarding_select_provider: 'Select a provider',
|
||||
onboarding_apikey: 'API Key',
|
||||
onboarding_apikey_placeholder: 'Enter your API key',
|
||||
onboarding_key_guide: 'No key yet? Create one',
|
||||
onboarding_apibase: 'API base (optional)',
|
||||
onboarding_model: 'Model',
|
||||
onboarding_select_model: 'Select a model',
|
||||
onboarding_done_title: 'All set',
|
||||
onboarding_done_desc: 'Setup complete. Start your first conversation.',
|
||||
onboarding_next: 'Next',
|
||||
onboarding_back: 'Back',
|
||||
onboarding_skip: 'Skip',
|
||||
onboarding_finish: 'Start chatting',
|
||||
onboarding_step: 'Step {n} of {total}',
|
||||
onboarding_saving: 'Saving...',
|
||||
onboarding_save_failed: 'Save failed, please check and retry',
|
||||
sessions_title: 'Chats',
|
||||
session_new: 'New chat',
|
||||
session_rename: 'Rename',
|
||||
session_delete: 'Delete',
|
||||
session_empty: 'No conversations yet',
|
||||
session_today: 'Today',
|
||||
session_yesterday: 'Yesterday',
|
||||
session_earlier: 'Earlier',
|
||||
msg_copy: 'Copy',
|
||||
msg_copied: 'Copied',
|
||||
msg_regenerate: 'Regenerate',
|
||||
msg_edit: 'Edit',
|
||||
msg_delete: 'Delete',
|
||||
msg_cancelled: 'Cancelled',
|
||||
msg_self_learned: 'Self-learned',
|
||||
msg_stop: 'Stop',
|
||||
chat_clear_context: 'Clear context',
|
||||
chat_load_earlier: 'Load earlier messages',
|
||||
chat_send: 'Send',
|
||||
chat_attach: 'Attach file',
|
||||
slash_hint: 'Type / for commands',
|
||||
chat_welcome: 'How can I help you?',
|
||||
chat_empty_hint: 'Send a message to start the conversation',
|
||||
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills,\nand keep growing through long-term memory.',
|
||||
example_sys_title: 'System',
|
||||
example_sys_text: 'Show me the files in the workspace',
|
||||
example_task_title: 'Skills',
|
||||
example_task_text: 'Show current tools and skills',
|
||||
example_code_title: 'Coding',
|
||||
example_code_text: 'Write a Python web scraper script',
|
||||
input_placeholder: 'Type a message...',
|
||||
config_title: 'Configuration',
|
||||
config_desc: 'Manage model and agent settings',
|
||||
config_model: 'Model Configuration',
|
||||
config_agent: 'Agent Configuration',
|
||||
config_provider: 'Provider',
|
||||
config_model_name: 'Model',
|
||||
config_custom_model_hint: 'Enter custom model name',
|
||||
config_save: 'Save',
|
||||
config_saved: 'Saved',
|
||||
config_save_error: 'Save failed',
|
||||
config_custom_option: 'Custom',
|
||||
config_max_tokens_hint: 'Max token length the Agent can take in; longer context is compressed automatically',
|
||||
config_max_turns_hint: 'One question and answer is a turn; older turns are compressed automatically',
|
||||
config_max_steps_hint: 'Max tool calls the Agent can make in one turn',
|
||||
config_thinking: 'Deep Thinking',
|
||||
config_thinking_hint: 'Whether to enable deep thinking mode',
|
||||
config_evolution: 'Self-Evolution',
|
||||
config_evolution_hint: 'Review automatically when idle: consolidate memory, refine skills, finish pending tasks',
|
||||
config_security: 'Security',
|
||||
config_password: 'Access Password',
|
||||
config_password_hint: 'Leave empty to disable password protection',
|
||||
config_password_placeholder: 'Leave empty for no password',
|
||||
config_password_saved: 'Password updated, please log in again',
|
||||
config_password_cleared: 'Password cleared',
|
||||
config_language: 'Language',
|
||||
config_language_hint: 'Interface and reply language',
|
||||
config_credentials_link: 'Set API key and endpoint in "Models"',
|
||||
config_goto_models: 'Configure',
|
||||
config_provider_unconfigured: 'Not configured',
|
||||
config_provider_unconfigured_hint: 'This provider has no API key yet — configure it first',
|
||||
config_cancel: 'Cancel',
|
||||
// settings tabs
|
||||
settings_tab_basic: 'Basic',
|
||||
settings_tab_models: 'Models',
|
||||
// models tab
|
||||
models_vendors: 'Provider Credentials',
|
||||
models_vendors_sub: 'Configured once, shared by multiple model capabilities',
|
||||
models_configured: 'configured',
|
||||
models_no_vendor: 'No provider configured yet',
|
||||
models_add_vendor: 'Add Provider',
|
||||
models_custom_vendor: 'Custom',
|
||||
models_add_custom: 'Add custom provider',
|
||||
models_add_custom_hint: 'Endpoint must follow the OpenAI API protocol',
|
||||
models_edit_custom: 'Edit custom provider',
|
||||
models_custom_name: 'Name',
|
||||
models_custom_base_hint: 'Endpoint must follow the OpenAI API protocol',
|
||||
models_clear: 'Clear credentials',
|
||||
models_delete: 'Delete',
|
||||
models_clear_confirm: 'Clear the API key and base URL for this provider? Related capabilities will stop working.',
|
||||
models_delete_confirm: 'Delete this custom provider? This cannot be undone.',
|
||||
models_provider: 'Provider',
|
||||
models_model: 'Model',
|
||||
models_voice: 'Voice',
|
||||
models_select_provider: 'Select',
|
||||
models_select_model: 'Select a model',
|
||||
models_select_voice: 'Select a voice',
|
||||
models_no_options: 'No options',
|
||||
models_auto: 'Auto',
|
||||
models_asr_auto: 'Auto (follow main model)',
|
||||
models_disabled: 'Disabled',
|
||||
models_fallback: 'Fallback',
|
||||
models_cap_chat: 'Main Model',
|
||||
models_cap_chat_sub: 'Used for basic chat and agent reasoning',
|
||||
models_cap_vision: 'Image Understanding',
|
||||
models_cap_vision_sub: 'Recognizes image content, used by image recognition tools',
|
||||
models_cap_image: 'Image Generation',
|
||||
models_cap_image_sub: 'Generates images, used by image generation skills',
|
||||
models_cap_asr: 'Speech Recognition',
|
||||
models_cap_asr_sub: 'Voice to text',
|
||||
models_cap_tts: 'Speech Synthesis',
|
||||
models_cap_tts_sub: 'Text to voice',
|
||||
models_cap_embedding: 'Embedding',
|
||||
models_cap_embedding_sub: 'Used for vectorized retrieval of memory and knowledge',
|
||||
models_cap_search: 'Web Search',
|
||||
models_cap_search_sub: 'Real-time web retrieval, used by search tools',
|
||||
models_tts_reply_mode: 'Voice Reply Mode',
|
||||
models_tts_reply_mode_hint: 'When to reply with voice',
|
||||
models_tts_mode_off: 'Off',
|
||||
models_tts_mode_if_voice: 'Only when user sends voice',
|
||||
models_tts_mode_always: 'Always reply with voice',
|
||||
models_embedding_dim: 'Dimension',
|
||||
models_embedding_rebuild_hint: 'Existing index becomes invalid and must be rebuilt after changing the model',
|
||||
models_search_strategy: 'Strategy',
|
||||
models_search_auto: 'Auto',
|
||||
models_search_fixed: 'Pinned',
|
||||
models_search_provider: 'Search provider',
|
||||
models_search_bocha_key: 'Configure Bocha API Key',
|
||||
models_search_bocha_hint: 'Create a key at the Bocha open platform',
|
||||
config_max_tokens: 'Max Context Tokens',
|
||||
config_max_turns: 'Max Memory Turns',
|
||||
config_max_steps: 'Max Steps',
|
||||
skills_title: 'Skills',
|
||||
skills_desc: 'View, enable, or disable agent tools and skills',
|
||||
skills_hub_btn: 'Skill Hub',
|
||||
tools_section_title: 'Built-in Tools',
|
||||
skills_section_title: 'Skills',
|
||||
tools_loading: 'Loading tools...',
|
||||
skills_loading: 'Loading skills...',
|
||||
skills_loading_desc: 'Skills will be displayed here after loading',
|
||||
skill_enabled: 'Enabled',
|
||||
skill_disabled: 'Disabled',
|
||||
tools_empty: 'No built-in tools',
|
||||
skills_empty: 'No skills found',
|
||||
memory_title: 'Memory',
|
||||
memory_desc: 'View agent memory files and contents',
|
||||
memory_tab_files: 'Memory Files',
|
||||
memory_tab_dreams: 'Self-Evolution',
|
||||
memory_loading: 'Loading memory files...',
|
||||
memory_loading_desc: 'Memory files will be displayed here',
|
||||
memory_col_name: 'Filename',
|
||||
memory_col_type: 'Type',
|
||||
memory_col_size: 'Size',
|
||||
memory_col_updated: 'Updated',
|
||||
memory_back: 'Back to list',
|
||||
memory_empty_files: 'No memory files',
|
||||
memory_empty_evolution: 'No evolution records yet',
|
||||
memory_type_global: 'Global',
|
||||
memory_type_daily: 'Daily',
|
||||
memory_type_evolution: 'Evolution',
|
||||
memory_type_dream: 'Dream',
|
||||
memory_doc_load_error: 'Failed to load content',
|
||||
memory_prev: 'Prev',
|
||||
memory_next: 'Next',
|
||||
channels_title: 'Channels',
|
||||
channels_desc: 'View and manage messaging channels',
|
||||
channels_add: 'Add channel',
|
||||
channels_connected: 'Connected',
|
||||
channels_disconnected: 'Disconnected',
|
||||
channels_connect: 'Connect',
|
||||
channels_disconnect: 'Disconnect',
|
||||
channels_save: 'Save',
|
||||
channels_loading: 'Loading channels...',
|
||||
channels_connected_section: 'Connected',
|
||||
channels_available_section: 'Available',
|
||||
channels_empty_connected: 'No connected channels yet',
|
||||
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',
|
||||
channels_connect_error: 'Failed to connect',
|
||||
channels_scan_login: 'QR login',
|
||||
channels_scan_register: 'QR register',
|
||||
weixin_scan_title: 'WeChat QR Login',
|
||||
weixin_scan_desc: 'Scan the QR code below with WeChat to log in',
|
||||
weixin_scan_loading: 'Fetching QR code...',
|
||||
weixin_scan_waiting: 'Waiting for scan',
|
||||
weixin_scan_scanned: 'Scanned, please confirm on your phone',
|
||||
weixin_scan_success: 'Logged in',
|
||||
weixin_scan_expired: 'QR code expired, refreshing...',
|
||||
weixin_scan_fail: 'Failed to fetch QR code',
|
||||
weixin_qr_tip: 'Scan with the WeChat account to log in',
|
||||
feishu_scan_title: 'Feishu QR Register',
|
||||
feishu_scan_desc: 'Scanning will create and connect a Feishu bot automatically',
|
||||
feishu_scan_loading: 'Generating QR code...',
|
||||
feishu_scan_waiting: 'Scan with Feishu to authorize',
|
||||
feishu_scan_tip: 'Confirm the authorization in Feishu after scanning',
|
||||
feishu_scan_open_link: 'Open authorization link',
|
||||
feishu_scan_success: 'Registered',
|
||||
feishu_scan_expired: 'QR code expired',
|
||||
feishu_scan_denied: 'Authorization denied',
|
||||
feishu_scan_fail: 'Registration failed',
|
||||
feishu_scan_retry: 'Retry',
|
||||
tasks_title: 'Scheduled Tasks',
|
||||
tasks_desc: 'View and manage scheduled tasks',
|
||||
tasks_active: 'Active',
|
||||
tasks_paused: 'Paused',
|
||||
tasks_empty: 'No scheduled tasks',
|
||||
tasks_empty_guide: 'Tell the agent "remind me every day at 9am…" in chat to create a scheduled task',
|
||||
tasks_go_chat: 'Create in chat',
|
||||
tasks_next_run: 'Next run',
|
||||
tasks_loading: 'Loading scheduled tasks...',
|
||||
task_edit_title: 'Edit Task',
|
||||
task_name: 'Name',
|
||||
task_enabled: 'Enabled',
|
||||
task_schedule_type: 'Schedule type',
|
||||
task_type_cron: 'Cron',
|
||||
task_type_interval: 'Interval',
|
||||
task_type_once: 'Once',
|
||||
task_cron_expr: 'Cron expression',
|
||||
task_cron_hint: 'e.g. 0 9 * * * runs daily at 9am',
|
||||
task_interval_seconds: 'Interval (seconds)',
|
||||
task_once_time: 'Run at',
|
||||
task_action_type: 'Action type',
|
||||
task_action_send: 'Send message',
|
||||
task_action_agent: 'Agent task',
|
||||
task_message_content: 'Message content',
|
||||
task_task_description: 'Task description',
|
||||
task_channel: 'Channel',
|
||||
task_receiver: 'Receiver',
|
||||
task_channel_locked: 'Channel and receiver cannot be changed after creation',
|
||||
task_save: 'Save',
|
||||
task_cancel: 'Cancel',
|
||||
task_delete: 'Delete',
|
||||
task_delete_confirm: 'Delete this task? This cannot be undone.',
|
||||
task_save_error: 'Failed to save',
|
||||
logs_title: 'Logs',
|
||||
logs_desc: 'Real-time log output (run.log)',
|
||||
logs_live: 'Live',
|
||||
logs_connecting: 'Log streaming will connect shortly...',
|
||||
status_starting: 'Starting CowAgent...',
|
||||
status_starting_desc: 'Initializing the client, please wait',
|
||||
status_error: 'Initialization Failed',
|
||||
status_error_desc: 'Failed to initialize the client, please retry',
|
||||
status_retry: 'Retry',
|
||||
},
|
||||
}
|
||||
|
||||
export type Lang = 'zh' | 'en'
|
||||
|
||||
// First-run default: follow the OS language so zh-* systems start in Chinese
|
||||
// and everyone else in English. Once the user picks a language it's persisted
|
||||
// in cow_lang and always wins. Falls back to 'en' when the locale is unknown.
|
||||
function detectDefaultLang(): Lang {
|
||||
const locale = (window.electronAPI?.systemLocale || navigator.language || '').toLowerCase()
|
||||
return locale.startsWith('zh') ? 'zh' : 'en'
|
||||
}
|
||||
|
||||
const savedLang = localStorage.getItem('cow_lang') as Lang | null
|
||||
let currentLang: Lang = savedLang === 'zh' || savedLang === 'en' ? savedLang : detectDefaultLang()
|
||||
|
||||
export function t(key: string): string {
|
||||
return translations[currentLang]?.[key] || translations['en']?.[key] || key
|
||||
}
|
||||
|
||||
export function getLang(): Lang {
|
||||
return currentLang
|
||||
}
|
||||
|
||||
export function setLang(lang: Lang) {
|
||||
currentLang = lang
|
||||
localStorage.setItem('cow_lang', lang)
|
||||
}
|
||||
|
||||
/** Resolve a possibly-localized label ({zh,en} or plain string) for the current language. */
|
||||
export function localizedLabel(label: string | { zh: string; en: string } | undefined): string {
|
||||
if (!label) return ''
|
||||
if (typeof label === 'string') return label
|
||||
return label[currentLang] || label.en || label.zh || ''
|
||||
}
|
||||
338
desktop/src/renderer/src/index.css
Normal file
338
desktop/src/renderer/src/index.css
Normal file
@@ -0,0 +1,338 @@
|
||||
@import './highlight.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ============================================================
|
||||
Design tokens — semantic CSS variables driving both themes.
|
||||
Components reference these via Tailwind semantic classes
|
||||
(bg-surface, text-primary, border-default, etc.), so theme
|
||||
switching only swaps variable values, never component code.
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* Brand accent (CowAgent green), used sparingly */
|
||||
--accent: #4abe6e;
|
||||
--accent-hover: #35a85b;
|
||||
--accent-active: #228547;
|
||||
--accent-soft: rgba(74, 190, 110, 0.12);
|
||||
--accent-contrast: #ffffff;
|
||||
|
||||
/* Status colors */
|
||||
--success: #4abe6e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--danger-soft: rgba(239, 68, 68, 0.1);
|
||||
--danger-border: rgba(239, 68, 68, 0.3);
|
||||
--info: #3b82f6;
|
||||
|
||||
/* Light theme — layered neutral surfaces */
|
||||
--bg-base: #fafafa; /* app background */
|
||||
--bg-surface: #ffffff; /* panels, cards */
|
||||
--bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */
|
||||
--bg-elevated: #ffffff; /* popovers, menus, modals */
|
||||
--bg-inset: #f4f4f5; /* inputs, code blocks */
|
||||
|
||||
--text-primary: #18181b; /* headings, primary text (contrast > 4.5:1) */
|
||||
--text-secondary: #52525b; /* body, labels */
|
||||
--text-tertiary: #71717a; /* hints, captions */
|
||||
--text-disabled: #a1a1aa;
|
||||
|
||||
--border-default: #e4e4e7;
|
||||
--border-strong: #d4d4d8;
|
||||
--border-subtle: #f0f0f1;
|
||||
|
||||
--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-md: 0 2px 8px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
|
||||
/* Chat-specific tokens (AI-Native UI) */
|
||||
--user-bubble-bg: var(--accent-soft);
|
||||
--ai-bubble-bg: transparent;
|
||||
--message-gap: 16px;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme — layered greys instead of harsh pure black */
|
||||
--bg-base: #0e0e10;
|
||||
--bg-surface: #161618;
|
||||
--bg-surface-2: #1c1c1f;
|
||||
--bg-elevated: #1f1f23;
|
||||
--bg-inset: #161618;
|
||||
|
||||
--text-primary: #f4f4f5; /* contrast > 7:1 on bg-base */
|
||||
--text-secondary: #c4c4cc;
|
||||
--text-tertiary: #8e8e96;
|
||||
--text-disabled: #5a5a62;
|
||||
|
||||
--border-default: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.14);
|
||||
--border-subtle: rgba(255, 255, 255, 0.04);
|
||||
|
||||
--accent-contrast: #0e0e10;
|
||||
|
||||
--overlay: rgba(0, 0, 0, 0.6);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--user-bubble-bg: rgba(74, 190, 110, 0.16);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Base
|
||||
============================================================ */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Smooth theme transition (respect reduced motion) */
|
||||
body,
|
||||
body * {
|
||||
transition-property: background-color, border-color, color, fill, stroke;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Scrollbar — thin, low-key, theme-aware
|
||||
============================================================ */
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* Windows scrollbars are slightly wider/more visible */
|
||||
.platform-win ::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Titlebar drag regions (frameless window)
|
||||
============================================================ */
|
||||
|
||||
.titlebar-drag {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.titlebar-no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Animations
|
||||
============================================================ */
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-blink {
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
/* AI typing indicator — 3-dot pulse */
|
||||
@keyframes typingPulse {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.typing-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 9999px;
|
||||
background: var(--text-tertiary);
|
||||
animation: typingPulse 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.typing-dot:nth-child(2) { animation-delay: 0.16s; }
|
||||
.typing-dot:nth-child(3) { animation-delay: 0.32s; }
|
||||
|
||||
/* Smooth content reveal */
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-reveal {
|
||||
animation: fadeInUp 0.25s ease both;
|
||||
}
|
||||
|
||||
/* Skeleton shimmer */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-surface-2) 25%,
|
||||
var(--border-subtle) 50%,
|
||||
var(--bg-surface-2) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Markdown content (assistant messages, memory/knowledge viewers)
|
||||
============================================================ */
|
||||
|
||||
.msg-content > *:first-child { margin-top: 0; }
|
||||
.msg-content > *:last-child { margin-bottom: 0; }
|
||||
.msg-content p { margin: 0.5em 0; line-height: 1.7; }
|
||||
.msg-content ul, .msg-content ol { padding-left: 1.4em; margin: 0.5em 0; }
|
||||
.msg-content li { margin: 0.25em 0; }
|
||||
.msg-content h1, .msg-content h2, .msg-content h3, .msg-content h4 {
|
||||
font-weight: 600;
|
||||
margin: 0.8em 0 0.4em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.msg-content h1 { font-size: 1.4em; }
|
||||
.msg-content h2 { font-size: 1.25em; }
|
||||
.msg-content h3 { font-size: 1.1em; }
|
||||
|
||||
.msg-content a { color: var(--accent); text-decoration: none; }
|
||||
.msg-content a:hover { text-decoration: underline; }
|
||||
|
||||
.msg-content blockquote {
|
||||
padding-left: 0.9em;
|
||||
margin: 0.6em 0;
|
||||
border-left: 3px solid var(--accent);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.msg-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 0.8em 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.msg-content th, .msg-content td {
|
||||
border: 1px solid var(--border-default);
|
||||
padding: 0.4em 0.7em;
|
||||
text-align: left;
|
||||
}
|
||||
.msg-content th { background: var(--bg-surface-2); font-weight: 600; }
|
||||
|
||||
.msg-content hr { border: none; border-top: 1px solid var(--border-default); margin: 1em 0; }
|
||||
|
||||
/* Inline code */
|
||||
.msg-content :not(pre) > code {
|
||||
padding: 0.12em 0.4em;
|
||||
border-radius: 5px;
|
||||
background: var(--bg-inset);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Fenced code blocks */
|
||||
.msg-content .code-block-wrapper {
|
||||
margin: 0.7em 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.msg-content .code-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 5px 10px 5px 12px;
|
||||
background: var(--bg-surface-2);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.msg-content .code-block-lang {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: lowercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.msg-content .code-copy-btn {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.msg-content .code-copy-btn:hover { color: var(--text-secondary); background: var(--bg-inset); }
|
||||
.msg-content .code-copy-btn.copied { color: var(--accent); }
|
||||
.msg-content .code-block-wrapper pre {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
overflow-x: auto;
|
||||
background: transparent;
|
||||
}
|
||||
.msg-content .code-block-wrapper pre code,
|
||||
.msg-content pre code.hljs {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
142
desktop/src/renderer/src/layout/NavRail.tsx
Normal file
142
desktop/src/renderer/src/layout/NavRail.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
MessageSquare,
|
||||
BookOpen,
|
||||
Brain,
|
||||
Zap,
|
||||
Radio,
|
||||
Clock,
|
||||
Settings,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Sun,
|
||||
Moon,
|
||||
ScrollText,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { t, getLang, setLang, Lang } from '../i18n'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import UpdateBanner from '../components/UpdateBanner'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
labelKey: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ path: '/', labelKey: 'menu_chat', icon: MessageSquare },
|
||||
{ path: '/knowledge', labelKey: 'menu_knowledge', icon: BookOpen },
|
||||
{ path: '/memory', labelKey: 'menu_memory', icon: Brain },
|
||||
{ path: '/skills', labelKey: 'menu_skills', icon: Zap },
|
||||
{ path: '/channels', labelKey: 'menu_channels', icon: Radio },
|
||||
{ path: '/tasks', labelKey: 'menu_tasks', icon: Clock },
|
||||
{ path: '/settings', labelKey: 'menu_settings', icon: Settings },
|
||||
]
|
||||
|
||||
interface NavRailProps {
|
||||
onLangChange: () => void
|
||||
}
|
||||
|
||||
const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { navCollapsed, toggleNav } = useUIStore()
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
|
||||
const collapsed = navCollapsed
|
||||
const width = collapsed ? 'w-[56px]' : 'w-[208px]'
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
|
||||
setLang(next)
|
||||
onLangChange()
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}>
|
||||
{/* Top: full-width drag strip; reserve space for macOS traffic lights.
|
||||
No right border here so the divider doesn't cut across the traffic lights. */}
|
||||
<div className="titlebar-drag h-[44px] flex-shrink-0" />
|
||||
|
||||
{/* Content area carries the right divider, starting below the titlebar */}
|
||||
<div className="flex-1 flex flex-col min-h-0 border-r border-default">
|
||||
{/* Nav items */}
|
||||
<nav className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = location.pathname === item.path
|
||||
return (
|
||||
<button
|
||||
key={item.path}
|
||||
onClick={() => navigate(item.path)}
|
||||
title={collapsed ? t(item.labelKey) : undefined}
|
||||
className={`group w-full flex items-center gap-3 rounded-btn cursor-pointer transition-colors h-9 ${
|
||||
collapsed ? 'justify-center px-0' : 'px-3'
|
||||
} ${
|
||||
isActive
|
||||
? 'bg-accent-soft text-accent'
|
||||
: 'text-content-secondary hover:bg-surface-2 hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Icon size={18} strokeWidth={isActive ? 2.2 : 1.8} className="flex-shrink-0" />
|
||||
{!collapsed && <span className="text-[13px] truncate">{t(item.labelKey)}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Update banner floats above the footer when a new version is pending */}
|
||||
<div className="relative">
|
||||
{!collapsed && <UpdateBanner />}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className={`flex-shrink-0 px-2 py-2 border-t border-subtle ${collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}`}>
|
||||
<FooterBtn
|
||||
collapsed={collapsed}
|
||||
onClick={() => navigate('/logs')}
|
||||
title={t('menu_logs')}
|
||||
active={location.pathname === '/logs'}
|
||||
>
|
||||
<ScrollText size={17} />
|
||||
</FooterBtn>
|
||||
<FooterBtn collapsed={collapsed} onClick={toggleTheme} title={theme === 'dark' ? 'Light' : 'Dark'}>
|
||||
{theme === 'dark' ? <Sun size={17} /> : <Moon size={17} />}
|
||||
</FooterBtn>
|
||||
<FooterBtn collapsed={collapsed} onClick={toggleLanguage} title="Language">
|
||||
<span className="text-[13px] font-medium w-[18px] text-center">{getLang() === 'zh' ? 'EN' : '中'}</span>
|
||||
</FooterBtn>
|
||||
<div className={collapsed ? '' : 'flex-1'} />
|
||||
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
|
||||
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
|
||||
</FooterBtn>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
const FooterBtn: React.FC<{
|
||||
collapsed: boolean
|
||||
onClick: () => void
|
||||
title: string
|
||||
active?: boolean
|
||||
children: React.ReactNode
|
||||
}> = ({ collapsed, onClick, title, active, children }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`inline-flex items-center gap-1.5 rounded-btn cursor-pointer transition-colors ${
|
||||
active
|
||||
? 'bg-accent-soft text-accent'
|
||||
: 'text-content-tertiary hover:text-content hover:bg-surface-2'
|
||||
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2'}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
export default NavRail
|
||||
180
desktop/src/renderer/src/layout/SessionList.tsx
Normal file
180
desktop/src/renderer/src/layout/SessionList.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Plus, MessageSquare, Pencil, Trash2, Check, X, PanelLeftClose } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import { useSessionStore } from '../store/sessionStore'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
import type { SessionItem } from '../types'
|
||||
|
||||
function groupByTime(sessions: SessionItem[]): { label: string; items: SessionItem[] }[] {
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
||||
const startOfYesterday = startOfToday - 86400
|
||||
|
||||
const today: SessionItem[] = []
|
||||
const yesterday: SessionItem[] = []
|
||||
const earlier: SessionItem[] = []
|
||||
|
||||
for (const s of sessions) {
|
||||
const ts = s.last_active || s.created_at
|
||||
if (ts >= startOfToday) today.push(s)
|
||||
else if (ts >= startOfYesterday) yesterday.push(s)
|
||||
else earlier.push(s)
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: t('session_today'), items: today },
|
||||
{ label: t('session_yesterday'), items: yesterday },
|
||||
{ label: t('session_earlier'), items: earlier },
|
||||
].filter((g) => g.items.length > 0)
|
||||
}
|
||||
|
||||
const SessionList: React.FC = () => {
|
||||
const { sessions, activeId, loading, loadSessions, loadMore, hasMore, setActive, newSession, rename, remove } =
|
||||
useSessionStore()
|
||||
const toggleSessions = useUIStore((s) => s.toggleSessions)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions(1)
|
||||
}, [loadSessions])
|
||||
|
||||
const groups = useMemo(() => groupByTime(sessions), [sessions])
|
||||
|
||||
const startEdit = (s: SessionItem) => {
|
||||
setEditingId(s.session_id)
|
||||
setEditValue(s.title || '')
|
||||
}
|
||||
|
||||
const commitEdit = async () => {
|
||||
if (editingId && editValue.trim()) {
|
||||
await rename(editingId, editValue.trim())
|
||||
}
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[240px] flex-shrink-0 flex flex-col h-full bg-surface border-r border-default">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-2 h-[44px] flex-shrink-0 titlebar-drag">
|
||||
<button
|
||||
onClick={toggleSessions}
|
||||
title={t('nav_collapse')}
|
||||
className="titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<PanelLeftClose size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => newSession()}
|
||||
title={t('session_new')}
|
||||
className="titlebar-no-drag inline-flex items-center gap-1.5 px-2.5 h-7 rounded-btn text-[12px] font-medium text-accent hover:bg-accent-soft cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus size={15} />
|
||||
{t('session_new')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-2 pb-2"
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 80 && hasMore && !loading) loadMore()
|
||||
}}
|
||||
>
|
||||
{sessions.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
|
||||
<MessageSquare size={22} className="text-content-disabled mb-2" />
|
||||
<p className="text-xs text-content-tertiary">{t('session_empty')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((group) => (
|
||||
<div key={group.label} className="mb-2">
|
||||
<div className="px-2 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-content-disabled">
|
||||
{group.label}
|
||||
</div>
|
||||
{group.items.map((s) => {
|
||||
const isActive = s.session_id === activeId
|
||||
const isEditing = editingId === s.session_id
|
||||
return (
|
||||
<div
|
||||
key={s.session_id}
|
||||
onClick={() => !isEditing && setActive(s.session_id)}
|
||||
className={`group flex items-center gap-2 px-2 h-9 rounded-btn cursor-pointer transition-colors ${
|
||||
isActive ? 'bg-accent-soft' : 'hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitEdit()
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex-1 min-w-0 bg-inset border border-strong rounded px-1.5 py-0.5 text-[13px] text-content focus:outline-none focus:border-accent"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`flex-1 min-w-0 truncate text-[13px] ${
|
||||
isActive ? 'text-accent font-medium' : 'text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{s.title || s.session_id}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<IconBtn onClick={(e) => { e.stopPropagation(); commitEdit() }}><Check size={13} /></IconBtn>
|
||||
<IconBtn onClick={(e) => { e.stopPropagation(); setEditingId(null) }}><X size={13} /></IconBtn>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<IconBtn onClick={(e) => { e.stopPropagation(); startEdit(s) }} title={t('session_rename')}>
|
||||
<Pencil size={13} />
|
||||
</IconBtn>
|
||||
<IconBtn onClick={(e) => { e.stopPropagation(); remove(s.session_id) }} title={t('session_delete')} danger>
|
||||
<Trash2 size={13} />
|
||||
</IconBtn>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{loading && (
|
||||
<div className="px-2 py-2 space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="skeleton h-7 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const IconBtn: React.FC<{
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
title?: string
|
||||
danger?: boolean
|
||||
children: React.ReactNode
|
||||
}> = ({ onClick, title, danger, children }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`inline-flex items-center justify-center w-6 h-6 rounded cursor-pointer transition-colors text-content-tertiary ${
|
||||
danger ? 'hover:text-danger hover:bg-danger-soft' : 'hover:text-content hover:bg-surface'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
export default SessionList
|
||||
42
desktop/src/renderer/src/layout/WindowControls.tsx
Normal file
42
desktop/src/renderer/src/layout/WindowControls.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Minus, Square, Copy, X } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Custom window controls for the frameless Windows titlebar.
|
||||
* On macOS the system renders traffic lights, so this returns null there.
|
||||
*/
|
||||
const WindowControls: React.FC = () => {
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
const api = window.electronAPI
|
||||
|
||||
useEffect(() => {
|
||||
api?.windowIsMaximized().then(setMaximized)
|
||||
const off = api?.onMaximizeChange(setMaximized)
|
||||
return off
|
||||
}, [api])
|
||||
|
||||
if (api?.platform === 'darwin') return null
|
||||
|
||||
const btn =
|
||||
'titlebar-no-drag inline-flex items-center justify-center w-11 h-full text-content-tertiary hover:text-content cursor-pointer transition-colors'
|
||||
|
||||
return (
|
||||
<div className="flex items-stretch h-full">
|
||||
<button className={`${btn} hover:bg-surface-2`} onClick={() => api?.windowMinimize()} aria-label="Minimize">
|
||||
<Minus size={15} strokeWidth={2} />
|
||||
</button>
|
||||
<button className={`${btn} hover:bg-surface-2`} onClick={() => api?.windowMaximize()} aria-label="Maximize">
|
||||
{maximized ? <Copy size={12} strokeWidth={2} /> : <Square size={12} strokeWidth={2} />}
|
||||
</button>
|
||||
<button
|
||||
className={`${btn} hover:bg-danger hover:text-white`}
|
||||
onClick={() => api?.windowClose()}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WindowControls
|
||||
13
desktop/src/renderer/src/main.tsx
Normal file
13
desktop/src/renderer/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</React.StrictMode>
|
||||
)
|
||||
271
desktop/src/renderer/src/pages/ChannelsPage.tsx
Normal file
271
desktop/src/renderer/src/pages/ChannelsPage.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, Plug, QrCode } from 'lucide-react'
|
||||
import { t, localizedLabel } from '../i18n'
|
||||
import apiClient from '../api/client'
|
||||
import type { ChannelInfo, ChannelField } from '../types'
|
||||
import { Toggle, Btn } from './settings/primitives'
|
||||
import QrLoginModal from '../components/QrLoginModal'
|
||||
|
||||
// Channels that connect via QR scanning rather than credential fields.
|
||||
const QR_PROVIDERS: Record<string, 'weixin' | 'feishu'> = { weixin: 'weixin', feishu: 'feishu' }
|
||||
|
||||
interface ChannelsPageProps {
|
||||
baseUrl: string
|
||||
}
|
||||
|
||||
// A masked secret looks like "abcd****wxyz"; the backend skips such values.
|
||||
const MASK_RE = /\*{2,}/
|
||||
|
||||
const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||
const [channels, setChannels] = useState<ChannelInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadChannels = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await apiClient.getChannels()
|
||||
setChannels(data || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load channels:', err)
|
||||
setChannels([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.setBaseUrl(baseUrl)
|
||||
void loadChannels()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [baseUrl])
|
||||
|
||||
const { connected, available } = useMemo(() => {
|
||||
const connected = channels.filter((c) => c.active)
|
||||
const available = channels.filter((c) => !c.active)
|
||||
return { connected, available }
|
||||
}, [channels])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-6 pt-5 pb-3 flex-shrink-0">
|
||||
<h2 className="text-xl font-bold text-content">{t('channels_title')}</h2>
|
||||
<p className="text-xs text-content-tertiary mt-1">{t('channels_desc')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto border-t border-default">
|
||||
<div className="max-w-3xl mx-auto px-6 py-5">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20 text-content-tertiary">
|
||||
<Loader2 size={18} className="animate-spin mr-2" />
|
||||
{t('channels_loading')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<Section title={t('channels_connected_section')}>
|
||||
{connected.length === 0 ? (
|
||||
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
|
||||
) : (
|
||||
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{available.length > 0 && (
|
||||
<Section title={t('channels_available_section')}>
|
||||
{available.map((ch) => (
|
||||
<ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-content-tertiary mb-2">{title}</h3>
|
||||
<div className="space-y-3">{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = ({ channel, onChanged }) => {
|
||||
// Channels with no fields connect purely via QR (e.g. weixin).
|
||||
const isQrLogin = channel.fields.length === 0
|
||||
// QR provider supported by the desktop scan modal (weixin / feishu).
|
||||
const qrProvider = QR_PROVIDERS[channel.name]
|
||||
const [showQr, setShowQr] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(channel.fields.map((f) => [f.key, f.value != null ? String(f.value) : '']))
|
||||
)
|
||||
// Track which secret fields still hold the server-provided mask.
|
||||
const [masked, setMasked] = useState<Record<string, boolean>>(() =>
|
||||
Object.fromEntries(
|
||||
channel.fields.map((f) => [f.key, f.type === 'secret' && !!f.value && MASK_RE.test(String(f.value))])
|
||||
)
|
||||
)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [status, setStatus] = useState('')
|
||||
|
||||
const setField = (key: string, val: string) => setValues((p) => ({ ...p, [key]: val }))
|
||||
|
||||
// Only send fields the user actually changed; masked secrets are skipped so
|
||||
// the backend keeps the stored value (mirrors the web console behavior).
|
||||
const buildConfig = (): Record<string, unknown> => {
|
||||
const cfg: Record<string, unknown> = {}
|
||||
channel.fields.forEach((f) => {
|
||||
const v = values[f.key]
|
||||
if (f.type === 'secret' && masked[f.key]) return
|
||||
if (v === '' || v == null) return
|
||||
cfg[f.key] = f.type === 'number' ? Number(v) : v
|
||||
})
|
||||
return cfg
|
||||
}
|
||||
|
||||
const run = async (action: 'save' | 'connect' | 'disconnect') => {
|
||||
setBusy(true)
|
||||
setStatus('')
|
||||
try {
|
||||
const cfg = action === 'disconnect' ? undefined : buildConfig()
|
||||
const res = await apiClient.channelAction(action, channel.name, cfg)
|
||||
if (res.status === 'success') {
|
||||
if (action === 'save') {
|
||||
setStatus(t('channels_save_ok'))
|
||||
setTimeout(() => setStatus(''), 1600)
|
||||
}
|
||||
onChanged()
|
||||
} else {
|
||||
setStatus((res.message as string) || t(action === 'connect' ? 'channels_connect_error' : 'channels_save_error'))
|
||||
}
|
||||
} catch {
|
||||
setStatus(t(action === 'connect' ? 'channels_connect_error' : 'channels_save_error'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-default bg-surface p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-inset flex items-center justify-center flex-shrink-0">
|
||||
{isQrLogin ? <QrCode size={16} className="text-content-secondary" /> : <Plug size={16} className="text-content-secondary" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm text-content">{localizedLabel(channel.label)}</span>
|
||||
<span className={`w-2 h-2 rounded-full ${channel.active ? 'bg-accent' : 'bg-content-tertiary'}`} />
|
||||
</div>
|
||||
<p className="text-xs text-content-tertiary font-mono mt-0.5">{channel.name}</p>
|
||||
</div>
|
||||
|
||||
{channel.active ? (
|
||||
<Btn variant="danger" onClick={() => run('disconnect')} disabled={busy}>
|
||||
{t('channels_disconnect')}
|
||||
</Btn>
|
||||
) : qrProvider ? (
|
||||
<Btn variant="primary" onClick={() => setShowQr(true)}>
|
||||
{qrProvider === 'weixin' ? t('channels_scan_login') : t('channels_scan_register')}
|
||||
</Btn>
|
||||
) : isQrLogin ? null : (
|
||||
<Btn variant="ghost" onClick={() => setExpanded((v) => !v)}>
|
||||
{t('channels_add')}
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR-login channels with no desktop support fall back to the web console. */}
|
||||
{isQrLogin && !channel.active && !qrProvider && (
|
||||
<p className="text-xs text-content-tertiary mt-3 pl-12">{t('channels_qr_hint')}</p>
|
||||
)}
|
||||
|
||||
{/* Field-bearing QR channels (feishu) can also be configured manually. */}
|
||||
{!isQrLogin && qrProvider && !channel.active && !expanded && (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
className="text-xs text-content-tertiary hover:text-content-secondary mt-3 pl-12 cursor-pointer transition-colors"
|
||||
>
|
||||
{t('channels_add')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Field editor: always for connected channels with fields, on-demand for available ones. */}
|
||||
{!isQrLogin && (channel.active || expanded) && (
|
||||
<div className="mt-4 space-y-3">
|
||||
{channel.fields.map((f) => (
|
||||
<FieldRow
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={values[f.key] ?? ''}
|
||||
onChange={(v) => setField(f.key, v)}
|
||||
onFocusSecret={() => {
|
||||
if (f.type === 'secret' && masked[f.key]) {
|
||||
setField(f.key, '')
|
||||
setMasked((p) => ({ ...p, [f.key]: false }))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-end gap-3 pt-1">
|
||||
<span className={`text-xs transition-opacity ${status ? 'opacity-100' : 'opacity-0'} ${status === t('channels_save_ok') ? 'text-accent' : 'text-danger'}`}>
|
||||
{status || '\u00a0'}
|
||||
</span>
|
||||
{channel.active ? (
|
||||
<Btn variant="primary" onClick={() => run('save')} disabled={busy}>
|
||||
{t('channels_save')}
|
||||
</Btn>
|
||||
) : (
|
||||
<Btn variant="primary" onClick={() => run('connect')} disabled={busy}>
|
||||
{t('channels_connect')}
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showQr && qrProvider && (
|
||||
<QrLoginModal
|
||||
provider={qrProvider}
|
||||
onClose={() => setShowQr(false)}
|
||||
onConnected={() => {
|
||||
setShowQr(false)
|
||||
onChanged()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FieldRow: React.FC<{
|
||||
field: ChannelField
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
onFocusSecret: () => void
|
||||
}> = ({ field, value, onChange, onFocusSecret }) => {
|
||||
if (field.type === 'bool') {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-content-secondary">{field.label}</span>
|
||||
<Toggle checked={value === 'true' || value === '1'} onChange={(v) => onChange(v ? 'true' : 'false')} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{field.label}</label>
|
||||
<input
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={value}
|
||||
placeholder={field.label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={onFocusSecret}
|
||||
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent font-mono transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChannelsPage
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user