mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-18 20:17:09 +08:00
Compare commits
108 Commits
feat-self-
...
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 | ||
|
|
3bc6e89b74 |
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/
|
build/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.cow.pid
|
.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/
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -15,7 +15,7 @@
|
|||||||
[English] | [<a href="docs/zh/README.md">中文</a>] | [<a href="docs/ja/README.md">日本語</a>]
|
[English] | [<a href="docs/zh/README.md">中文</a>] | [<a href="docs/ja/README.md">日本語</a>]
|
||||||
</p>
|
</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.
|
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.
|
||||||
|
|
||||||
@@ -36,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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [Channels](https://docs.cowagent.ai/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, Telegram, and Slack |
|
||||||
@@ -47,7 +48,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
|
|||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ 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.
|
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.
|
||||||
|
|
||||||
@@ -102,14 +103,14 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
|||||||
|
|
||||||
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
| 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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.1, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [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 | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.6 | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | |
|
||||||
@@ -198,6 +199,10 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
|
|||||||
|
|
||||||
## 🏷 Changelog
|
## 🏷 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.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.
|
> **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.
|
||||||
|
|||||||
@@ -49,6 +49,16 @@ class ChatService:
|
|||||||
agent.model.channel_type = channel_type or ""
|
agent.model.channel_type = channel_type or ""
|
||||||
agent.model.session_id = session_id 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 shared between the event callback and this method
|
||||||
state = _StreamState()
|
state = _StreamState()
|
||||||
|
|
||||||
@@ -199,6 +209,8 @@ class ChatService:
|
|||||||
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
logger.info("[ChatService] Cleared agent message history after executor recovery")
|
||||||
raise
|
raise
|
||||||
finally:
|
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.
|
# Release cancel token to keep the registry bounded.
|
||||||
if session_id:
|
if session_id:
|
||||||
try:
|
try:
|
||||||
@@ -268,10 +280,68 @@ class ChatService:
|
|||||||
# Execute post-process tools
|
# Execute post-process tools
|
||||||
agent._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}")
|
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
|
@staticmethod
|
||||||
def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""):
|
def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from typing import Any
|
|||||||
# Defaults — conservative (see executor module docstring). Disabled by default
|
# Defaults — conservative (see executor module docstring). Disabled by default
|
||||||
# until release; enable via ``self_evolution_enabled``.
|
# until release; enable via ``self_evolution_enabled``.
|
||||||
DEFAULT_ENABLED = False
|
DEFAULT_ENABLED = False
|
||||||
DEFAULT_IDLE_MINUTES = 15
|
DEFAULT_IDLE_MINUTES = 10
|
||||||
DEFAULT_MIN_TURNS = 6
|
DEFAULT_MIN_TURNS = 6
|
||||||
# Max review steps for the isolated evolution agent. Kept small (not exposed as
|
# 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.
|
# config): the review is meant to be cheap and focused, not a long autonomous run.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ remember_scheduled_output, channel_factory) rather than introducing a fork.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -37,8 +38,10 @@ from agent.evolution.prompts import (
|
|||||||
from agent.evolution.record import append_session_evolution
|
from agent.evolution.record import append_session_evolution
|
||||||
|
|
||||||
# Tools the isolated evolution agent is allowed to use. Everything else is
|
# Tools the isolated evolution agent is allowed to use. Everything else is
|
||||||
# withheld so a review pass can only read context and edit memory/skill files.
|
# withheld so a review pass can only read context, run workspace scripts, and
|
||||||
_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "memory_search", "memory_get"}
|
# 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
|
# 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.
|
# background model runs at once. Extra sessions simply wait for the next scan.
|
||||||
@@ -159,22 +162,95 @@ class _WorkspaceWriteGuard:
|
|||||||
return self._inner.execute(args)
|
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:
|
def _guard_tools(tools: list, workspace_dir: str) -> list:
|
||||||
"""Wrap write/edit tools with the workspace guard; leave others as-is."""
|
"""Wrap write/edit/bash tools with workspace guards; leave others as-is."""
|
||||||
guarded = []
|
guarded = []
|
||||||
for t in tools:
|
for t in tools:
|
||||||
if getattr(t, "name", None) in _WRITE_TOOLS:
|
name = getattr(t, "name", None)
|
||||||
|
if name in _WRITE_TOOLS:
|
||||||
guarded.append(_WorkspaceWriteGuard(t, workspace_dir))
|
guarded.append(_WorkspaceWriteGuard(t, workspace_dir))
|
||||||
|
elif name == "bash":
|
||||||
|
guarded.append(_BashWorkspaceGuard(t, workspace_dir))
|
||||||
else:
|
else:
|
||||||
guarded.append(t)
|
guarded.append(t)
|
||||||
return guarded
|
return guarded
|
||||||
|
|
||||||
|
|
||||||
# Workspace subtrees worth watching for evolution-induced changes.
|
# Workspace subtrees worth watching for evolution-induced changes. AGENT.md is
|
||||||
_WATCH_SUBDIRS = ("MEMORY.md", "skills", "knowledge", "output")
|
# 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
|
# Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly
|
||||||
# dream diary, none of which count as a user-facing change signal.
|
# dream diary, none of which count as a user-facing change signal.
|
||||||
_MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution")
|
_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:
|
def _workspace_snapshot(workspace_dir) -> dict:
|
||||||
@@ -195,6 +271,8 @@ def _workspace_snapshot(workspace_dir) -> dict:
|
|||||||
for p in root.rglob("*"):
|
for p in root.rglob("*"):
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
continue
|
continue
|
||||||
|
if p.name in _WATCH_IGNORE_NAMES:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
st = p.stat()
|
st = p.stat()
|
||||||
snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size)
|
snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size)
|
||||||
@@ -269,8 +347,8 @@ def run_evolution_for_session(
|
|||||||
new_messages = all_messages[done:]
|
new_messages = all_messages[done:]
|
||||||
transcript = _build_transcript(new_messages)
|
transcript = _build_transcript(new_messages)
|
||||||
if not transcript.strip():
|
if not transcript.strip():
|
||||||
logger.info(f"[Evolution] session={session_id}: no new messages, skip")
|
# Routine no-op: the per-minute scan hits every idle session. Advance
|
||||||
# Advance the cursor anyway so we don't re-scan the same tail.
|
# the cursor so we don't re-scan the same tail; no log (pure noise).
|
||||||
agent._evo_done_msg_count = total_msgs
|
agent._evo_done_msg_count = total_msgs
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -304,7 +382,11 @@ def run_evolution_for_session(
|
|||||||
today_daily = Path(workspace_dir) / "memory" / "users" / user_id / (
|
today_daily = Path(workspace_dir) / "memory" / "users" / user_id / (
|
||||||
datetime.now().strftime("%Y-%m-%d") + ".md"
|
datetime.now().strftime("%Y-%m-%d") + ".md"
|
||||||
)
|
)
|
||||||
backup_files = [Path(memory_file), today_daily]
|
# 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():
|
if skills_dir.exists():
|
||||||
for skill_md in skills_dir.rglob("SKILL.md"):
|
for skill_md in skills_dir.rglob("SKILL.md"):
|
||||||
# The skill dir is the SKILL.md's parent (or an ancestor for
|
# The skill dir is the SKILL.md's parent (or an ancestor for
|
||||||
@@ -332,17 +414,23 @@ def run_evolution_for_session(
|
|||||||
str(workspace_dir),
|
str(workspace_dir),
|
||||||
)
|
)
|
||||||
review_agent = agent_bridge.create_agent(
|
review_agent = agent_bridge.create_agent(
|
||||||
system_prompt=EVOLUTION_SYSTEM_PROMPT,
|
system_prompt="",
|
||||||
tools=review_tools,
|
tools=review_tools,
|
||||||
description="Self-evolution review agent",
|
description="Self-evolution review agent",
|
||||||
max_steps=cfg.max_steps,
|
max_steps=cfg.max_steps,
|
||||||
workspace_dir=str(workspace_dir),
|
workspace_dir=str(workspace_dir),
|
||||||
skill_manager=getattr(agent, "skill_manager", None),
|
skill_manager=getattr(agent, "skill_manager", None),
|
||||||
memory_manager=getattr(agent, "memory_manager", None),
|
memory_manager=getattr(agent, "memory_manager", None),
|
||||||
enable_skills=False,
|
enable_skills=True,
|
||||||
|
runtime_info=getattr(agent, "runtime_info", None),
|
||||||
)
|
)
|
||||||
# Reuse the live model so it follows the user's configured model.
|
# Reuse the live model so it follows the user's configured model.
|
||||||
review_agent.model = agent.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(
|
logger.info(
|
||||||
f"[Evolution] backup {backup_id} ({_backup_n} files) → running review agent"
|
f"[Evolution] backup {backup_id} ({_backup_n} files) → running review agent"
|
||||||
@@ -355,26 +443,40 @@ def run_evolution_for_session(
|
|||||||
# only looks at messages added after this point (silent or not).
|
# only looks at messages added after this point (silent or not).
|
||||||
agent._evo_done_msg_count = total_msgs
|
agent._evo_done_msg_count = total_msgs
|
||||||
|
|
||||||
if not result or SILENT_TOKEN in result:
|
# 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])")
|
logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Hard gate: an evolution only counts (and only notifies) if a workspace
|
# Anti-nag backstop: if the model wrote a summary but actually changed no
|
||||||
# file ACTUALLY changed. If the model did real work (wrote memory /
|
# watched file, stay silent — never notify about work that didn't happen.
|
||||||
# patched a skill / finished a task) the user is told; if it merely
|
|
||||||
# produced text without changing anything, we stay silent. This is the
|
|
||||||
# key anti-nag rule — no notification unless something was actually done.
|
|
||||||
if not _workspace_changed(workspace_dir, pre_snapshot):
|
if not _workspace_changed(workspace_dir, pre_snapshot):
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[Evolution] ✗ session={session_id}: model produced text but "
|
f"[Evolution] ✗ session={session_id}: text produced but no file "
|
||||||
f"changed no file — treating as silent"
|
f"changed — staying silent"
|
||||||
)
|
)
|
||||||
return False
|
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}")
|
logger.info(f"[Evolution] ✓ session={session_id} evolved:\n{result}")
|
||||||
append_session_evolution(workspace_dir, result, backup_id=backup_id, user_id=user_id)
|
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 an [EVOLUTION] note so the main agent can honor "undo".
|
||||||
_inject_evolution_record(agent_bridge, session_id, channel_type, result, backup_id)
|
_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
|
# 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
|
# change" gate above is the only throttle we need: real evolutions are
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ language (instructed at the end of the prompt).
|
|||||||
|
|
||||||
Design goals (see ref/hermes-agent background_review for inspiration):
|
Design goals (see ref/hermes-agent background_review for inspiration):
|
||||||
- Default to doing NOTHING. Evolution is the exception, not the rule.
|
- Default to doing NOTHING. Evolution is the exception, not the rule.
|
||||||
- Three signal types: memory, skill, unfinished task.
|
- Signal types: skill, unfinished task, memory, knowledge.
|
||||||
- An explicit "do NOT capture" list to avoid self-poisoning over time.
|
- An explicit "do NOT capture" list to avoid self-poisoning over time.
|
||||||
- Generic examples only — never bake in domain-specific business terms.
|
- Generic examples only — never bake in domain-specific business terms.
|
||||||
"""
|
"""
|
||||||
@@ -52,11 +52,13 @@ them. When their signal is clear, act; do not be shy here.
|
|||||||
the relevant skill file under the skills directory and make a small
|
the relevant skill file under the skills directory and make a small
|
||||||
incremental edit so it never recurs.
|
incremental edit so it never recurs.
|
||||||
b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that
|
b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that
|
||||||
no existing skill covers and the user is likely to want again. To create
|
no existing skill covers and the user is likely to want again. Follow the
|
||||||
one, follow the `skill-creator` skill's conventions (read its SKILL.md for
|
`skill-creator` skill's conventions (read its SKILL.md for the required
|
||||||
the required structure) and write the new skill under the workspace
|
structure), then create `skills/<name>/SKILL.md` by WRITING the file
|
||||||
`skills/` directory. Only create when the workflow is genuinely reusable —
|
directly with the write tool — this is the simplest reliable path. (bash
|
||||||
not for a one-off task.
|
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
|
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,
|
cause of a problem lives IN a skill file itself (its instructions, content,
|
||||||
@@ -72,25 +74,34 @@ them. When their signal is clear, act; do not be shy here.
|
|||||||
reply/decision, do NOTHING and stay [SILENT] — do not nag or ping the user.
|
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.
|
You only ever notify the user as a side effect of having actually done work.
|
||||||
|
|
||||||
3. MEMORY — LAST resort, and you are only a SAFETY NET here, not the primary
|
3. MEMORY — RARE, last resort. Default to writing NOTHING here. The main
|
||||||
writer. The main assistant already writes memory DURING the conversation, and
|
assistant already writes memory during the chat, and a nightly pass plus
|
||||||
a nightly pass consolidates daily notes into long-term memory. Prefer fixing
|
context-overflow saves are dedicated safety nets — so memory is almost always
|
||||||
a skill (above) over writing memory whenever the fact belongs in a skill.
|
already covered without you. Skip unless the main assistant clearly missed a
|
||||||
Act ONLY on something the main assistant clearly MISSED that does not belong
|
durable fact that belongs in no skill AND would visibly change future replies.
|
||||||
in any skill.
|
|
||||||
- MEMORY.md is the curated long-term index, auto-loaded into EVERY future
|
- MEMORY.md is the curated long-term index, auto-loaded into EVERY future
|
||||||
conversation. Treat it as precious: writing here is RARE and reserved for
|
conversation. Treat it as precious: edit it in place to CORRECT a wrong
|
||||||
CORRECTING a wrong fact already in MEMORY.md (edit that line in place).
|
fact, or append a new durable preference/decision/lesson — but do so
|
||||||
Do NOT append new entries to MEMORY.md — that is the nightly pass's job.
|
SPARINGLY (a lasting fact, not a passing detail; the nightly pass handles
|
||||||
- For a genuinely important NEW durable fact the chat missed, append ONE
|
routine consolidation).
|
||||||
short bullet to today's `memory/YYYY-MM-DD.md` (not MEMORY.md). When unsure,
|
- For a NEW fact that is important but not yet clearly lasting, append ONE
|
||||||
the daily file is the safe place — but first ask whether this really
|
short bullet to today's `memory/YYYY-MM-DD.md` instead. When unsure, the
|
||||||
belongs in a skill instead.
|
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
|
- Keep it to ONE short bullet. Never write paragraphs, never re-summarize the
|
||||||
conversation, never copy what the main assistant already recorded.
|
conversation, never copy what the main assistant already recorded.
|
||||||
- If it is already captured anywhere (check MEMORY.md AND the daily file
|
- If it is already captured anywhere (check MEMORY.md AND the daily file
|
||||||
first), do NOTHING.
|
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)
|
# Do NOT capture (these poison future behavior)
|
||||||
|
|
||||||
- Environment failures: missing binaries, unset credentials, uninstalled
|
- Environment failures: missing binaries, unset credentials, uninstalled
|
||||||
@@ -119,16 +130,11 @@ them. When their signal is clear, act; do not be shy here.
|
|||||||
|
|
||||||
- Nothing worth evolving -> output exactly `[SILENT]` and nothing else.
|
- Nothing worth evolving -> output exactly `[SILENT]` and nothing else.
|
||||||
- Otherwise, after performing the edits, output a short user-facing summary in
|
- Otherwise, after performing the edits, output a short user-facing summary in
|
||||||
the SAME LANGUAGE the user used in the conversation. Tell the user, briefly:
|
the SAME LANGUAGE the user speaks in the conversation transcript. Write it for an ordinary user, in plain
|
||||||
1) that you just did a self-learning pass,
|
everyday words — NOT a developer report. No need to expose internal details
|
||||||
2) what you learned and what you changed (in plain terms — no need to cite
|
(file names/paths, system mechanics, etc.). Briefly speak directly TO the user, telling them that you just did a self-learning pass,
|
||||||
exact file paths; "remembered X" / "improved the weekly-report skill" is
|
what you learned, and what you changed in THIS pass. Keep it clear and focused on the key changes (a few lines), and let
|
||||||
enough).
|
the user know they can undo it.
|
||||||
Keep it to 1-3 lines. Generic shape (do not copy domain words):
|
|
||||||
"I just did a self-learning pass.
|
|
||||||
- Learned: <what you learned>
|
|
||||||
- Changed: <remembered it / improved the <name> skill / finished <task>>
|
|
||||||
Reply 'undo the last learning' if this is wrong."
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -138,9 +144,6 @@ def build_review_user_message(transcript: str, protected_skills: list = None) ->
|
|||||||
``protected_skills`` lists skill names that must never be edited (built-in
|
``protected_skills`` lists skill names that must never be edited (built-in
|
||||||
skills shipped with the product). Surfaced so the agent avoids them.
|
skills shipped with the product). Surfaced so the agent avoids them.
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
|
||||||
today = datetime.now().strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
protected_note = ""
|
protected_note = ""
|
||||||
if protected_skills:
|
if protected_skills:
|
||||||
names = ", ".join(sorted(protected_skills))
|
names = ", ".join(sorted(protected_skills))
|
||||||
@@ -148,15 +151,19 @@ def build_review_user_message(transcript: str, protected_skills: list = None) ->
|
|||||||
"\n\nPROTECTED skills (built-in — never edit these): "
|
"\n\nPROTECTED skills (built-in — never edit these): "
|
||||||
f"{names}\n"
|
f"{names}\n"
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
from common import i18n
|
||||||
|
lang_name = "中文" if i18n.is_zh() else "English"
|
||||||
|
except Exception:
|
||||||
|
lang_name = "中文"
|
||||||
return (
|
return (
|
||||||
"Here is the conversation transcript that just went idle. Review it per "
|
"Here is the conversation transcript that just went idle. Review it per "
|
||||||
"your instructions and act on any clear signal. Prefer fixing a skill at "
|
"your instructions. Acting is the exception: the main value is fixing or "
|
||||||
"its source over writing memory whenever the fact belongs in a skill.\n"
|
"creating a skill and finishing promised work. Memory and knowledge are "
|
||||||
f"Today is {today}. Only if a fact genuinely belongs in memory (and not "
|
"rare last resorts — stay [SILENT] unless there is a clear, durable signal "
|
||||||
f"in a skill): append one short bullet to the daily file "
|
"not already covered."
|
||||||
f"`memory/{today}.md` for a new fact, or edit MEMORY.md in place to "
|
|
||||||
f"correct an existing wrong fact."
|
|
||||||
f"{protected_note}\n"
|
f"{protected_note}\n"
|
||||||
|
f"The summary should preferably be written in: {lang_name}\n"
|
||||||
"<transcript>\n"
|
"<transcript>\n"
|
||||||
f"{transcript}\n"
|
f"{transcript}\n"
|
||||||
"</transcript>"
|
"</transcript>"
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ def note_user_turn(agent, channel_type: str = "", receiver: str = "") -> None:
|
|||||||
pass
|
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:
|
def start_evolution_trigger(agent_bridge) -> None:
|
||||||
"""Start the idle-scan thread once per process (idempotent)."""
|
"""Start the idle-scan thread once per process (idempotent)."""
|
||||||
if getattr(agent_bridge, "_evolution_trigger_started", False):
|
if getattr(agent_bridge, "_evolution_trigger_started", False):
|
||||||
@@ -105,6 +119,10 @@ def _scan_once(agent_bridge, cfg) -> None:
|
|||||||
sessions = list(getattr(agent_bridge, "agents", {}).items())
|
sessions = list(getattr(agent_bridge, "agents", {}).items())
|
||||||
for session_id, agent in sessions:
|
for session_id, agent in sessions:
|
||||||
try:
|
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)
|
last_active = getattr(agent, "_evo_last_active", 0)
|
||||||
turns = int(getattr(agent, "_evo_turns", 0))
|
turns = int(getattr(agent, "_evo_turns", 0))
|
||||||
# Enough signal = enough turns OR enough context pressure.
|
# Enough signal = enough turns OR enough context pressure.
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ Knowledge file layout (under workspace_root):
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional, Iterable
|
||||||
|
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from config import conf
|
from config import conf
|
||||||
|
from agent.memory.config import MemoryConfig
|
||||||
|
from agent.memory.manager import MemoryManager
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeService:
|
class KnowledgeService:
|
||||||
@@ -25,9 +30,189 @@ class KnowledgeService:
|
|||||||
Operates directly on the filesystem.
|
Operates directly on the filesystem.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, workspace_root: str):
|
PROTECTED_FILES = {"index.md", "log.md"}
|
||||||
self.workspace_root = workspace_root
|
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
|
||||||
self.knowledge_dir = os.path.join(workspace_root, "knowledge")
|
|
||||||
|
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
|
# list — directory tree with stats
|
||||||
@@ -121,15 +306,8 @@ class KnowledgeService:
|
|||||||
:raises ValueError: if path is invalid or escapes knowledge dir
|
:raises ValueError: if path is invalid or escapes knowledge dir
|
||||||
:raises FileNotFoundError: if file does not exist
|
:raises FileNotFoundError: if file does not exist
|
||||||
"""
|
"""
|
||||||
if not rel_path or ".." in rel_path:
|
rel_path, full_path = self._resolve_path(rel_path, kind="document")
|
||||||
raise ValueError("invalid path")
|
if not full_path.is_file():
|
||||||
|
|
||||||
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):
|
|
||||||
raise FileNotFoundError(f"file not found: {rel_path}")
|
raise FileNotFoundError(f"file not found: {rel_path}")
|
||||||
|
|
||||||
with open(full_path, "r", encoding="utf-8") as f:
|
with open(full_path, "r", encoding="utf-8") as f:
|
||||||
@@ -228,13 +406,26 @@ class KnowledgeService:
|
|||||||
result = self.build_graph()
|
result = self.build_graph()
|
||||||
return {"action": action, "code": 200, "message": "success", "payload": result}
|
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:
|
else:
|
||||||
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
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:
|
except ValueError as e:
|
||||||
return {"action": action, "code": 403, "message": str(e), "payload": None}
|
return {"action": action, "code": 403, "message": str(e), "payload": None}
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
return {"action": action, "code": 404, "message": str(e), "payload": None}
|
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:
|
except Exception as e:
|
||||||
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
|
logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}")
|
||||||
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
return {"action": action, "code": 500, "message": str(e), "payload": None}
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ def _is_internal_user_marker(text: str) -> bool:
|
|||||||
return any(t.startswith(m) for m in _SCHEDULED_DISPLAY_MARKERS)
|
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:
|
def _clean_display_text(text: str) -> str:
|
||||||
"""Strip internal markers from assistant text for user-facing display.
|
"""Strip internal markers from assistant text for user-facing display.
|
||||||
|
|
||||||
@@ -306,6 +311,10 @@ def _group_into_display_turns(
|
|||||||
step["result"] = tr.get("result", "")
|
step["result"] = tr.get("result", "")
|
||||||
step["is_error"] = tr.get("is_error", False)
|
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
|
# Clean internal markers from the user-facing assistant text. Applies to
|
||||||
# both the final content and the mirrored content step so the rendered
|
# both the final content and the mirrored content step so the rendered
|
||||||
# bubble shows clean text while the stored message keeps the markers.
|
# bubble shows clean text while the stored message keeps the markers.
|
||||||
@@ -321,6 +330,8 @@ def _group_into_display_turns(
|
|||||||
"steps": steps,
|
"steps": steps,
|
||||||
"created_at": final_ts or (user_row[1] if user_row else 0),
|
"created_at": final_ts or (user_row[1] if user_row else 0),
|
||||||
}
|
}
|
||||||
|
if is_evolution:
|
||||||
|
turn["kind"] = "evolution"
|
||||||
if merged_extras:
|
if merged_extras:
|
||||||
turn["extras"] = merged_extras
|
turn["extras"] = merged_extras
|
||||||
turns.append(turn)
|
turns.append(turn)
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ Supports multiple OpenAI-compatible embedding vendors:
|
|||||||
- dashscope (Aliyun Tongyi text-embedding-v4)
|
- dashscope (Aliyun Tongyi text-embedding-v4)
|
||||||
- doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark)
|
- doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark)
|
||||||
- zhipu (ZhipuAI embedding-3)
|
- zhipu (ZhipuAI embedding-3)
|
||||||
|
- custom (any OpenAI-compatible endpoint)
|
||||||
|
|
||||||
Vendor keys here intentionally match the project's bot_type constants in
|
Vendor keys here intentionally match the project's bot_type constants in
|
||||||
common.const (OPENAI, LINKAI, QWEN_DASHSCOPE, DOUBAO, ZHIPU_AI).
|
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
|
All providers share a single OpenAI-compatible REST client. Vendor-specific
|
||||||
behaviors (truncation, query instruction prefix) are configured via metadata.
|
behaviors (truncation, query instruction prefix) are configured via metadata.
|
||||||
"""
|
"""
|
||||||
@@ -138,6 +142,22 @@ EMBEDDING_VENDORS = {
|
|||||||
"query_instruction": "",
|
"query_instruction": "",
|
||||||
"max_batch_size": 64,
|
"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"]
|
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(
|
return OpenAIEmbeddingProvider(
|
||||||
model=model or meta["default_model"],
|
model=resolved_model,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
api_base=api_base or meta["default_base_url"],
|
api_base=resolved_base,
|
||||||
extra_headers=extra_headers,
|
extra_headers=extra_headers,
|
||||||
dimensions=final_dim,
|
dimensions=final_dim,
|
||||||
supports_dim_param=meta["supports_dim_param"],
|
supports_dim_param=meta["supports_dim_param"],
|
||||||
|
|||||||
@@ -825,9 +825,10 @@ class MemoryStorage:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
def delete_by_path(self, path: str):
|
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:
|
with self._lock:
|
||||||
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
|
self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,))
|
||||||
|
self.conn.execute("DELETE FROM files WHERE path = ?", (path,))
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def get_file_hash(self, path: str) -> Optional[str]:
|
def get_file_hash(self, path: str) -> Optional[str]:
|
||||||
|
|||||||
@@ -462,13 +462,12 @@ class MemoryFlushManager:
|
|||||||
daily_content=daily_content or "(no recent daily records)",
|
daily_content=daily_content or "(no recent daily records)",
|
||||||
)
|
)
|
||||||
from agent.protocol.models import LLMRequest
|
from agent.protocol.models import LLMRequest
|
||||||
# Scale max_tokens based on input size to avoid truncating large MEMORY.md
|
# No output cap: the prompt already keeps MEMORY.md concise (~50
|
||||||
input_chars = len(memory_content) + len(daily_content)
|
# items), so a hard max_tokens would only risk truncating a large
|
||||||
dream_max_tokens = max(2000, min(input_chars, 8000))
|
# rewrite. Let the model use its default output budget.
|
||||||
request = LLMRequest(
|
request = LLMRequest(
|
||||||
messages=[{"role": "user", "content": user_msg}],
|
messages=[{"role": "user", "content": user_msg}],
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=dream_max_tokens,
|
|
||||||
stream=False,
|
stream=False,
|
||||||
system=_dream_system_prompt(),
|
system=_dream_system_prompt(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ class Agent:
|
|||||||
self.workspace_dir = workspace_dir # Workspace directory
|
self.workspace_dir = workspace_dir # Workspace directory
|
||||||
self.enable_skills = enable_skills # Skills enabled flag
|
self.enable_skills = enable_skills # Skills enabled flag
|
||||||
self.runtime_info = runtime_info # Runtime info for dynamic time update
|
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
|
# Initialize skill manager
|
||||||
self.skill_manager = None
|
self.skill_manager = None
|
||||||
@@ -120,15 +125,20 @@ class Agent:
|
|||||||
except Exception:
|
except Exception:
|
||||||
lang = "zh"
|
lang = "zh"
|
||||||
builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang)
|
builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang)
|
||||||
return builder.build(
|
full = builder.build(
|
||||||
tools=self.tools,
|
tools=self.tools,
|
||||||
context_files=context_files,
|
context_files=context_files,
|
||||||
skill_manager=self.skill_manager,
|
skill_manager=self.skill_manager,
|
||||||
memory_manager=self.memory_manager,
|
memory_manager=self.memory_manager,
|
||||||
runtime_info=self.runtime_info,
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to rebuild system prompt, using cached version: {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
|
return self.system_prompt
|
||||||
|
|
||||||
def refresh_skills(self):
|
def refresh_skills(self):
|
||||||
|
|||||||
@@ -1174,10 +1174,21 @@ class AgentStreamExecutor:
|
|||||||
# Set tool context
|
# Set tool context
|
||||||
tool.model = self.model
|
tool.model = self.model
|
||||||
tool.context = self.agent
|
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
|
# Execute tool
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
try:
|
||||||
result: ToolResult = tool.execute_tool(arguments)
|
result: ToolResult = tool.execute_tool(arguments)
|
||||||
|
finally:
|
||||||
|
tool.progress_callback = None
|
||||||
execution_time = time.time() - start_time
|
execution_time = time.time() - start_time
|
||||||
|
|
||||||
result_dict = {
|
result_dict = {
|
||||||
|
|||||||
@@ -34,6 +34,27 @@ class SkillService:
|
|||||||
"""
|
"""
|
||||||
self.manager = skill_manager
|
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
|
# query
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -107,7 +128,7 @@ class SkillService:
|
|||||||
if not files:
|
if not files:
|
||||||
raise ValueError("skill files list is empty")
|
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"
|
tmp_dir = skill_dir + ".tmp"
|
||||||
if os.path.exists(tmp_dir):
|
if os.path.exists(tmp_dir):
|
||||||
@@ -146,7 +167,7 @@ class SkillService:
|
|||||||
raise ValueError("package url is required")
|
raise ValueError("package url is required")
|
||||||
|
|
||||||
url = files[0]["url"]
|
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:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
zip_path = os.path.join(tmp_dir, "package.zip")
|
zip_path = os.path.join(tmp_dir, "package.zip")
|
||||||
@@ -217,7 +238,7 @@ class SkillService:
|
|||||||
if not name:
|
if not name:
|
||||||
raise ValueError("skill name is required")
|
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):
|
if os.path.exists(skill_dir):
|
||||||
shutil.rmtree(skill_dir)
|
shutil.rmtree(skill_dir)
|
||||||
logger.info(f"[SkillService] delete: removed directory {skill_dir}")
|
logger.info(f"[SkillService] delete: removed directory {skill_dir}")
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ class BaseTool:
|
|||||||
description: str = "Base tool"
|
description: str = "Base tool"
|
||||||
params: dict = {} # Store JSON Schema
|
params: dict = {} # Store JSON Schema
|
||||||
model: Optional[Any] = None # LLM model instance, type depends on bot implementation
|
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
|
@classmethod
|
||||||
def get_json_schema(cls) -> dict:
|
def get_json_schema(cls) -> dict:
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ Bash tool - Execute bash commands
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
@@ -19,6 +22,10 @@ class Bash(BaseTool):
|
|||||||
"""Tool for executing bash commands"""
|
"""Tool for executing bash commands"""
|
||||||
|
|
||||||
_IS_WIN = sys.platform == "win32"
|
_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"
|
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.
|
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.
|
||||||
@@ -106,25 +113,35 @@ SAFETY:
|
|||||||
else:
|
else:
|
||||||
logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}")
|
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
|
# On Windows, convert $VAR references to %VAR% for cmd.exe
|
||||||
if self._IS_WIN:
|
if self._IS_WIN:
|
||||||
env["PYTHONIOENCODING"] = "utf-8"
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
command = self._convert_env_vars_for_windows(command, dotenv_vars)
|
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"):
|
if command and not command.strip().lower().startswith("chcp"):
|
||||||
command = f"chcp 65001 >nul 2>&1 && {command}"
|
command = f"chcp 65001 >nul 2>&1 && {command}"
|
||||||
|
|
||||||
result = subprocess.run(
|
try:
|
||||||
|
result = self._run_streaming(
|
||||||
command,
|
command,
|
||||||
shell=True,
|
timeout,
|
||||||
cwd=self.cwd,
|
env,
|
||||||
stdout=subprocess.PIPE,
|
dotenv_vars,
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
encoding="utf-8",
|
|
||||||
errors="replace",
|
|
||||||
timeout=timeout,
|
|
||||||
env=env,
|
|
||||||
)
|
)
|
||||||
|
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] Exit code: {result.returncode}")
|
||||||
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
|
logger.debug(f"[Bash] Stdout length: {len(result.stdout)}")
|
||||||
@@ -236,6 +253,105 @@ SAFETY:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult.fail(f"Error executing command: {str(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:
|
def _get_safety_warning(self, command: str) -> str:
|
||||||
"""
|
"""
|
||||||
Get safety warning for absolutely catastrophic commands only.
|
Get safety warning for absolutely catastrophic commands only.
|
||||||
@@ -293,3 +409,43 @@ SAFETY:
|
|||||||
return m.group(0)
|
return m.group(0)
|
||||||
|
|
||||||
return re.sub(r'\$\{(\w+)\}|\$(\w+)', replace_match, command)
|
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.
|
- fresh: Set `persistent` to false to fall back to a clean context every run.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
from agent.tools.browser.browser_service import BrowserService
|
from agent.tools.browser.browser_service import BrowserService
|
||||||
from common.log import logger
|
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):
|
class BrowserTool(BaseTool):
|
||||||
"""Single tool exposing all browser actions via an 'action' parameter."""
|
"""Single tool exposing all browser actions via an 'action' parameter."""
|
||||||
|
|
||||||
@@ -121,6 +130,61 @@ class BrowserTool(BaseTool):
|
|||||||
BrowserTool._shared_service = self._service
|
BrowserTool._shared_service = self._service
|
||||||
return 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:
|
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||||
action = args.get("action", "").strip().lower()
|
action = args.get("action", "").strip().lower()
|
||||||
if not action:
|
if not action:
|
||||||
@@ -148,6 +212,16 @@ class BrowserTool(BaseTool):
|
|||||||
# Only auto-prepend https:// for bare hosts; preserve file://, about:, data:, etc.
|
# Only auto-prepend https:// for bare hosts; preserve file://, about:, data:, etc.
|
||||||
if "://" not in url and not url.startswith(("about:", "data:")):
|
if "://" not in url and not url.startswith(("about:", "data:")):
|
||||||
url = "https://" + url
|
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)
|
timeout = args.get("timeout", 30000)
|
||||||
service = self._get_service()
|
service = self._get_service()
|
||||||
result = service.navigate(url, timeout=timeout)
|
result = service.navigate(url, timeout=timeout)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Memory get tool
|
|||||||
Allows agents to read specific sections from memory files
|
Allows agents to read specific sections from memory files
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool
|
from agent.tools.base_tool import BaseTool
|
||||||
|
|
||||||
|
|
||||||
@@ -88,7 +90,12 @@ class MemoryGetTool(BaseTool):
|
|||||||
file_path = (workspace_dir / path).resolve()
|
file_path = (workspace_dir / path).resolve()
|
||||||
workspace_resolved = workspace_dir.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")
|
return ToolResult.fail(f"Error: Access denied: path outside workspace")
|
||||||
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
|
|||||||
@@ -182,8 +182,15 @@ class TaskStore:
|
|||||||
if enabled_only:
|
if enabled_only:
|
||||||
task_list = [t for t in task_list if t.get("enabled", True)]
|
task_list = [t for t in task_list if t.get("enabled", True)]
|
||||||
|
|
||||||
# Sort by next_run_at
|
# Sort by enabled status (enabled first), then by next_run_at
|
||||||
task_list.sort(key=lambda t: t.get("next_run_at", float('inf')))
|
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
|
return task_list
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ from .diff import (
|
|||||||
FuzzyMatchResult
|
FuzzyMatchResult
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .url_safety import (
|
||||||
|
validate_url_safe,
|
||||||
|
assert_public_ip
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'truncate_head',
|
'truncate_head',
|
||||||
'truncate_tail',
|
'truncate_tail',
|
||||||
@@ -36,5 +41,7 @@ __all__ = [
|
|||||||
'normalize_for_fuzzy_match',
|
'normalize_for_fuzzy_match',
|
||||||
'fuzzy_find_text',
|
'fuzzy_find_text',
|
||||||
'generate_diff_string',
|
'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
|
import requests
|
||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
|
from agent.tools.utils.url_safety import validate_url_safe
|
||||||
from common import const
|
from common import const
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
DEFAULT_MODEL = const.GPT_41_MINI
|
DEFAULT_MODEL = const.GPT_41_MINI
|
||||||
DEFAULT_TIMEOUT = 60
|
DEFAULT_TIMEOUT = 180
|
||||||
MAX_TOKENS = 1000
|
MAX_TOKENS = 4000
|
||||||
COMPRESS_THRESHOLD = 1_048_576 # 1 MB
|
COMPRESS_THRESHOLD = 1_048_576 # 1 MB
|
||||||
|
|
||||||
SUPPORTED_EXTENSIONS = {
|
SUPPORTED_EXTENSIONS = {
|
||||||
@@ -330,6 +331,12 @@ class Vision(BaseTool):
|
|||||||
- None : unknown provider id, or the bot can't be created.
|
- None : unknown provider id, or the bot can't be created.
|
||||||
Caller falls through to model-name-based routing.
|
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)
|
display_name = _PROVIDER_ID_TO_DISPLAY.get(provider_id)
|
||||||
if not display_name:
|
if not display_name:
|
||||||
return None
|
return None
|
||||||
@@ -595,6 +602,34 @@ class Vision(BaseTool):
|
|||||||
model_override=preferred_model,
|
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,
|
def _call_via_bot(self, model: str, question: str, image_content: dict,
|
||||||
provider: Optional[VisionProvider] = None) -> ToolResult:
|
provider: Optional[VisionProvider] = None) -> ToolResult:
|
||||||
"""
|
"""
|
||||||
@@ -654,6 +689,22 @@ class Vision(BaseTool):
|
|||||||
return api_base
|
return api_base
|
||||||
return api_base.rstrip("/") + "/v1"
|
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:
|
def _build_image_content(self, image: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Build the image_url content block.
|
Build the image_url content block.
|
||||||
@@ -661,6 +712,7 @@ class Vision(BaseTool):
|
|||||||
so every bot backend can consume them without extra downloads.
|
so every bot backend can consume them without extra downloads.
|
||||||
"""
|
"""
|
||||||
if image.startswith(("http://", "https://")):
|
if image.startswith(("http://", "https://")):
|
||||||
|
self._validate_url_safe(image)
|
||||||
return self._download_to_data_url(image)
|
return self._download_to_data_url(image)
|
||||||
|
|
||||||
if not os.path.isfile(image):
|
if not os.path.isfile(image):
|
||||||
|
|||||||
@@ -16,11 +16,15 @@ import requests
|
|||||||
|
|
||||||
from agent.tools.base_tool import BaseTool, ToolResult
|
from agent.tools.base_tool import BaseTool, ToolResult
|
||||||
from agent.tools.utils.truncate import truncate_head, format_size
|
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
|
from common.log import logger
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
|
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 = {
|
DEFAULT_HEADERS = {
|
||||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
"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"):
|
if parsed.scheme not in ("http", "https"):
|
||||||
return ToolResult.fail("Error: Invalid URL (must start with http:// or 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):
|
if _is_document_url(url):
|
||||||
return self._fetch_document(url)
|
return self._fetch_document(url)
|
||||||
|
|
||||||
return self._fetch_webpage(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 ----
|
# ---- Web page fetching ----
|
||||||
|
|
||||||
def _fetch_webpage(self, url: str) -> ToolResult:
|
def _fetch_webpage(self, url: str) -> ToolResult:
|
||||||
"""Fetch and extract readable text from an HTML web page."""
|
"""Fetch and extract readable text from an HTML web page."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = self._safe_get(url)
|
||||||
url,
|
|
||||||
headers=DEFAULT_HEADERS,
|
|
||||||
timeout=DEFAULT_TIMEOUT,
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
return ToolResult.fail(f"Error: Request timed out after {DEFAULT_TIMEOUT}s")
|
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}")
|
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
|
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:
|
except Exception as e:
|
||||||
return ToolResult.fail(f"Error: Failed to fetch URL: {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}")
|
logger.info(f"[WebFetch] Downloading document: {url} -> {local_path}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.get(
|
response = self._safe_get(url, stream=True)
|
||||||
url,
|
|
||||||
headers=DEFAULT_HEADERS,
|
|
||||||
timeout=DEFAULT_TIMEOUT,
|
|
||||||
stream=True,
|
|
||||||
allow_redirects=True,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
content_length = int(response.headers.get("Content-Length", 0))
|
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}")
|
return ToolResult.fail(f"Error: Failed to connect to {parsed.netloc}")
|
||||||
except requests.HTTPError as e:
|
except requests.HTTPError as e:
|
||||||
return ToolResult.fail(f"Error: HTTP {e.response.status_code} for URL: {url}")
|
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:
|
except Exception as e:
|
||||||
self._cleanup_file(local_path)
|
self._cleanup_file(local_path)
|
||||||
return ToolResult.fail(f"Error: Failed to download file: {e}")
|
return ToolResult.fail(f"Error: Failed to download file: {e}")
|
||||||
|
|||||||
17
app.py
17
app.py
@@ -15,6 +15,11 @@ import threading
|
|||||||
|
|
||||||
_channel_mgr = None
|
_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():
|
def get_channel_manager():
|
||||||
return _channel_mgr
|
return _channel_mgr
|
||||||
@@ -75,7 +80,7 @@ class ChannelManager:
|
|||||||
if self._primary_channel is None and channels:
|
if self._primary_channel is None and channels:
|
||||||
self._primary_channel = channels[0][1]
|
self._primary_channel = channels[0][1]
|
||||||
|
|
||||||
if first_start:
|
if first_start and not DESKTOP_MODE:
|
||||||
PluginManager().load_plugins()
|
PluginManager().load_plugins()
|
||||||
|
|
||||||
# Cloud client is optional. It is only started when
|
# Cloud client is optional. It is only started when
|
||||||
@@ -364,9 +369,17 @@ def run():
|
|||||||
_sync_builtin_skills()
|
_sync_builtin_skills()
|
||||||
|
|
||||||
# Kick off MCP server loading in the background so first-message
|
# Kick off MCP server loading in the background so first-message
|
||||||
# latency isn't dominated by npx package downloads.
|
# 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_mcp_tools()
|
||||||
|
|
||||||
|
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()
|
_warmup_scheduler()
|
||||||
|
|
||||||
logger.info(f"[App] Starting channels: {channel_names}")
|
logger.info(f"[App] Starting channels: {channel_names}")
|
||||||
|
|||||||
@@ -515,6 +515,24 @@ class AgentBridge:
|
|||||||
)
|
)
|
||||||
self._trim_in_memory_to_turns(agent, scheduler_keep_turns)
|
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:
|
try:
|
||||||
# Use agent's run_stream method with event handler
|
# Use agent's run_stream method with event handler
|
||||||
response = agent.run_stream(
|
response = agent.run_stream(
|
||||||
@@ -524,6 +542,13 @@ class AgentBridge:
|
|||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
)
|
)
|
||||||
finally:
|
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
|
# Restore original tools
|
||||||
if context and context.get("is_scheduled_task"):
|
if context and context.get("is_scheduled_task"):
|
||||||
agent.tools = original_tools
|
agent.tools = original_tools
|
||||||
@@ -541,7 +566,11 @@ class AgentBridge:
|
|||||||
# Persist new messages generated during this run
|
# Persist new messages generated during this run
|
||||||
if session_id:
|
if session_id:
|
||||||
channel_type = (context.get("channel_type") or "") if context else ""
|
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:
|
if new_messages:
|
||||||
self._persist_messages(session_id, list(new_messages), channel_type)
|
self._persist_messages(session_id, list(new_messages), channel_type)
|
||||||
else:
|
else:
|
||||||
@@ -757,6 +786,48 @@ class AgentBridge:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[AgentBridge] Failed to sync API keys: {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(
|
def _persist_messages(
|
||||||
self, session_id: str, new_messages: list, channel_type: str = ""
|
self, session_id: str, new_messages: list, channel_type: str = ""
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -395,7 +395,13 @@ class AgentInitializer:
|
|||||||
from agent.memory.embedding import EMBEDDING_VENDORS
|
from agent.memory.embedding import EMBEDDING_VENDORS
|
||||||
from config import conf
|
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:
|
if meta is None:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[AgentInitializer] Unknown embedding_provider '{provider_key}'. "
|
f"[AgentInitializer] Unknown embedding_provider '{provider_key}'. "
|
||||||
@@ -414,7 +420,17 @@ class AgentInitializer:
|
|||||||
)
|
)
|
||||||
return None
|
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:
|
try:
|
||||||
cfg_dim = int(conf().get("embedding_dimensions") or 0)
|
cfg_dim = int(conf().get("embedding_dimensions") or 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
@@ -423,7 +439,7 @@ class AgentInitializer:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
provider = create_embedding_provider(
|
provider = create_embedding_provider(
|
||||||
provider=provider_key,
|
provider=resolved_provider_key,
|
||||||
model=model,
|
model=model,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
api_base=api_base,
|
api_base=api_base,
|
||||||
@@ -450,6 +466,17 @@ class AgentInitializer:
|
|||||||
"""Pick the API key for an explicit embedding provider from config."""
|
"""Pick the API key for an explicit embedding provider from config."""
|
||||||
from config import conf
|
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 = {
|
key_map = {
|
||||||
"openai": "open_ai_api_key",
|
"openai": "open_ai_api_key",
|
||||||
"linkai": "linkai_api_key",
|
"linkai": "linkai_api_key",
|
||||||
@@ -470,6 +497,17 @@ class AgentInitializer:
|
|||||||
"""Pick the API base for an explicit embedding provider from config."""
|
"""Pick the API base for an explicit embedding provider from config."""
|
||||||
from config import conf
|
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 = {
|
base_map = {
|
||||||
"openai": "open_ai_api_base",
|
"openai": "open_ai_api_base",
|
||||||
"linkai": "linkai_api_base",
|
"linkai": "linkai_api_base",
|
||||||
@@ -524,6 +562,14 @@ class AgentInitializer:
|
|||||||
logger.debug("[AgentInitializer] WebSearch skipped - no search provider configured")
|
logger.debug("[AgentInitializer] WebSearch skipped - no search provider configured")
|
||||||
continue
|
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
|
# Special handling for EnvConfig tool
|
||||||
if tool_name == "env_config":
|
if tool_name == "env_config":
|
||||||
from agent.tools import EnvConfig
|
from agent.tools import EnvConfig
|
||||||
|
|||||||
@@ -519,7 +519,10 @@ class ChatChannel(Channel):
|
|||||||
def cancel_session(self, session_id):
|
def cancel_session(self, session_id):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if session_id in self.sessions:
|
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()
|
future.cancel()
|
||||||
cnt = self.sessions[session_id][0].qsize()
|
cnt = self.sessions[session_id][0].qsize()
|
||||||
if cnt > 0:
|
if cnt > 0:
|
||||||
@@ -529,7 +532,7 @@ class ChatChannel(Channel):
|
|||||||
def cancel_all_session(self):
|
def cancel_all_session(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
for session_id in self.sessions:
|
for session_id in self.sessions:
|
||||||
for future in self.futures[session_id]:
|
for future in self.futures.get(session_id, []):
|
||||||
future.cancel()
|
future.cancel()
|
||||||
cnt = self.sessions[session_id][0].qsize()
|
cnt = self.sessions[session_id][0].qsize()
|
||||||
if cnt > 0:
|
if cnt > 0:
|
||||||
|
|||||||
@@ -285,7 +285,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Docs Link -->
|
<!-- 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
|
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">
|
cursor-pointer transition-colors duration-150" title="Documentation">
|
||||||
<i class="fas fa-book text-base"></i>
|
<i class="fas fa-book text-base"></i>
|
||||||
@@ -840,6 +840,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<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-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">
|
<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')"
|
<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">
|
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
|
||||||
@@ -995,6 +1000,14 @@
|
|||||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="tasks_title">定时任务</h2>
|
<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>
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="tasks_desc">查看和管理定时任务</p>
|
||||||
</div>
|
</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>
|
||||||
<div id="tasks-empty" class="flex flex-col items-center justify-center py-20">
|
<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">
|
<div class="w-16 h-16 rounded-2xl bg-rose-50 dark:bg-rose-900/20 flex items-center justify-center mb-4">
|
||||||
@@ -1068,6 +1081,34 @@
|
|||||||
</div><!-- /main-content -->
|
</div><!-- /main-content -->
|
||||||
</div><!-- /app -->
|
</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 -->
|
<!-- Confirm Dialog -->
|
||||||
<div id="confirm-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
<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
|
<div class="bg-white dark:bg-[#1A1A1A] rounded-2xl border border-slate-200 dark:border-white/10 shadow-xl
|
||||||
@@ -1165,6 +1206,240 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script defer src="assets/js/console.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -244,6 +244,52 @@
|
|||||||
}
|
}
|
||||||
.dark .session-delete:hover { background: rgba(239, 68, 68, 0.15); }
|
.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 */
|
||||||
.context-divider {
|
.context-divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -605,6 +651,18 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.tool-error-text { color: #f87171; }
|
.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 level highlighting */
|
||||||
.log-line { display: block; }
|
.log-line { display: block; }
|
||||||
@@ -854,6 +912,14 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
flex-shrink: 0;
|
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 */
|
||||||
#chat-input {
|
#chat-input {
|
||||||
@@ -1191,6 +1257,34 @@
|
|||||||
background: #EDFDF3;
|
background: #EDFDF3;
|
||||||
color: #228547;
|
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 {
|
.dark .knowledge-tree-file:hover {
|
||||||
background: rgba(255,255,255,0.06);
|
background: rgba(255,255,255,0.06);
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
@@ -1571,3 +1665,9 @@
|
|||||||
.delete-msg-btn:hover {
|
.delete-msg-btn:hover {
|
||||||
color: #ef4444 !important;
|
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
@@ -24,7 +24,7 @@ from common import const
|
|||||||
from common import i18n
|
from common import i18n
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from common.singleton import singleton
|
from common.singleton import singleton
|
||||||
from config import conf
|
from config import conf, get_data_root, get_weixin_credentials_path
|
||||||
|
|
||||||
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
|
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
|
||||||
VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
|
VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
|
||||||
@@ -360,6 +360,13 @@ class WebChannel(ChatChannel):
|
|||||||
):
|
):
|
||||||
logger.debug(f"Polling skipped duplicate file reply for session {session_id}")
|
logger.debug(f"Polling skipped duplicate file reply for session {session_id}")
|
||||||
return
|
return
|
||||||
|
# SSE-enabled requests already stream the text reply to the
|
||||||
|
# client. Do NOT also enqueue it for polling: if the user
|
||||||
|
# switched away mid-run, the queued copy would resurface as a
|
||||||
|
# duplicate bubble when they return and poll the session.
|
||||||
|
if reply.type == ReplyType.TEXT and context.get("on_event") is not None:
|
||||||
|
logger.debug(f"Polling skipped SSE text reply for session {session_id}")
|
||||||
|
return
|
||||||
response_data = {
|
response_data = {
|
||||||
"type": str(reply.type),
|
"type": str(reply.type),
|
||||||
"content": content,
|
"content": content,
|
||||||
@@ -425,7 +432,15 @@ class WebChannel(ChatChannel):
|
|||||||
elif event_type == "tool_execution_start":
|
elif event_type == "tool_execution_start":
|
||||||
tool_name = data.get("tool_name", "tool")
|
tool_name = data.get("tool_name", "tool")
|
||||||
arguments = data.get("arguments", {})
|
arguments = data.get("arguments", {})
|
||||||
q.put({"type": "tool_start", "tool": tool_name, "arguments": arguments})
|
q.put({"type": "tool_start", "tool_call_id": data.get("tool_call_id"), "tool": tool_name, "arguments": arguments})
|
||||||
|
|
||||||
|
elif event_type == "tool_execution_progress":
|
||||||
|
q.put({
|
||||||
|
"type": "tool_progress",
|
||||||
|
"tool_call_id": data.get("tool_call_id"),
|
||||||
|
"tool": data.get("tool_name", "tool"),
|
||||||
|
"content": str(data.get("message", ""))[-4 * 1024:],
|
||||||
|
})
|
||||||
|
|
||||||
elif event_type == "tool_execution_end":
|
elif event_type == "tool_execution_end":
|
||||||
tool_name = data.get("tool_name", "tool")
|
tool_name = data.get("tool_name", "tool")
|
||||||
@@ -438,6 +453,7 @@ class WebChannel(ChatChannel):
|
|||||||
result_str = result_str[:2000] + "…"
|
result_str = result_str[:2000] + "…"
|
||||||
q.put({
|
q.put({
|
||||||
"type": "tool_end",
|
"type": "tool_end",
|
||||||
|
"tool_call_id": data.get("tool_call_id"),
|
||||||
"tool": tool_name,
|
"tool": tool_name,
|
||||||
"status": status,
|
"status": status,
|
||||||
"result": result_str,
|
"result": result_str,
|
||||||
@@ -940,6 +956,11 @@ class WebChannel(ChatChannel):
|
|||||||
post_done = True
|
post_done = True
|
||||||
post_deadline = time.time() + 2 # 2s post-attach tail
|
post_deadline = time.time() + 2 # 2s post-attach tail
|
||||||
finally:
|
finally:
|
||||||
|
# Only drop the queue once the reply is actually complete. If the
|
||||||
|
# client disconnected early (e.g. switched sessions and will
|
||||||
|
# re-attach with the same request_id), keep the queue so the new
|
||||||
|
# connection can resume reading the remaining events.
|
||||||
|
if post_done or time.time() >= deadline:
|
||||||
self.sse_queues.pop(request_id, None)
|
self.sse_queues.pop(request_id, None)
|
||||||
|
|
||||||
def cancel_request(self):
|
def cancel_request(self):
|
||||||
@@ -1093,6 +1114,9 @@ class WebChannel(ChatChannel):
|
|||||||
else:
|
else:
|
||||||
logger.info(f"[WebChannel] 🔒 Listening on {host} only (local access). For public access, set web_host to 0.0.0.0 and configure web_password")
|
logger.info(f"[WebChannel] 🔒 Listening on {host} only (local access). For public access, set web_host to 0.0.0.0 and configure web_password")
|
||||||
|
|
||||||
|
# In desktop mode the Electron shell renders the UI, so don't pop a
|
||||||
|
# browser window (also avoids issues when running detached/headless).
|
||||||
|
if os.environ.get("COW_DESKTOP") != "1":
|
||||||
try:
|
try:
|
||||||
import webbrowser
|
import webbrowser
|
||||||
webbrowser.open(f"http://localhost:{port}")
|
webbrowser.open(f"http://localhost:{port}")
|
||||||
@@ -1100,11 +1124,15 @@ class WebChannel(ChatChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"[WebChannel] Could not open browser: {e}")
|
logger.debug(f"[WebChannel] Could not open browser: {e}")
|
||||||
|
|
||||||
# 确保静态文件目录存在
|
# Ensure the static dir exists. In a packaged build it ships read-only
|
||||||
|
# inside the bundle, so swallow errors instead of failing startup.
|
||||||
static_dir = os.path.join(os.path.dirname(__file__), 'static')
|
static_dir = os.path.join(os.path.dirname(__file__), 'static')
|
||||||
if not os.path.exists(static_dir):
|
if not os.path.exists(static_dir):
|
||||||
|
try:
|
||||||
os.makedirs(static_dir)
|
os.makedirs(static_dir)
|
||||||
logger.debug(f"[WebChannel] Created static directory: {static_dir}")
|
logger.debug(f"[WebChannel] Created static directory: {static_dir}")
|
||||||
|
except OSError as e:
|
||||||
|
logger.debug(f"[WebChannel] Skipped creating static dir (read-only bundle?): {e}")
|
||||||
|
|
||||||
urls = (
|
urls = (
|
||||||
'/', 'RootHandler',
|
'/', 'RootHandler',
|
||||||
@@ -1133,7 +1161,11 @@ class WebChannel(ChatChannel):
|
|||||||
'/api/knowledge/list', 'KnowledgeListHandler',
|
'/api/knowledge/list', 'KnowledgeListHandler',
|
||||||
'/api/knowledge/read', 'KnowledgeReadHandler',
|
'/api/knowledge/read', 'KnowledgeReadHandler',
|
||||||
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
||||||
|
'/api/knowledge/action', 'KnowledgeActionHandler',
|
||||||
'/api/scheduler', 'SchedulerHandler',
|
'/api/scheduler', 'SchedulerHandler',
|
||||||
|
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
||||||
|
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
||||||
|
'/api/scheduler/delete', 'SchedulerDeleteHandler',
|
||||||
'/api/sessions', 'SessionsHandler',
|
'/api/sessions', 'SessionsHandler',
|
||||||
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
|
'/api/sessions/(.*)/generate_title', 'SessionTitleHandler',
|
||||||
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
|
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
|
||||||
@@ -1447,15 +1479,17 @@ class ChatHandler:
|
|||||||
class ConfigHandler:
|
class ConfigHandler:
|
||||||
|
|
||||||
_RECOMMENDED_MODELS = [
|
_RECOMMENDED_MODELS = [
|
||||||
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO, const.DEEPSEEK_CHAT, const.DEEPSEEK_REASONER,
|
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
||||||
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
||||||
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET,
|
# claude-fable-5 is placed after claude-opus-4-7 (not as the Claude
|
||||||
|
# default) since it is often unavailable due to policy restrictions.
|
||||||
|
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
||||||
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
||||||
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||||
const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||||
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
||||||
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
|
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
|
||||||
const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||||
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
||||||
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
||||||
]
|
]
|
||||||
@@ -1494,7 +1528,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "claude_api_base",
|
"api_base_key": "claude_api_base",
|
||||||
"api_base_default": "https://api.anthropic.com/v1",
|
"api_base_default": "https://api.anthropic.com/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS, const.CLAUDE_4_5_SONNET],
|
"models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||||
}),
|
}),
|
||||||
("gemini", {
|
("gemini", {
|
||||||
"label": "Gemini",
|
"label": "Gemini",
|
||||||
@@ -1518,7 +1552,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "zhipu_ai_api_base",
|
"api_base_key": "zhipu_ai_api_base",
|
||||||
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
|
"api_base_default": "https://open.bigmodel.cn/api/paas/v4",
|
||||||
"api_base_placeholder": _PLACEHOLDER_ZHIPU,
|
"api_base_placeholder": _PLACEHOLDER_ZHIPU,
|
||||||
"models": [const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7],
|
"models": [const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7],
|
||||||
}),
|
}),
|
||||||
("dashscope", {
|
("dashscope", {
|
||||||
"label": {"zh": "通义千问", "en": "Qwen"},
|
"label": {"zh": "通义千问", "en": "Qwen"},
|
||||||
@@ -1542,7 +1576,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "moonshot_base_url",
|
"api_base_key": "moonshot_base_url",
|
||||||
"api_base_default": "https://api.moonshot.cn/v1",
|
"api_base_default": "https://api.moonshot.cn/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
"models": [const.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2],
|
||||||
}),
|
}),
|
||||||
("qianfan", {
|
("qianfan", {
|
||||||
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
"label": {"zh": "百度千帆", "en": "ERNIE"},
|
||||||
@@ -1586,6 +1620,7 @@ class ConfigHandler:
|
|||||||
"open_ai_api_key", "deepseek_api_key", "qianfan_api_key", "claude_api_key", "gemini_api_key",
|
"open_ai_api_key", "deepseek_api_key", "qianfan_api_key", "claude_api_key", "gemini_api_key",
|
||||||
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
|
"zhipu_ai_api_key", "dashscope_api_key", "moonshot_api_key",
|
||||||
"ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key",
|
"ark_api_key", "minimax_api_key", "linkai_api_key", "custom_api_key", "mimo_api_key",
|
||||||
|
"custom_providers",
|
||||||
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
"agent_max_context_tokens", "agent_max_context_turns", "agent_max_steps",
|
||||||
"enable_thinking", "self_evolution_enabled", "web_password",
|
"enable_thinking", "self_evolution_enabled", "web_password",
|
||||||
}
|
}
|
||||||
@@ -1627,6 +1662,32 @@ class ConfigHandler:
|
|||||||
"api_key_field": p.get("api_key_field"),
|
"api_key_field": p.get("api_key_field"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Expose user-defined custom providers as "custom:<id>" entries so
|
||||||
|
# the legacy config page can display and select them. Credentials
|
||||||
|
# are managed on the Models page, hence the null key/base fields.
|
||||||
|
# Mirrors the Models page: when expanded entries exist, the bare
|
||||||
|
# legacy "custom" entry is hidden — unless the flat single-provider
|
||||||
|
# custom config is still active or filled in.
|
||||||
|
try:
|
||||||
|
from models.custom_provider import get_custom_providers
|
||||||
|
custom_list = get_custom_providers()
|
||||||
|
legacy_custom_in_use = ModelsHandler._legacy_custom_in_use(local_config)
|
||||||
|
if custom_list and not legacy_custom_in_use:
|
||||||
|
providers.pop("custom", None)
|
||||||
|
for cp in custom_list:
|
||||||
|
cid = f"custom:{cp.get('id')}"
|
||||||
|
cname = cp.get("name") or cp.get("id")
|
||||||
|
providers[cid] = {
|
||||||
|
"label": {"zh": cname, "en": cname},
|
||||||
|
"models": [cp["model"]] if cp.get("model") else [],
|
||||||
|
"api_base_key": None,
|
||||||
|
"api_base_default": None,
|
||||||
|
"api_base_placeholder": "",
|
||||||
|
"api_key_field": None,
|
||||||
|
}
|
||||||
|
except Exception as cp_err:
|
||||||
|
logger.warning(f"[ConfigHandler] failed to expand custom providers: {cp_err}")
|
||||||
|
|
||||||
raw_pwd = str(local_config.get("web_password", "") or "")
|
raw_pwd = str(local_config.get("web_password", "") or "")
|
||||||
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
masked_pwd = ("*" * len(raw_pwd)) if raw_pwd else ""
|
||||||
|
|
||||||
@@ -1676,8 +1737,7 @@ class ConfigHandler:
|
|||||||
if not applied:
|
if not applied:
|
||||||
return json.dumps({"status": "error", "message": "no valid keys to update"})
|
return json.dumps({"status": "error", "message": "no valid keys to update"})
|
||||||
|
|
||||||
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
config_path = os.path.join(get_data_root(), "config.json")
|
||||||
os.path.abspath(__file__)))), "config.json")
|
|
||||||
if os.path.exists(config_path):
|
if os.path.exists(config_path):
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
file_cfg = json.load(f)
|
file_cfg = json.load(f)
|
||||||
@@ -2008,7 +2068,20 @@ class ModelsHandler:
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
_EMBEDDING_PROVIDERS = ["openai", "dashscope", "doubao", "zhipu", "linkai"]
|
_EMBEDDING_PROVIDERS = ["openai", "dashscope", "doubao", "zhipu", "linkai", "custom"]
|
||||||
|
|
||||||
|
# Embedding model catalog per provider. Mirrors the default_model in
|
||||||
|
# agent/memory/embedding/provider.py::EMBEDDING_VENDORS.
|
||||||
|
# Custom providers have no preset list — model names vary per vendor,
|
||||||
|
# so the user always types the model id manually.
|
||||||
|
_EMBEDDING_PROVIDER_MODELS = {
|
||||||
|
"openai": ["text-embedding-3-small", "text-embedding-3-large"],
|
||||||
|
"dashscope": ["text-embedding-v4"],
|
||||||
|
"doubao": ["doubao-embedding-vision-251215"],
|
||||||
|
"zhipu": ["embedding-3"],
|
||||||
|
"linkai": ["text-embedding-3-small"],
|
||||||
|
"custom": [],
|
||||||
|
}
|
||||||
|
|
||||||
# Capability-scoped model catalogs. The chat dropdown can reuse the
|
# Capability-scoped model catalogs. The chat dropdown can reuse the
|
||||||
# provider's generic model list, but vision and image generation are
|
# provider's generic model list, but vision and image generation are
|
||||||
@@ -2058,6 +2131,9 @@ class ModelsHandler:
|
|||||||
const.CLAUDE_4_6_SONNET,
|
const.CLAUDE_4_6_SONNET,
|
||||||
const.GEMINI_31_FLASH_LITE_PRE,
|
const.GEMINI_31_FLASH_LITE_PRE,
|
||||||
],
|
],
|
||||||
|
# Custom OpenAI-compatible providers have no preset list — model
|
||||||
|
# names vary per vendor, so the user types the model id manually.
|
||||||
|
"custom": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Image-generation catalog. Source of truth: skills/image-generation/SKILL.md.
|
# Image-generation catalog. Source of truth: skills/image-generation/SKILL.md.
|
||||||
@@ -2094,10 +2170,7 @@ class ModelsHandler:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _config_path() -> str:
|
def _config_path() -> str:
|
||||||
return os.path.join(
|
return os.path.join(get_data_root(), "config.json")
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
|
||||||
"config.json",
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _read_file_config(cls) -> dict:
|
def _read_file_config(cls) -> dict:
|
||||||
@@ -2116,13 +2189,99 @@ class ModelsHandler:
|
|||||||
def _is_real_key(value: str) -> bool:
|
def _is_real_key(value: str) -> bool:
|
||||||
return bool(value) and value not in ("", "YOUR API KEY", "YOUR_API_KEY")
|
return bool(value) and value not in ("", "YOUR API KEY", "YOUR_API_KEY")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _custom_provider_cards(cls, local_config: dict) -> List[dict]:
|
||||||
|
"""Expand ``custom_providers`` into one card per provider.
|
||||||
|
|
||||||
|
Each user-defined OpenAI-compatible provider becomes its own card with
|
||||||
|
id ``custom:<id>`` so the frontend can render, edit, delete and
|
||||||
|
activate them independently. The card carries ``is_custom=True`` and
|
||||||
|
``active`` flags that the UI uses to render the extra controls.
|
||||||
|
|
||||||
|
Returns an empty list when no multi-providers are configured, in which
|
||||||
|
case the caller keeps the single legacy ``custom`` card untouched —
|
||||||
|
guaranteeing backward compatibility with the flat
|
||||||
|
``custom_api_key`` / ``custom_api_base`` config.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from models.custom_provider import get_custom_providers, parse_custom_bot_type
|
||||||
|
providers = get_custom_providers()
|
||||||
|
except Exception as e: # pragma: no cover - defensive
|
||||||
|
logger.warning(f"[ModelsHandler] failed to load custom_providers: {e}")
|
||||||
|
providers = []
|
||||||
|
if not providers:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Determine the currently active provider id from bot_type.
|
||||||
|
bot_type = local_config.get("bot_type") or ""
|
||||||
|
_, active_id = parse_custom_bot_type(bot_type)
|
||||||
|
|
||||||
|
meta = ConfigHandler.PROVIDER_MODELS.get("custom") or {}
|
||||||
|
cards = []
|
||||||
|
for p in providers:
|
||||||
|
pid = p.get("id") or ""
|
||||||
|
name = p.get("name") or pid
|
||||||
|
raw_key = p.get("api_key") or ""
|
||||||
|
raw_base = p.get("api_base") or ""
|
||||||
|
configured = cls._is_real_key(raw_key)
|
||||||
|
cards.append({
|
||||||
|
"id": f"custom:{pid}",
|
||||||
|
"label": {"zh": name, "en": name},
|
||||||
|
"configured": configured,
|
||||||
|
"is_custom": True,
|
||||||
|
"custom_id": pid,
|
||||||
|
"custom_name": name,
|
||||||
|
"active": (pid == active_id),
|
||||||
|
"model": p.get("model") or "",
|
||||||
|
# Custom cards are edited via the dedicated set_custom_provider
|
||||||
|
# action, not the field-based set_provider flow, so the field
|
||||||
|
# names are intentionally null.
|
||||||
|
"api_key_field": None,
|
||||||
|
"api_base_field": None,
|
||||||
|
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
|
||||||
|
"api_base": raw_base,
|
||||||
|
"api_base_default": "",
|
||||||
|
"api_base_placeholder": meta.get("api_base_placeholder") or "",
|
||||||
|
"models": [p.get("model")] if p.get("model") else [],
|
||||||
|
})
|
||||||
|
return cards
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _legacy_custom_in_use(cls, local_config: dict) -> bool:
|
||||||
|
"""True when the flat single-provider custom config is still relevant:
|
||||||
|
either it is the active bot_type, or its key/base fields are filled.
|
||||||
|
In that case the legacy "custom" card must stay visible even when
|
||||||
|
multi ``custom_providers`` entries exist."""
|
||||||
|
if (local_config.get("bot_type") or "") == "custom":
|
||||||
|
return True
|
||||||
|
return (cls._is_real_key(local_config.get("custom_api_key") or "")
|
||||||
|
or bool(local_config.get("custom_api_base")))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _provider_overview(cls) -> List[dict]:
|
def _provider_overview(cls) -> List[dict]:
|
||||||
"""All known providers (configured first, unconfigured after).
|
"""All known providers (configured first, unconfigured after).
|
||||||
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list."""
|
Re-uses ConfigHandler.PROVIDER_MODELS for the canonical list.
|
||||||
|
|
||||||
|
When the user has defined multiple custom (OpenAI-compatible)
|
||||||
|
providers via ``custom_providers``, the single built-in ``custom``
|
||||||
|
card is replaced by one card per provider (see
|
||||||
|
``_custom_provider_cards``). Otherwise the legacy single ``custom``
|
||||||
|
card is shown unchanged.
|
||||||
|
"""
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
|
custom_cards = cls._custom_provider_cards(local_config)
|
||||||
|
# Keep the legacy single "custom" card visible alongside the expanded
|
||||||
|
# ones when the flat custom_api_key/base config is active or filled,
|
||||||
|
# so existing single-provider setups never disappear from the UI.
|
||||||
|
keep_legacy_custom = cls._legacy_custom_in_use(local_config)
|
||||||
items = []
|
items = []
|
||||||
for pid, p in ConfigHandler.PROVIDER_MODELS.items():
|
for pid, p in ConfigHandler.PROVIDER_MODELS.items():
|
||||||
|
if pid == "custom" and custom_cards:
|
||||||
|
# Multi-provider mode: emit the expanded cards, plus the
|
||||||
|
# legacy card when it is still in use.
|
||||||
|
items.extend(custom_cards)
|
||||||
|
if not keep_legacy_custom:
|
||||||
|
continue
|
||||||
key_field = p.get("api_key_field")
|
key_field = p.get("api_key_field")
|
||||||
base_field = p.get("api_base_key")
|
base_field = p.get("api_base_key")
|
||||||
raw_key = local_config.get(key_field, "") if key_field else ""
|
raw_key = local_config.get(key_field, "") if key_field else ""
|
||||||
@@ -2132,6 +2291,7 @@ class ModelsHandler:
|
|||||||
"id": pid,
|
"id": pid,
|
||||||
"label": p["label"],
|
"label": p["label"],
|
||||||
"configured": configured,
|
"configured": configured,
|
||||||
|
"is_custom": (pid == "custom"),
|
||||||
"api_key_field": key_field,
|
"api_key_field": key_field,
|
||||||
"api_base_field": base_field,
|
"api_base_field": base_field,
|
||||||
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
|
"api_key_masked": ConfigHandler._mask_key(raw_key) if configured else "",
|
||||||
@@ -2140,7 +2300,19 @@ class ModelsHandler:
|
|||||||
"api_base_placeholder": p.get("api_base_placeholder") or "",
|
"api_base_placeholder": p.get("api_base_placeholder") or "",
|
||||||
"models": list(p.get("models") or []),
|
"models": list(p.get("models") or []),
|
||||||
})
|
})
|
||||||
items.sort(key=lambda it: (0 if it["configured"] else 1, list(ConfigHandler.PROVIDER_MODELS.keys()).index(it["id"])))
|
|
||||||
|
def _sort_key(it):
|
||||||
|
pid = it["id"]
|
||||||
|
# Custom expanded cards share the sort weight of the base "custom"
|
||||||
|
# entry so they cluster where the single custom card used to be.
|
||||||
|
base_id = "custom" if it.get("is_custom") else pid
|
||||||
|
try:
|
||||||
|
order = list(ConfigHandler.PROVIDER_MODELS.keys()).index(base_id)
|
||||||
|
except ValueError:
|
||||||
|
order = len(ConfigHandler.PROVIDER_MODELS)
|
||||||
|
return (0 if it["configured"] else 1, order)
|
||||||
|
|
||||||
|
items.sort(key=_sort_key)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -2148,13 +2320,28 @@ class ModelsHandler:
|
|||||||
"""Main chat model — drives the agent. bot_type maps to a provider id."""
|
"""Main chat model — drives the agent. bot_type maps to a provider id."""
|
||||||
bot_type = local_config.get("bot_type") or ""
|
bot_type = local_config.get("bot_type") or ""
|
||||||
provider_id = "openai" if bot_type == "chatGPT" else bot_type
|
provider_id = "openai" if bot_type == "chatGPT" else bot_type
|
||||||
if provider_id not in ConfigHandler.PROVIDER_MODELS and local_config.get("use_linkai"):
|
is_custom_id = provider_id.startswith("custom:")
|
||||||
|
if (provider_id not in ConfigHandler.PROVIDER_MODELS and not is_custom_id
|
||||||
|
and local_config.get("use_linkai")):
|
||||||
provider_id = "linkai"
|
provider_id = "linkai"
|
||||||
|
# In multi-provider mode, replace the single "custom" entry with the
|
||||||
|
# expanded "custom:<id>" ids so the chat dropdown matches the cards.
|
||||||
|
# The legacy "custom" entry stays when its flat config is still used.
|
||||||
|
provider_ids = []
|
||||||
|
custom_cards = cls._custom_provider_cards(local_config)
|
||||||
|
keep_legacy_custom = cls._legacy_custom_in_use(local_config)
|
||||||
|
for pid in ConfigHandler.PROVIDER_MODELS.keys():
|
||||||
|
if pid == "custom" and custom_cards:
|
||||||
|
provider_ids.extend(c["id"] for c in custom_cards)
|
||||||
|
if keep_legacy_custom:
|
||||||
|
provider_ids.append(pid)
|
||||||
|
else:
|
||||||
|
provider_ids.append(pid)
|
||||||
return {
|
return {
|
||||||
"editable": True,
|
"editable": True,
|
||||||
"current_provider": provider_id,
|
"current_provider": provider_id,
|
||||||
"current_model": local_config.get("model", ""),
|
"current_model": local_config.get("model", ""),
|
||||||
"providers": list(ConfigHandler.PROVIDER_MODELS.keys()),
|
"providers": provider_ids,
|
||||||
"use_linkai": bool(local_config.get("use_linkai", False)),
|
"use_linkai": bool(local_config.get("use_linkai", False)),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2257,18 +2444,31 @@ class ModelsHandler:
|
|||||||
user_specified = (vision_conf.get("model") or "").strip()
|
user_specified = (vision_conf.get("model") or "").strip()
|
||||||
explicit_provider = (vision_conf.get("provider") or "").strip()
|
explicit_provider = (vision_conf.get("provider") or "").strip()
|
||||||
|
|
||||||
|
# Build provider list: built-in providers + expanded custom:<id> entries.
|
||||||
|
# Same pattern as _embedding_capability — each user-created custom
|
||||||
|
# provider gets its own dropdown entry showing the user-chosen name.
|
||||||
|
providers = []
|
||||||
|
custom_cards = cls._custom_provider_cards(local_config)
|
||||||
|
for pid in cls._VISION_PROVIDER_MODELS:
|
||||||
|
if pid == "custom":
|
||||||
|
if custom_cards:
|
||||||
|
providers.extend(c["id"] for c in custom_cards)
|
||||||
|
else:
|
||||||
|
providers.append(pid)
|
||||||
|
|
||||||
# Provider resolution priority:
|
# Provider resolution priority:
|
||||||
# 1. Explicit `tools.vision.provider` (persisted via UI; supports
|
# 1. Explicit `tools.vision.provider` (persisted via UI; supports
|
||||||
# custom model names that prefix-inference can't recognize).
|
# custom model names that prefix-inference can't recognize).
|
||||||
# 2. Scan per-provider model lists by model name.
|
# 2. Scan per-provider model lists by model name.
|
||||||
# Empty provider keeps the dropdown on "auto" when we can't tell.
|
# Empty provider keeps the dropdown on "auto" when we can't tell.
|
||||||
inferred_provider = ""
|
inferred_provider = ""
|
||||||
if explicit_provider and explicit_provider in cls._VISION_PROVIDER_MODELS:
|
if explicit_provider and explicit_provider in providers:
|
||||||
inferred_provider = explicit_provider
|
inferred_provider = explicit_provider
|
||||||
elif user_specified:
|
elif user_specified:
|
||||||
for pid, models in cls._VISION_PROVIDER_MODELS.items():
|
for pid, models in cls._VISION_PROVIDER_MODELS.items():
|
||||||
if user_specified in models:
|
if user_specified in models:
|
||||||
inferred_provider = pid
|
# For "custom" key, map to the first custom card
|
||||||
|
inferred_provider = custom_cards[0]["id"] if pid == "custom" and custom_cards else pid
|
||||||
break
|
break
|
||||||
|
|
||||||
# In auto mode the hint should reflect what vision.py will actually
|
# In auto mode the hint should reflect what vision.py will actually
|
||||||
@@ -2284,7 +2484,7 @@ class ModelsHandler:
|
|||||||
"current_model": user_specified,
|
"current_model": user_specified,
|
||||||
"fallback_provider": predicted["provider"],
|
"fallback_provider": predicted["provider"],
|
||||||
"fallback_model": predicted["model"],
|
"fallback_model": predicted["model"],
|
||||||
"providers": list(cls._VISION_PROVIDER_MODELS.keys()),
|
"providers": providers,
|
||||||
"provider_models": cls._VISION_PROVIDER_MODELS,
|
"provider_models": cls._VISION_PROVIDER_MODELS,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2357,18 +2557,40 @@ class ModelsHandler:
|
|||||||
suggested = ""
|
suggested = ""
|
||||||
if not explicit:
|
if not explicit:
|
||||||
for pid in cls._EMBEDDING_PROVIDERS:
|
for pid in cls._EMBEDDING_PROVIDERS:
|
||||||
|
if pid == "custom":
|
||||||
|
continue
|
||||||
meta = ConfigHandler.PROVIDER_MODELS.get(pid) or {}
|
meta = ConfigHandler.PROVIDER_MODELS.get(pid) or {}
|
||||||
key_field = meta.get("api_key_field")
|
key_field = meta.get("api_key_field")
|
||||||
if key_field and cls._is_real_key(local_config.get(key_field, "")):
|
if key_field and cls._is_real_key(local_config.get(key_field, "")):
|
||||||
suggested = pid
|
suggested = pid
|
||||||
break
|
break
|
||||||
|
if not suggested:
|
||||||
|
custom_cards = cls._custom_provider_cards(local_config)
|
||||||
|
if custom_cards:
|
||||||
|
suggested = custom_cards[0]["id"]
|
||||||
|
|
||||||
|
# Build provider list: built-in providers + expanded custom:<id> entries
|
||||||
|
# Same pattern as _chat_capability — each user-created custom provider
|
||||||
|
# gets its own dropdown entry showing the user-chosen name.
|
||||||
|
providers = []
|
||||||
|
custom_cards = cls._custom_provider_cards(local_config)
|
||||||
|
for pid in cls._EMBEDDING_PROVIDERS:
|
||||||
|
if pid == "custom":
|
||||||
|
if custom_cards:
|
||||||
|
providers.extend(c["id"] for c in custom_cards)
|
||||||
|
# No custom providers configured — skip the bare "custom" entry
|
||||||
|
# since the runtime cannot resolve its credentials.
|
||||||
|
else:
|
||||||
|
providers.append(pid)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"editable": True,
|
"editable": True,
|
||||||
"current_provider": explicit,
|
"current_provider": explicit,
|
||||||
"suggested_provider": suggested,
|
"suggested_provider": suggested,
|
||||||
"current_model": local_config.get("embedding_model", "") or "",
|
"current_model": local_config.get("embedding_model", "") or "",
|
||||||
"current_dim": int(local_config.get("embedding_dimensions") or 0) or None,
|
"current_dim": int(local_config.get("embedding_dimensions") or 0) or None,
|
||||||
"providers": cls._EMBEDDING_PROVIDERS,
|
"providers": providers,
|
||||||
|
"provider_models": cls._EMBEDDING_PROVIDER_MODELS,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Auto-fallback order for image generation. Mirrors the global priority
|
# Auto-fallback order for image generation. Mirrors the global priority
|
||||||
@@ -2588,6 +2810,12 @@ class ModelsHandler:
|
|||||||
return self._handle_set_provider(data)
|
return self._handle_set_provider(data)
|
||||||
if action == "delete_provider":
|
if action == "delete_provider":
|
||||||
return self._handle_delete_provider(data)
|
return self._handle_delete_provider(data)
|
||||||
|
if action == "set_custom_provider":
|
||||||
|
return self._handle_set_custom_provider(data)
|
||||||
|
if action == "delete_custom_provider":
|
||||||
|
return self._handle_delete_custom_provider(data)
|
||||||
|
if action == "set_active_custom_provider":
|
||||||
|
return self._handle_set_active_custom_provider(data)
|
||||||
if action == "set_capability":
|
if action == "set_capability":
|
||||||
return self._handle_set_capability(data)
|
return self._handle_set_capability(data)
|
||||||
if action == "set_voice_reply_mode":
|
if action == "set_voice_reply_mode":
|
||||||
@@ -2671,6 +2899,170 @@ class ModelsHandler:
|
|||||||
self._reset_bridge()
|
self._reset_bridge()
|
||||||
return json.dumps({"status": "success", "provider": provider_id, "cleared": cleared})
|
return json.dumps({"status": "success", "provider": provider_id, "cleared": cleared})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Multiple custom (OpenAI-compatible) providers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# These actions manage the ``custom_providers`` list. Activation is done
|
||||||
|
# by setting ``bot_type`` to ``"custom:<id>"``. There is no separate
|
||||||
|
# ``custom_active_provider`` field — a single source of truth.
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_custom_providers(raw) -> List[dict]:
|
||||||
|
"""Return a clean list of provider dicts (drops malformed entries)."""
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for p in raw:
|
||||||
|
if isinstance(p, dict) and (p.get("id") or "").strip():
|
||||||
|
out.append(p)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _persist_custom_providers(self, providers: List[dict], bot_type=None) -> None:
|
||||||
|
"""Write the providers list to both in-memory conf and the on-disk
|
||||||
|
config, then reset the bridge so bots rebuild.
|
||||||
|
|
||||||
|
If ``bot_type`` is given, also update ``bot_type``. When activating a
|
||||||
|
provider (bot_type is ``custom:<id>``), also write the provider's
|
||||||
|
``model`` into the global ``model`` field so that all paths (chat,
|
||||||
|
agent, vision) automatically use the correct model."""
|
||||||
|
from models.custom_provider import parse_custom_bot_type
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
file_cfg = self._read_file_config()
|
||||||
|
local_config["custom_providers"] = providers
|
||||||
|
file_cfg["custom_providers"] = providers
|
||||||
|
if bot_type is not None:
|
||||||
|
local_config["bot_type"] = bot_type
|
||||||
|
file_cfg["bot_type"] = bot_type
|
||||||
|
# Sync the provider's model into the global model field.
|
||||||
|
_, pid = parse_custom_bot_type(bot_type)
|
||||||
|
if pid:
|
||||||
|
provider = next((p for p in providers if p.get("id") == pid), None)
|
||||||
|
if provider and provider.get("model"):
|
||||||
|
local_config["model"] = provider["model"]
|
||||||
|
file_cfg["model"] = provider["model"]
|
||||||
|
self._write_file_config(file_cfg)
|
||||||
|
self._reset_bridge()
|
||||||
|
|
||||||
|
def _handle_set_custom_provider(self, data: dict) -> str:
|
||||||
|
"""Add a new custom provider or update an existing one.
|
||||||
|
|
||||||
|
Payload::
|
||||||
|
|
||||||
|
{
|
||||||
|
"action": "set_custom_provider",
|
||||||
|
"id": "3f2a9c1b", # required for edit; omit for create
|
||||||
|
"name": "my-provider", # required, display label
|
||||||
|
"api_base": "https://...", # required when creating
|
||||||
|
"api_key": "sk-...", # optional on edit (keep existing)
|
||||||
|
"model": "model-name", # optional default model
|
||||||
|
"make_active": true # optional, also activate it
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from models.custom_provider import generate_provider_id, parse_custom_bot_type
|
||||||
|
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return json.dumps({"status": "error", "message": "name is required"})
|
||||||
|
|
||||||
|
provider_id = (data.get("id") or "").strip()
|
||||||
|
api_base = (data.get("api_base") or "").strip()
|
||||||
|
# api_key omitted/empty on edit => keep the existing one.
|
||||||
|
api_key_raw = data.get("api_key")
|
||||||
|
api_key = api_key_raw.strip() if isinstance(api_key_raw, str) else ""
|
||||||
|
model = (data.get("model") or "").strip()
|
||||||
|
make_active = bool(data.get("make_active"))
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||||
|
|
||||||
|
existing = next((p for p in providers if p.get("id") == provider_id), None) if provider_id else None
|
||||||
|
if existing is None:
|
||||||
|
# Creating a new provider — api_base is mandatory.
|
||||||
|
if not api_base:
|
||||||
|
return json.dumps({"status": "error", "message": "api_base is required"})
|
||||||
|
provider_id = generate_provider_id()
|
||||||
|
entry = {"id": provider_id, "name": name, "api_key": api_key, "api_base": api_base}
|
||||||
|
if model:
|
||||||
|
entry["model"] = model
|
||||||
|
providers.append(entry)
|
||||||
|
created = True
|
||||||
|
else:
|
||||||
|
existing["name"] = name
|
||||||
|
if api_base:
|
||||||
|
existing["api_base"] = api_base
|
||||||
|
if api_key:
|
||||||
|
existing["api_key"] = api_key
|
||||||
|
# Only touch model when explicitly provided in the payload; an
|
||||||
|
# explicit empty string clears it, a missing key keeps it (the
|
||||||
|
# UI modal no longer sends model, so manual config survives edits).
|
||||||
|
if "model" in data:
|
||||||
|
if model:
|
||||||
|
existing["model"] = model
|
||||||
|
else:
|
||||||
|
existing.pop("model", None)
|
||||||
|
created = False
|
||||||
|
|
||||||
|
# Decide bot_type — only switch when explicitly requested.
|
||||||
|
new_bot_type = None
|
||||||
|
if make_active:
|
||||||
|
new_bot_type = f"custom:{provider_id}"
|
||||||
|
|
||||||
|
self._persist_custom_providers(providers, new_bot_type)
|
||||||
|
logger.info(
|
||||||
|
f"[ModelsHandler] custom provider {name!r} (id={provider_id}) "
|
||||||
|
f"{'created' if created else 'updated'}"
|
||||||
|
)
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success",
|
||||||
|
"id": provider_id,
|
||||||
|
"name": name,
|
||||||
|
"created": created,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _handle_delete_custom_provider(self, data: dict) -> str:
|
||||||
|
"""Remove a custom provider by id."""
|
||||||
|
from models.custom_provider import parse_custom_bot_type
|
||||||
|
|
||||||
|
provider_id = (data.get("id") or "").strip()
|
||||||
|
if not provider_id:
|
||||||
|
return json.dumps({"status": "error", "message": "id is required"})
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||||
|
remaining = [p for p in providers if p.get("id") != provider_id]
|
||||||
|
if len(remaining) == len(providers):
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
|
||||||
|
|
||||||
|
# If the deleted provider was active, fall back to the first remaining.
|
||||||
|
_, current_active_id = parse_custom_bot_type(local_config.get("bot_type") or "")
|
||||||
|
new_bot_type = None
|
||||||
|
if current_active_id == provider_id:
|
||||||
|
if remaining:
|
||||||
|
new_bot_type = f"custom:{remaining[0]['id']}"
|
||||||
|
else:
|
||||||
|
new_bot_type = "custom" # revert to legacy
|
||||||
|
|
||||||
|
self._persist_custom_providers(remaining, new_bot_type)
|
||||||
|
logger.info(f"[ModelsHandler] custom provider id={provider_id} deleted")
|
||||||
|
return json.dumps({"status": "success", "id": provider_id})
|
||||||
|
|
||||||
|
def _handle_set_active_custom_provider(self, data: dict) -> str:
|
||||||
|
"""Activate a custom provider by setting bot_type to 'custom:<id>'."""
|
||||||
|
provider_id = (data.get("id") or "").strip()
|
||||||
|
if not provider_id:
|
||||||
|
return json.dumps({"status": "error", "message": "id is required"})
|
||||||
|
|
||||||
|
local_config = conf()
|
||||||
|
providers = self._normalize_custom_providers(local_config.get("custom_providers"))
|
||||||
|
if not any(p.get("id") == provider_id for p in providers):
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown custom provider id: {provider_id}"})
|
||||||
|
|
||||||
|
new_bot_type = f"custom:{provider_id}"
|
||||||
|
self._persist_custom_providers(providers, new_bot_type)
|
||||||
|
logger.info(f"[ModelsHandler] active custom provider set to id={provider_id}")
|
||||||
|
return json.dumps({"status": "success", "active_id": provider_id})
|
||||||
|
|
||||||
def _handle_set_capability(self, data: dict) -> str:
|
def _handle_set_capability(self, data: dict) -> str:
|
||||||
capability = (data.get("capability") or "").strip()
|
capability = (data.get("capability") or "").strip()
|
||||||
provider_id = (data.get("provider_id") or "").strip()
|
provider_id = (data.get("provider_id") or "").strip()
|
||||||
@@ -2735,13 +3127,28 @@ class ModelsHandler:
|
|||||||
})
|
})
|
||||||
|
|
||||||
def _set_chat(self, provider_id: str, model: str) -> str:
|
def _set_chat(self, provider_id: str, model: str) -> str:
|
||||||
if provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
|
# Accept expanded custom provider ids ("custom:<id>") as well as the
|
||||||
|
# built-in vendors, so the chat capability card and the custom
|
||||||
|
# providers section behave consistently.
|
||||||
|
custom_provider = None
|
||||||
|
if provider_id.startswith("custom:"):
|
||||||
|
from models.custom_provider import parse_custom_bot_type
|
||||||
|
_, custom_id = parse_custom_bot_type(provider_id)
|
||||||
|
providers = self._normalize_custom_providers(conf().get("custom_providers"))
|
||||||
|
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
|
||||||
|
if custom_provider is None:
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
|
||||||
|
elif provider_id and provider_id not in ConfigHandler.PROVIDER_MODELS:
|
||||||
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
||||||
|
|
||||||
applied = {}
|
applied = {}
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
file_cfg = self._read_file_config()
|
file_cfg = self._read_file_config()
|
||||||
|
|
||||||
|
# Fall back to the custom provider's default model when none is given.
|
||||||
|
if not model and custom_provider:
|
||||||
|
model = custom_provider.get("model") or ""
|
||||||
|
|
||||||
if provider_id:
|
if provider_id:
|
||||||
bot_type_value = "chatGPT" if provider_id == "openai" else provider_id
|
bot_type_value = "chatGPT" if provider_id == "openai" else provider_id
|
||||||
local_config["bot_type"] = bot_type_value
|
local_config["bot_type"] = bot_type_value
|
||||||
@@ -2769,6 +3176,25 @@ class ModelsHandler:
|
|||||||
# is persisted so users picking a custom model under a specific vendor
|
# is persisted so users picking a custom model under a specific vendor
|
||||||
# still get routed there — runtime falls back to model-name prefix
|
# still get routed there — runtime falls back to model-name prefix
|
||||||
# inference only when provider is empty.
|
# inference only when provider is empty.
|
||||||
|
# Validate provider_id — mirrors _set_chat / _set_embedding pattern.
|
||||||
|
if provider_id.startswith("custom:"):
|
||||||
|
from models.custom_provider import parse_custom_bot_type
|
||||||
|
_, custom_id = parse_custom_bot_type(provider_id)
|
||||||
|
providers = self._normalize_custom_providers(conf().get("custom_providers"))
|
||||||
|
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
|
||||||
|
if custom_provider is None:
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
|
||||||
|
if not model:
|
||||||
|
model = custom_provider.get("model") or ""
|
||||||
|
elif provider_id and provider_id not in {k for k in ModelsHandler._VISION_PROVIDER_MODELS if k != "custom"}:
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
||||||
|
|
||||||
|
if provider_id and not model:
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": "vision model is required when a provider is selected",
|
||||||
|
})
|
||||||
|
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
file_cfg = self._read_file_config()
|
file_cfg = self._read_file_config()
|
||||||
self._set_nested_namespace_value(file_cfg, "tools", "vision", "model", model)
|
self._set_nested_namespace_value(file_cfg, "tools", "vision", "model", model)
|
||||||
@@ -2894,7 +3320,20 @@ class ModelsHandler:
|
|||||||
logger.warning(f"[ModelsHandler] Bridge voice refresh failed: {e}")
|
logger.warning(f"[ModelsHandler] Bridge voice refresh failed: {e}")
|
||||||
|
|
||||||
def _set_embedding(self, provider_id: str, model: str) -> str:
|
def _set_embedding(self, provider_id: str, model: str) -> str:
|
||||||
# Two valid states: both empty (reset to pick-or-empty) OR both set.
|
# Validate provider_id — mirrors _set_chat's validation pattern.
|
||||||
|
if provider_id.startswith("custom:"):
|
||||||
|
from models.custom_provider import parse_custom_bot_type
|
||||||
|
_, custom_id = parse_custom_bot_type(provider_id)
|
||||||
|
providers = self._normalize_custom_providers(conf().get("custom_providers"))
|
||||||
|
custom_provider = next((p for p in providers if p.get("id") == custom_id), None)
|
||||||
|
if custom_provider is None:
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown custom provider id: {custom_id}"})
|
||||||
|
# Fall back to the custom provider's default model when none is given.
|
||||||
|
if not model:
|
||||||
|
model = custom_provider.get("model") or ""
|
||||||
|
elif provider_id and provider_id not in {p for p in ModelsHandler._EMBEDDING_PROVIDERS if p != "custom"}:
|
||||||
|
return json.dumps({"status": "error", "message": f"unknown provider: {provider_id}"})
|
||||||
|
|
||||||
# A provider without a model leaves the runtime in a broken half-state,
|
# A provider without a model leaves the runtime in a broken half-state,
|
||||||
# so reject that explicitly instead of silently writing it through.
|
# so reject that explicitly instead of silently writing it through.
|
||||||
if provider_id and not model:
|
if provider_id and not model:
|
||||||
@@ -3114,8 +3553,12 @@ class ChannelsHandler:
|
|||||||
try:
|
try:
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
active_channels = self._active_channel_set()
|
active_channels = self._active_channel_set()
|
||||||
|
# Desktop build ships without lark-oapi, so hide Feishu from the list.
|
||||||
|
desktop_mode = os.environ.get("COW_DESKTOP") == "1"
|
||||||
channels = []
|
channels = []
|
||||||
for ch_name, ch_def in self.CHANNEL_DEFS.items():
|
for ch_name, ch_def in self.CHANNEL_DEFS.items():
|
||||||
|
if desktop_mode and ch_name == "feishu":
|
||||||
|
continue
|
||||||
fields_out = []
|
fields_out = []
|
||||||
for f in ch_def["fields"]:
|
for f in ch_def["fields"]:
|
||||||
raw_val = local_config.get(f["key"], f.get("default", ""))
|
raw_val = local_config.get(f["key"], f.get("default", ""))
|
||||||
@@ -3197,8 +3640,7 @@ class ChannelsHandler:
|
|||||||
if not applied:
|
if not applied:
|
||||||
return json.dumps({"status": "error", "message": "no valid fields to update"})
|
return json.dumps({"status": "error", "message": "no valid fields to update"})
|
||||||
|
|
||||||
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
config_path = os.path.join(get_data_root(), "config.json")
|
||||||
os.path.abspath(__file__)))), "config.json")
|
|
||||||
if os.path.exists(config_path):
|
if os.path.exists(config_path):
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
file_cfg = json.load(f)
|
file_cfg = json.load(f)
|
||||||
@@ -3268,8 +3710,7 @@ class ChannelsHandler:
|
|||||||
new_channel_type = ",".join(existing)
|
new_channel_type = ",".join(existing)
|
||||||
local_config["channel_type"] = new_channel_type
|
local_config["channel_type"] = new_channel_type
|
||||||
|
|
||||||
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
config_path = os.path.join(get_data_root(), "config.json")
|
||||||
os.path.abspath(__file__)))), "config.json")
|
|
||||||
if os.path.exists(config_path):
|
if os.path.exists(config_path):
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
file_cfg = json.load(f)
|
file_cfg = json.load(f)
|
||||||
@@ -3324,8 +3765,7 @@ class ChannelsHandler:
|
|||||||
local_config = conf()
|
local_config = conf()
|
||||||
local_config["channel_type"] = new_channel_type
|
local_config["channel_type"] = new_channel_type
|
||||||
|
|
||||||
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
config_path = os.path.join(get_data_root(), "config.json")
|
||||||
os.path.abspath(__file__)))), "config.json")
|
|
||||||
if os.path.exists(config_path):
|
if os.path.exists(config_path):
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
file_cfg = json.load(f)
|
file_cfg = json.load(f)
|
||||||
@@ -3475,9 +3915,7 @@ class WeixinQrHandler:
|
|||||||
if not bot_token or not bot_id:
|
if not bot_token or not bot_id:
|
||||||
return json.dumps({"status": "error", "message": "Login confirmed but missing token"})
|
return json.dumps({"status": "error", "message": "Login confirmed but missing token"})
|
||||||
|
|
||||||
cred_path = os.path.expanduser(
|
cred_path = get_weixin_credentials_path()
|
||||||
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
|
|
||||||
)
|
|
||||||
from channel.weixin.weixin_channel import _save_credentials
|
from channel.weixin.weixin_channel import _save_credentials
|
||||||
_save_credentials(cred_path, {
|
_save_credentials(cred_path, {
|
||||||
"token": bot_token,
|
"token": bot_token,
|
||||||
@@ -3812,6 +4250,141 @@ class SchedulerHandler:
|
|||||||
return json.dumps({"status": "error", "message": str(e)})
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerToggleHandler:
|
||||||
|
def POST(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
body = json.loads(web.data())
|
||||||
|
task_id = body.get("task_id")
|
||||||
|
enabled = body.get("enabled", True)
|
||||||
|
if not task_id:
|
||||||
|
return json.dumps({"status": "error", "message": "task_id required"})
|
||||||
|
from agent.tools.scheduler.task_store import TaskStore
|
||||||
|
workspace_root = _get_workspace_root()
|
||||||
|
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
||||||
|
store = TaskStore(store_path)
|
||||||
|
store.enable_task(task_id, enabled)
|
||||||
|
task = store.get_task(task_id)
|
||||||
|
return json.dumps({"status": "success", "task": task}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Scheduler toggle error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerUpdateHandler:
|
||||||
|
def POST(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
body = json.loads(web.data())
|
||||||
|
task_id = body.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
return json.dumps({"status": "error", "message": "task_id required"})
|
||||||
|
|
||||||
|
from agent.tools.scheduler.task_store import TaskStore
|
||||||
|
from agent.tools.scheduler.scheduler_service import SchedulerService
|
||||||
|
from datetime import datetime
|
||||||
|
workspace_root = _get_workspace_root()
|
||||||
|
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
||||||
|
store = TaskStore(store_path)
|
||||||
|
|
||||||
|
# Get original task (single query to avoid repeated I/O)
|
||||||
|
original_task = store.get_task(task_id)
|
||||||
|
if not original_task:
|
||||||
|
return json.dumps({"status": "error", "message": f"Task '{task_id}' not found"})
|
||||||
|
|
||||||
|
# Build updates dict
|
||||||
|
updates = {}
|
||||||
|
if "name" in body:
|
||||||
|
updates["name"] = body["name"]
|
||||||
|
if "enabled" in body:
|
||||||
|
updates["enabled"] = body["enabled"]
|
||||||
|
|
||||||
|
# Update schedule
|
||||||
|
if "schedule" in body:
|
||||||
|
updates["schedule"] = body["schedule"]
|
||||||
|
# If schedule config changed, recalculate next_run_at
|
||||||
|
# Build merged temp task data for calculation (without modifying the original object)
|
||||||
|
merged = dict(original_task)
|
||||||
|
merged.update(updates)
|
||||||
|
if "action" in body:
|
||||||
|
merged["action"] = body["action"]
|
||||||
|
temp_service = SchedulerService(store, lambda t: None)
|
||||||
|
next_run = temp_service._calculate_next_run(merged, datetime.now())
|
||||||
|
if next_run:
|
||||||
|
updates["next_run_at"] = next_run.isoformat()
|
||||||
|
else:
|
||||||
|
# Cannot calculate next run time, schedule config may be invalid
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": "Cannot calculate next run time. Please check the schedule config (e.g., cron expression format, or whether the one-time task time has already passed)."
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Update action
|
||||||
|
if "action" in body:
|
||||||
|
action = body["action"]
|
||||||
|
channel_type = action.get("channel_type", "web")
|
||||||
|
|
||||||
|
# Get the task's original channel_type
|
||||||
|
old_channel = original_task.get("action", {}).get("channel_type", "web")
|
||||||
|
|
||||||
|
# If channel type changed or no receiver, reject the update.
|
||||||
|
# Note: the web UI disables the channel selector, so this branch
|
||||||
|
# is only reachable via direct API calls. Changing a task's channel
|
||||||
|
# after creation is not supported because the receiver identity is
|
||||||
|
# channel-bound and cannot be trivially re-populated (e.g. weixin
|
||||||
|
# requires a valid context_token tied to the original user-session).
|
||||||
|
if old_channel and old_channel != channel_type:
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Cannot change channel type from '{old_channel}' to '{channel_type}'. Please create a new task on the target channel instead."
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
if not action.get("receiver"):
|
||||||
|
return json.dumps({
|
||||||
|
"status": "error",
|
||||||
|
"message": "Receiver is required. Please create a new task through the chat interface."
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
updates["action"] = action
|
||||||
|
|
||||||
|
# If schedule was not updated but action was, ensure next_run_at exists
|
||||||
|
if "schedule" not in body and "next_run_at" not in original_task:
|
||||||
|
merged = dict(original_task)
|
||||||
|
merged.update(updates)
|
||||||
|
temp_service = SchedulerService(store, lambda t: None)
|
||||||
|
next_run = temp_service._calculate_next_run(merged, datetime.now())
|
||||||
|
if next_run:
|
||||||
|
updates["next_run_at"] = next_run.isoformat()
|
||||||
|
|
||||||
|
store.update_task(task_id, updates)
|
||||||
|
task = store.get_task(task_id)
|
||||||
|
return json.dumps({"status": "success", "task": task}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Scheduler update error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerDeleteHandler:
|
||||||
|
def POST(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
body = json.loads(web.data())
|
||||||
|
task_id = body.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
return json.dumps({"status": "error", "message": "task_id required"})
|
||||||
|
|
||||||
|
from agent.tools.scheduler.task_store import TaskStore
|
||||||
|
workspace_root = _get_workspace_root()
|
||||||
|
store_path = os.path.join(workspace_root, "scheduler", "tasks.json")
|
||||||
|
store = TaskStore(store_path)
|
||||||
|
store.delete_task(task_id)
|
||||||
|
return json.dumps({"status": "success"}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Scheduler delete error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
class SessionsHandler:
|
class SessionsHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
_require_auth()
|
_require_auth()
|
||||||
@@ -3988,7 +4561,7 @@ class MessageDeleteHandler:
|
|||||||
# 2. Sync agent's in-memory context so its next turn sees the
|
# 2. Sync agent's in-memory context so its next turn sees the
|
||||||
# same history as the DB. Handled by the agent_bridge helper.
|
# same history as the DB. Handled by the agent_bridge helper.
|
||||||
try:
|
try:
|
||||||
from bridge import Bridge
|
from bridge.bridge import Bridge
|
||||||
Bridge().get_agent_bridge().sync_session_messages_from_store(session_id)
|
Bridge().get_agent_bridge().sync_session_messages_from_store(session_id)
|
||||||
except Exception as sync_err:
|
except Exception as sync_err:
|
||||||
logger.warning(f"[WebChannel] Failed to sync agent memory: {sync_err}")
|
logger.warning(f"[WebChannel] Failed to sync agent memory: {sync_err}")
|
||||||
@@ -4006,8 +4579,7 @@ class LogsHandler:
|
|||||||
web.header('Cache-Control', 'no-cache')
|
web.header('Cache-Control', 'no-cache')
|
||||||
web.header('X-Accel-Buffering', 'no')
|
web.header('X-Accel-Buffering', 'no')
|
||||||
|
|
||||||
from config import get_root
|
log_path = os.path.join(get_data_root(), "run.log")
|
||||||
log_path = os.path.join(get_root(), "run.log")
|
|
||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
if not os.path.isfile(log_path):
|
if not os.path.isfile(log_path):
|
||||||
@@ -4140,6 +4712,25 @@ class KnowledgeGraphHandler:
|
|||||||
return json.dumps({"nodes": [], "links": []})
|
return json.dumps({"nodes": [], "links": []})
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeActionHandler:
|
||||||
|
def POST(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
try:
|
||||||
|
body = json.loads(web.data() or b"{}")
|
||||||
|
action = body.get("action", "")
|
||||||
|
payload = body.get("payload") or {}
|
||||||
|
from agent.knowledge.service import KnowledgeService
|
||||||
|
result = KnowledgeService(_get_workspace_root()).dispatch(action, payload)
|
||||||
|
return json.dumps({
|
||||||
|
"status": "success" if result["code"] < 300 else "error",
|
||||||
|
**result,
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Knowledge action error: {e}")
|
||||||
|
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
|
||||||
|
|
||||||
|
|
||||||
class VersionHandler:
|
class VersionHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
|||||||
@@ -12,16 +12,19 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import web
|
||||||
import websocket
|
import websocket
|
||||||
|
|
||||||
from bridge.context import Context, ContextType
|
from bridge.context import Context, ContextType
|
||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
from channel.chat_channel import ChatChannel, check_prefix
|
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 channel.wecom_bot.wecom_bot_message import WecomBotMessage
|
||||||
from common.expired_dict import ExpiredDict
|
from common.expired_dict import ExpiredDict
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
@@ -32,6 +35,9 @@ from config import conf
|
|||||||
WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
|
WECOM_WS_URL = "wss://openws.work.weixin.qq.com"
|
||||||
HEARTBEAT_INTERVAL = 30
|
HEARTBEAT_INTERVAL = 30
|
||||||
MEDIA_CHUNK_SIZE = 512 * 1024 # 512KB per chunk (before base64 encoding)
|
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:
|
def _escape_control_chars_inside_json_strings(s: str) -> str:
|
||||||
@@ -97,6 +103,14 @@ class WecomBotChannel(ChatChannel):
|
|||||||
self._pending_lock = threading.Lock()
|
self._pending_lock = threading.Lock()
|
||||||
self._stream_states = {} # req_id -> {"stream_id": str, "content": str}
|
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()["group_name_white_list"] = ["ALL_GROUP"]
|
||||||
conf()["single_chat_prefix"] = [""]
|
conf()["single_chat_prefix"] = [""]
|
||||||
|
|
||||||
@@ -105,6 +119,11 @@ class WecomBotChannel(ChatChannel):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def startup(self):
|
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_id = conf().get("wecom_bot_id", "")
|
||||||
self.bot_secret = conf().get("wecom_bot_secret", "")
|
self.bot_secret = conf().get("wecom_bot_secret", "")
|
||||||
|
|
||||||
@@ -127,6 +146,13 @@ class WecomBotChannel(ChatChannel):
|
|||||||
pass
|
pass
|
||||||
self._ws = None
|
self._ws = None
|
||||||
self._connected = False
|
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
|
# WebSocket connection
|
||||||
@@ -183,6 +209,192 @@ class WecomBotChannel(ChatChannel):
|
|||||||
def _gen_req_id(self) -> str:
|
def _gen_req_id(self) -> str:
|
||||||
return uuid.uuid4().hex[:16]
|
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
|
# Subscribe & heartbeat
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -287,16 +499,31 @@ class WecomBotChannel(ChatChannel):
|
|||||||
chattype = body.get("chattype", "single")
|
chattype = body.get("chattype", "single")
|
||||||
is_group = chattype == "group"
|
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:
|
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:
|
except NotImplementedError as e:
|
||||||
logger.warning(f"[WecomBot] {e}")
|
logger.warning(f"[WecomBot] {e}")
|
||||||
return
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
|
logger.error(f"[WecomBot] Failed to parse message: {e}", exc_info=True)
|
||||||
return
|
return None
|
||||||
|
|
||||||
wecom_msg.req_id = req_id
|
|
||||||
|
|
||||||
# File cache logic (same pattern as feishu)
|
# File cache logic (same pattern as feishu)
|
||||||
from channel.file_cache import get_file_cache
|
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:
|
if hasattr(wecom_msg, "image_path") and wecom_msg.image_path:
|
||||||
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
|
file_cache.add(session_id, wecom_msg.image_path, file_type="image")
|
||||||
logger.info(f"[WecomBot] Image cached for session {session_id}")
|
logger.info(f"[WecomBot] Image cached for session {session_id}")
|
||||||
return
|
return None
|
||||||
|
|
||||||
if wecom_msg.ctype == ContextType.FILE:
|
if wecom_msg.ctype == ContextType.FILE:
|
||||||
wecom_msg.prepare()
|
wecom_msg.prepare()
|
||||||
file_cache.add(session_id, wecom_msg.content, file_type="file")
|
file_cache.add(session_id, wecom_msg.content, file_type="file")
|
||||||
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
|
logger.info(f"[WecomBot] File cached for session {session_id}: {wecom_msg.content}")
|
||||||
return
|
return None
|
||||||
|
|
||||||
if wecom_msg.ctype == ContextType.TEXT:
|
if wecom_msg.ctype == ContextType.TEXT:
|
||||||
cached_files = file_cache.get(session_id)
|
cached_files = file_cache.get(session_id)
|
||||||
@@ -346,10 +573,9 @@ class WecomBotChannel(ChatChannel):
|
|||||||
msg=wecom_msg,
|
msg=wecom_msg,
|
||||||
no_need_at=True,
|
no_need_at=True,
|
||||||
)
|
)
|
||||||
if context:
|
if not context:
|
||||||
if req_id:
|
return None
|
||||||
context["on_event"] = self._make_stream_callback(req_id)
|
return context, wecom_msg
|
||||||
self.produce(context)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Event callback
|
# Event callback
|
||||||
@@ -490,11 +716,233 @@ class WecomBotChannel(ChatChannel):
|
|||||||
|
|
||||||
return context
|
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
|
# Send reply
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def send(self, reply: Reply, context: Context):
|
def send(self, reply: Reply, context: Context):
|
||||||
|
if self.mode == "webhook":
|
||||||
|
self._callback_send(reply, context)
|
||||||
|
return
|
||||||
|
|
||||||
msg = context.get("msg")
|
msg = context.get("msg")
|
||||||
is_group = context.get("isgroup", False)
|
is_group = context.get("isgroup", False)
|
||||||
receiver = context.get("receiver", "")
|
receiver = context.get("receiver", "")
|
||||||
@@ -906,3 +1354,85 @@ class WecomBotChannel(ChatChannel):
|
|||||||
else:
|
else:
|
||||||
logger.error("[WecomBot] Failed to get media_id from finish response")
|
logger.error("[WecomBot] Failed to get media_id from finish response")
|
||||||
return media_id
|
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):
|
class WecomBotMessage(ChatMessage):
|
||||||
"""Message wrapper for wecom bot (websocket long-connection mode)."""
|
"""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)
|
super().__init__(msg_body)
|
||||||
self.msg_id = msg_body.get("msgid")
|
self.msg_id = msg_body.get("msgid")
|
||||||
self.create_time = msg_body.get("create_time")
|
self.create_time = msg_body.get("create_time")
|
||||||
self.is_group = is_group
|
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")
|
msg_type = msg_body.get("msgtype")
|
||||||
from_userid = msg_body.get("from", {}).get("userid", "")
|
from_userid = msg_body.get("from", {}).get("userid", "")
|
||||||
@@ -113,7 +116,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.IMAGE
|
self.ctype = ContextType.IMAGE
|
||||||
image_info = msg_body.get("image", {})
|
image_info = msg_body.get("image", {})
|
||||||
image_url = image_info.get("url", "")
|
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()
|
tmp_dir = _get_tmp_dir()
|
||||||
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
|
image_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}.png")
|
||||||
|
|
||||||
@@ -147,7 +150,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
elif item_type == "image":
|
elif item_type == "image":
|
||||||
img_info = item.get("image", {})
|
img_info = item.get("image", {})
|
||||||
img_url = img_info.get("url", "")
|
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")
|
img_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}_{idx}.png")
|
||||||
try:
|
try:
|
||||||
img_data = _decrypt_media(img_url, img_aeskey)
|
img_data = _decrypt_media(img_url, img_aeskey)
|
||||||
@@ -166,7 +169,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.FILE
|
self.ctype = ContextType.FILE
|
||||||
file_info = msg_body.get("file", {})
|
file_info = msg_body.get("file", {})
|
||||||
file_url = file_info.get("url", "")
|
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()
|
tmp_dir = _get_tmp_dir()
|
||||||
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
|
base_path = os.path.join(tmp_dir, f"wecom_{self.msg_id}")
|
||||||
self.content = base_path
|
self.content = base_path
|
||||||
@@ -188,7 +191,7 @@ class WecomBotMessage(ChatMessage):
|
|||||||
self.ctype = ContextType.FILE
|
self.ctype = ContextType.FILE
|
||||||
video_info = msg_body.get("video", {})
|
video_info = msg_body.get("video", {})
|
||||||
video_url = video_info.get("url", "")
|
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()
|
tmp_dir = _get_tmp_dir()
|
||||||
self.content = os.path.join(tmp_dir, f"wecom_{self.msg_id}.mp4")
|
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.expired_dict import ExpiredDict
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from common.singleton import singleton
|
from common.singleton import singleton
|
||||||
from config import conf
|
from config import conf, get_weixin_credentials_path
|
||||||
|
|
||||||
MAX_CONSECUTIVE_FAILURES = 3
|
MAX_CONSECUTIVE_FAILURES = 3
|
||||||
BACKOFF_DELAY = 30
|
BACKOFF_DELAY = 30
|
||||||
@@ -96,9 +96,7 @@ class WeixinChannel(ChatChannel):
|
|||||||
cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
|
cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
|
||||||
token = conf().get("weixin_token", "")
|
token = conf().get("weixin_token", "")
|
||||||
|
|
||||||
self._credentials_path = os.path.expanduser(
|
self._credentials_path = get_weixin_credentials_path()
|
||||||
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Always load credentials so we can restore context_tokens even when
|
# Always load credentials so we can restore context_tokens even when
|
||||||
# the bot token itself comes from config.
|
# the bot token itself comes from config.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2.1.0
|
2.1.1
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import click
|
import click
|
||||||
from cli import __version__
|
from cli import __version__
|
||||||
from cli.commands.skill import skill
|
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.context import context
|
||||||
from cli.commands.install import install_browser
|
from cli.commands.install import install_browser
|
||||||
from cli.commands.knowledge import knowledge
|
from cli.commands.knowledge import knowledge
|
||||||
@@ -68,6 +68,7 @@ main.add_command(skill)
|
|||||||
main.add_command(start)
|
main.add_command(start)
|
||||||
main.add_command(stop)
|
main.add_command(stop)
|
||||||
main.add_command(restart)
|
main.add_command(restart)
|
||||||
|
main.add_command(self_restart)
|
||||||
main.add_command(update)
|
main.add_command(update)
|
||||||
main.add_command(status)
|
main.add_command(status)
|
||||||
main.add_command(logs)
|
main.add_command(logs)
|
||||||
|
|||||||
@@ -78,12 +78,13 @@ def _is_china_network() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _pip_install(package_spec: str, stream: StreamFn) -> int:
|
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
|
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:
|
if ret != 0:
|
||||||
stream(" Retrying with --user flag...", "yellow")
|
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
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@@ -155,6 +156,22 @@ def run_install_browser(
|
|||||||
|
|
||||||
target_version = PLAYWRIGHT_LEGACY_VERSION if legacy_mode else PLAYWRIGHT_VERSION
|
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…"))
|
_phase(on_phase, _t("📦 [1/3] 正在安装 Playwright Python 包…", "📦 [1/3] Installing Playwright Python package…"))
|
||||||
stream("[1/3] Installing playwright Python package...", "yellow")
|
stream("[1/3] Installing playwright Python package...", "yellow")
|
||||||
ret = _pip_install(f"playwright=={target_version}", stream)
|
ret = _pip_install(f"playwright=={target_version}", stream)
|
||||||
|
|||||||
@@ -195,6 +195,120 @@ def restart(ctx, no_logs):
|
|||||||
ctx.invoke(start, no_logs=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.command()
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def update(ctx):
|
def update(ctx):
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from bridge.context import Context, ContextType
|
|||||||
from bridge.reply import Reply, ReplyType
|
from bridge.reply import Reply, ReplyType
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from linkai import LinkAIClient, PushMsg
|
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
|
from plugins import PluginManager
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -172,6 +172,11 @@ class CloudClient(LinkAIClient):
|
|||||||
if key in available_setting and config.get(key) is not None:
|
if key in available_setting and config.get(key) is not None:
|
||||||
local_config[key] = config.get(key)
|
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
|
# Voice settings
|
||||||
reply_voice_mode = config.get("reply_voice_mode")
|
reply_voice_mode = config.get("reply_voice_mode")
|
||||||
if reply_voice_mode:
|
if reply_voice_mode:
|
||||||
@@ -331,9 +336,7 @@ class CloudClient(LinkAIClient):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _remove_weixin_credentials():
|
def _remove_weixin_credentials():
|
||||||
"""Remove the weixin token credentials file so next connect triggers QR login."""
|
"""Remove the weixin token credentials file so next connect triggers QR login."""
|
||||||
cred_path = os.path.expanduser(
|
cred_path = get_weixin_credentials_path()
|
||||||
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
if os.path.exists(cred_path):
|
if os.path.exists(cred_path):
|
||||||
os.remove(cred_path)
|
os.remove(cred_path)
|
||||||
@@ -341,6 +344,20 @@ class CloudClient(LinkAIClient):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[CloudClient] Failed to remove weixin credentials: {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
|
# channel credentials helpers
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -855,6 +872,10 @@ def _build_config():
|
|||||||
"agent_max_context_turns": local_conf.get("agent_max_context_turns"),
|
"agent_max_context_turns": local_conf.get("agent_max_context_turns"),
|
||||||
"agent_max_context_tokens": local_conf.get("agent_max_context_tokens"),
|
"agent_max_context_tokens": local_conf.get("agent_max_context_tokens"),
|
||||||
"agent_max_steps": local_conf.get("agent_max_steps"),
|
"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"),
|
"channelType": local_conf.get("channel_type"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 厂商类型
|
# Provider types
|
||||||
OPEN_AI = "openAI"
|
OPEN_AI = "openAI"
|
||||||
OPENAI = "openai"
|
OPENAI = "openai"
|
||||||
CHATGPT = "chatGPT" # legacy alias for OPENAI, kept for backward compatibility
|
CHATGPT = "chatGPT" # legacy alias for OPENAI, kept for backward compatibility
|
||||||
@@ -8,48 +8,49 @@ XUNFEI = "xunfei"
|
|||||||
CHATGPTONAZURE = "chatGPTOnAzure"
|
CHATGPTONAZURE = "chatGPTOnAzure"
|
||||||
LINKAI = "linkai"
|
LINKAI = "linkai"
|
||||||
CLAUDEAPI= "claudeAPI"
|
CLAUDEAPI= "claudeAPI"
|
||||||
QWEN = "qwen" # 千问 (兼容旧配置,实际走 DashscopeBot)
|
QWEN = "qwen" # legacy alias, actually routed to DashscopeBot
|
||||||
QWEN_DASHSCOPE = "dashscope" # 千问 DashScope 接入
|
QWEN_DASHSCOPE = "dashscope" # Qwen via DashScope
|
||||||
GEMINI = "gemini"
|
GEMINI = "gemini"
|
||||||
ZHIPU_AI = "zhipu"
|
ZHIPU_AI = "zhipu"
|
||||||
MOONSHOT = "moonshot"
|
MOONSHOT = "moonshot"
|
||||||
MiniMax = "minimax"
|
MiniMax = "minimax"
|
||||||
DEEPSEEK = "deepseek"
|
DEEPSEEK = "deepseek"
|
||||||
MIMO = "mimo" # 小米 MiMo 大模型
|
MIMO = "mimo" # Xiaomi MiMo
|
||||||
CUSTOM = "custom" # custom OpenAI-compatible API, bot_type won't auto-switch on model change
|
CUSTOM = "custom" # custom OpenAI-compatible API, bot_type won't auto-switch on model change
|
||||||
MODELSCOPE = "modelscope"
|
MODELSCOPE = "modelscope"
|
||||||
|
|
||||||
# 模型列表
|
# Model list
|
||||||
# Claude (Anthropic)
|
# Claude (Anthropic)
|
||||||
CLAUDE3 = "claude-3-opus-20240229"
|
CLAUDE3 = "claude-3-opus-20240229"
|
||||||
CLAUDE_3_OPUS = "claude-3-opus-latest"
|
CLAUDE_3_OPUS = "claude-3-opus-latest"
|
||||||
CLAUDE_3_OPUS_0229 = "claude-3-opus-20240229"
|
CLAUDE_3_OPUS_0229 = "claude-3-opus-20240229"
|
||||||
CLAUDE_3_SONNET = "claude-3-sonnet-20240229"
|
CLAUDE_3_SONNET = "claude-3-sonnet-20240229"
|
||||||
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
|
CLAUDE_3_HAIKU = "claude-3-haiku-20240307"
|
||||||
CLAUDE_35_SONNET = "claude-3-5-sonnet-latest" # 带 latest 标签的模型名称,会不断更新指向最新发布的模型
|
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" # 带具体日期的模型名称,会固定为该日期发布的模型
|
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_35_SONNET_0620 = "claude-3-5-sonnet-20240620"
|
||||||
CLAUDE_4_OPUS = "claude-opus-4-0"
|
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_7_OPUS = "claude-opus-4-7" # Claude Opus 4.7
|
||||||
CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
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_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0
|
||||||
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - 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推荐模型
|
CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent recommended model
|
||||||
|
|
||||||
# Gemini (Google)
|
# Gemini (Google)
|
||||||
GEMINI_PRO = "gemini-1.0-pro"
|
GEMINI_PRO = "gemini-1.0-pro"
|
||||||
GEMINI_15_flash = "gemini-1.5-flash"
|
GEMINI_15_flash = "gemini-1.5-flash"
|
||||||
GEMINI_15_PRO = "gemini-1.5-pro"
|
GEMINI_15_PRO = "gemini-1.5-pro"
|
||||||
GEMINI_20_flash_exp = "gemini-2.0-flash-exp" # exp结尾为实验模型,会逐步不再支持
|
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" # 正式版模型
|
GEMINI_20_FLASH = "gemini-2.0-flash" # stable release
|
||||||
GEMINI_25_FLASH_PRE = "gemini-2.5-flash-preview-05-20"
|
GEMINI_25_FLASH_PRE = "gemini-2.5-flash-preview-05-20"
|
||||||
GEMINI_25_PRO_PRE = "gemini-2.5-pro-preview-05-06"
|
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_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_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推荐模型
|
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推荐模型
|
GEMINI_35_FLASH = "gemini-3.5-flash" # Gemini 3.5 Flash - Agent recommended model
|
||||||
|
|
||||||
# OpenAI
|
# OpenAI
|
||||||
GPT35 = "gpt-3.5-turbo"
|
GPT35 = "gpt-3.5-turbo"
|
||||||
@@ -85,10 +86,10 @@ TTS_1 = "tts-1"
|
|||||||
TTS_1_HD = "tts-1-hd"
|
TTS_1_HD = "tts-1-hd"
|
||||||
|
|
||||||
# DeepSeek
|
# DeepSeek
|
||||||
DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3对话模型
|
DEEPSEEK_CHAT = "deepseek-chat" # DeepSeek-V3 chat model
|
||||||
DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1模型
|
DEEPSEEK_REASONER = "deepseek-reasoner" # DeepSeek-R1 model
|
||||||
DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - 默认推荐 (思考模式 + 工具调用)
|
DEEPSEEK_V4_FLASH = "deepseek-v4-flash" # DeepSeek V4 Flash - default recommendation (thinking + tool calls)
|
||||||
DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - 复杂任务更强 (思考模式 + 工具调用)
|
DEEPSEEK_V4_PRO = "deepseek-v4-pro" # DeepSeek V4 Pro - stronger on complex tasks (thinking + tool calls)
|
||||||
|
|
||||||
# Baidu Qianfan / ERNIE
|
# Baidu Qianfan / ERNIE
|
||||||
ERNIE_5_1 = "ernie-5.1" # ERNIE 5.1 - default recommendation, latest flagship
|
ERNIE_5_1 = "ernie-5.1" # ERNIE 5.1 - default recommendation, latest flagship
|
||||||
@@ -100,30 +101,31 @@ ERNIE_4_TURBO_8K = "ERNIE-4.0-Turbo-8K"
|
|||||||
ERNIE_45_TURBO_VL = "ernie-4.5-turbo-vl"
|
ERNIE_45_TURBO_VL = "ernie-4.5-turbo-vl"
|
||||||
ERNIE_45_TURBO_VL_32K = "ernie-4.5-turbo-vl-32k"
|
ERNIE_45_TURBO_VL_32K = "ernie-4.5-turbo-vl-32k"
|
||||||
|
|
||||||
# Qwen (通义千问 - 阿里云 DashScope)
|
# Qwen (Alibaba Cloud DashScope)
|
||||||
QWEN_TURBO = "qwen-turbo"
|
QWEN_TURBO = "qwen-turbo"
|
||||||
QWEN_PLUS = "qwen-plus"
|
QWEN_PLUS = "qwen-plus"
|
||||||
QWEN_MAX = "qwen-max"
|
QWEN_MAX = "qwen-max"
|
||||||
QWEN_LONG = "qwen-long"
|
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)
|
QWEN35_PLUS = "qwen3.5-plus" # Qwen3.5 Plus - Omni model (MultiModalConversation)
|
||||||
QWEN36_PLUS = "qwen3.6-plus" # Qwen3.6 Plus - Omni model (MultiModalConversation)
|
QWEN36_PLUS = "qwen3.6-plus" # Qwen3.6 Plus - Omni model (MultiModalConversation)
|
||||||
QWEN37_PLUS = "qwen3.7-plus" # Qwen3.7 Plus - Omni model (MultiModalConversation)
|
QWEN37_PLUS = "qwen3.7-plus" # Qwen3.7 Plus - Omni model (MultiModalConversation)
|
||||||
QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent推荐模型
|
QWEN37_MAX = "qwen3.7-max" # Qwen3.7 Max - Agent recommended model
|
||||||
QWQ_PLUS = "qwq-plus"
|
QWQ_PLUS = "qwq-plus"
|
||||||
|
|
||||||
# MiniMax
|
# MiniMax
|
||||||
MINIMAX_M3 = "MiniMax-M3" # MiniMax M3 - Latest (default)
|
MINIMAX_M3 = "MiniMax-M3" # MiniMax M3 - Latest (default)
|
||||||
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7
|
MINIMAX_M2_7 = "MiniMax-M2.7" # MiniMax M2.7
|
||||||
MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
|
MINIMAX_M2_7_HIGHSPEED = "MiniMax-M2.7-highspeed" # MiniMax M2.7 highspeed
|
||||||
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax 多模态 (vision)
|
MINIMAX_TEXT_01 = "MiniMax-Text-01" # MiniMax multimodal (vision)
|
||||||
MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
|
MINIMAX_ABAB6_5 = "abab6.5-chat" # MiniMax abab6.5
|
||||||
|
|
||||||
# GLM (智谱AI)
|
# GLM (Zhipu AI)
|
||||||
GLM_5_1 = "glm-5.1" # 智谱 GLM-5.1 - Agent recommended model (default)
|
GLM_5_2 = "glm-5.2" # GLM-5.2 - Agent recommended model (default)
|
||||||
GLM_5_TURBO = "glm-5-turbo" # 智谱 GLM-5-Turbo
|
GLM_5_1 = "glm-5.1" # GLM-5.1
|
||||||
GLM_5 = "glm-5" # 智谱 GLM-5
|
GLM_5_TURBO = "glm-5-turbo" # GLM-5-Turbo
|
||||||
GLM_5V_TURBO = "glm-5v-turbo" # 智谱多模态 (vision)
|
GLM_5 = "glm-5" # GLM-5
|
||||||
|
GLM_5V_TURBO = "glm-5v-turbo" # Zhipu multimodal (vision)
|
||||||
GLM_4 = "glm-4"
|
GLM_4 = "glm-4"
|
||||||
GLM_4_PLUS = "glm-4-plus"
|
GLM_4_PLUS = "glm-4-plus"
|
||||||
GLM_4_flash = "glm-4-flash"
|
GLM_4_flash = "glm-4-flash"
|
||||||
@@ -132,20 +134,22 @@ GLM_4_ALLTOOLS = "glm-4-alltools"
|
|||||||
GLM_4_0520 = "glm-4-0520"
|
GLM_4_0520 = "glm-4-0520"
|
||||||
GLM_4_AIR = "glm-4-air"
|
GLM_4_AIR = "glm-4-air"
|
||||||
GLM_4_AIRX = "glm-4-airx"
|
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)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT = "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 = "kimi-k2"
|
||||||
KIMI_K2_5 = "kimi-k2.5"
|
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
|
# Xiaomi MiMo
|
||||||
MIMO_V2_5_PRO = "mimo-v2.5-pro" # MiMo V2.5 Pro - 旗舰,长上下文(默认推荐)
|
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 - 多模态(文/图/音/视频)
|
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_PRO = "mimo-v2-pro" # MiMo V2 Pro
|
||||||
MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - 多模态
|
MIMO_V2_OMNI = "mimo-v2-omni" # MiMo V2 Omni - multimodal
|
||||||
MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - 极速版
|
MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - high-speed
|
||||||
|
|
||||||
# Doubao (Volcengine Ark)
|
# Doubao (Volcengine Ark)
|
||||||
DOUBAO = "doubao"
|
DOUBAO = "doubao"
|
||||||
@@ -154,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_LITE = "doubao-seed-2-0-lite-260215"
|
||||||
DOUBAO_SEED_2_MINI = "doubao-seed-2-0-mini-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_235B_A22B_INSTRUCT_2507 = "Qwen/Qwen3-235B-A22B-Instruct-2507"
|
||||||
QWEN3_5_27B = "Qwen/Qwen3.5-27B"
|
QWEN3_5_27B = "Qwen/Qwen3.5-27B"
|
||||||
|
|
||||||
# 其他模型
|
# Other models
|
||||||
WEN_XIN = "wenxin"
|
WEN_XIN = "wenxin"
|
||||||
WEN_XIN_4 = "wenxin-4"
|
WEN_XIN_4 = "wenxin-4"
|
||||||
XUNFEI = "xunfei"
|
XUNFEI = "xunfei"
|
||||||
@@ -189,11 +193,11 @@ MODEL_LIST = [
|
|||||||
# MiniMax
|
# MiniMax
|
||||||
MiniMax, MINIMAX_M3, MINIMAX_M2_7, MINIMAX_M2_7_HIGHSPEED, 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,
|
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||||
|
|
||||||
# Claude
|
# 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_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",
|
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||||
|
|
||||||
@@ -211,19 +215,19 @@ MODEL_LIST = [
|
|||||||
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
GPT_54, GPT_55, GPT_54_MINI, GPT_54_NANO,
|
||||||
O1, O1_MINI,
|
O1, O1_MINI,
|
||||||
|
|
||||||
# GLM (智谱AI)
|
# GLM (Zhipu 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,
|
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,
|
GLM_4_0520, GLM_4_AIR, GLM_4_AIRX, GLM_4_7,
|
||||||
|
|
||||||
# Qwen (通义千问)
|
# Qwen
|
||||||
QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
|
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,
|
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
||||||
|
|
||||||
# Kimi (Moonshot)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
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
|
||||||
MODELSCOPE,
|
MODELSCOPE,
|
||||||
@@ -231,11 +235,12 @@ MODEL_LIST = [
|
|||||||
# LinkAI
|
# LinkAI
|
||||||
LINKAI_35, LINKAI_4_TURBO, LINKAI_4o,
|
LINKAI_35, LINKAI_4_TURBO, LINKAI_4o,
|
||||||
|
|
||||||
# 其他模型
|
# Other models
|
||||||
WEN_XIN, WEN_XIN_4, XUNFEI,
|
WEN_XIN, WEN_XIN_4, XUNFEI,
|
||||||
]
|
]
|
||||||
|
|
||||||
MODEL_LIST = MODEL_LIST + GITEE_AI_MODEL_LIST + MODELSCOPE_MODEL_LIST
|
MODEL_LIST = MODEL_LIST + GITEE_AI_MODEL_LIST + MODELSCOPE_MODEL_LIST
|
||||||
|
|
||||||
# channel
|
# channel
|
||||||
FEISHU = "feishu"
|
FEISHU = "feishu"
|
||||||
DINGTALK = "dingtalk"
|
DINGTALK = "dingtalk"
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import io
|
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):
|
def _reset_logger(log):
|
||||||
for handler in log.handlers:
|
for handler in log.handlers:
|
||||||
handler.close()
|
handler.close()
|
||||||
@@ -20,7 +33,7 @@ def _reset_logger(log):
|
|||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
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(
|
file_handle.setFormatter(
|
||||||
logging.Formatter(
|
logging.Formatter(
|
||||||
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
|
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
|
||||||
|
|
||||||
|
from common.utils import expand_path
|
||||||
from config import conf
|
from config import conf
|
||||||
|
|
||||||
|
|
||||||
class TmpDir(object):
|
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):
|
def __init__(self):
|
||||||
pathExists = os.path.exists(self.tmpFilePath)
|
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||||
if not pathExists:
|
self.tmpFilePath = os.path.join(ws_root, "tmp")
|
||||||
os.makedirs(self.tmpFilePath)
|
os.makedirs(self.tmpFilePath, exist_ok=True)
|
||||||
|
|
||||||
def path(self):
|
def path(self):
|
||||||
return str(self.tmpFilePath) + "/"
|
return str(self.tmpFilePath) + "/"
|
||||||
|
|||||||
@@ -27,10 +27,14 @@ def compress_imgfile(file, max_size):
|
|||||||
img = Image.open(file)
|
img = Image.open(file)
|
||||||
rgb_image = img.convert("RGB")
|
rgb_image = img.convert("RGB")
|
||||||
quality = 95
|
quality = 95
|
||||||
|
min_quality = 10
|
||||||
while True:
|
while True:
|
||||||
out_buf = io.BytesIO()
|
out_buf = io.BytesIO()
|
||||||
rgb_image.save(out_buf, "JPEG", quality=quality)
|
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
|
return out_buf
|
||||||
quality -= 5
|
quality -= 5
|
||||||
|
|
||||||
|
|||||||
@@ -40,5 +40,6 @@
|
|||||||
"agent_max_steps": 20,
|
"agent_max_steps": 20,
|
||||||
"enable_thinking": false,
|
"enable_thinking": false,
|
||||||
"reasoning_effort": "high",
|
"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 logging
|
||||||
import os
|
import os
|
||||||
import pickle
|
import pickle
|
||||||
|
import sys
|
||||||
|
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from common import i18n
|
from common import i18n
|
||||||
@@ -24,8 +25,11 @@ available_setting = {
|
|||||||
"open_ai_api_base": "https://api.openai.com/v1",
|
"open_ai_api_base": "https://api.openai.com/v1",
|
||||||
"claude_api_base": "https://api.anthropic.com/v1", # claude api base
|
"claude_api_base": "https://api.anthropic.com/v1", # claude api base
|
||||||
"gemini_api_base": "https://generativelanguage.googleapis.com", # gemini 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_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")
|
"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
|
"proxy": "", # proxy used by openai
|
||||||
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
|
# 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
|
"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 smart bot config (long connection mode)
|
||||||
"wecom_bot_id": "", # WeCom smart bot BotID
|
"wecom_bot_id": "", # WeCom smart bot BotID
|
||||||
"wecom_bot_secret": "", # WeCom smart bot long-connection secret
|
"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 config
|
||||||
"telegram_token": "", # Bot token from @BotFather
|
"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)
|
"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)
|
||||||
@@ -252,8 +261,8 @@ available_setting = {
|
|||||||
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
|
"reasoning_effort": "high", # Reasoning depth under thinking mode: "high" or "max"
|
||||||
"knowledge": True, # whether to enable the knowledge base feature
|
"knowledge": True, # whether to enable the knowledge base feature
|
||||||
# Self-evolution: review idle conversations to learn memory/skills. Flat keys.
|
# Self-evolution: review idle conversations to learn memory/skills. Flat keys.
|
||||||
"self_evolution_enabled": False, # master switch (off until release)
|
"self_evolution_enabled": False, # switch to enable/disable self-evolution
|
||||||
"self_evolution_idle_minutes": 15, # idle time before a session is reviewed
|
"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
|
"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
|
"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)
|
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
|
||||||
@@ -321,24 +330,37 @@ class Config(dict):
|
|||||||
config = Config()
|
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):
|
def drag_sensitive(config):
|
||||||
try:
|
try:
|
||||||
if isinstance(config, str):
|
if isinstance(config, str):
|
||||||
conf_dict: dict = json.loads(config)
|
conf_dict: dict = json.loads(config)
|
||||||
conf_dict_copy = copy.deepcopy(conf_dict)
|
conf_dict_copy = _mask_sensitive_recursive(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:]
|
|
||||||
return json.dumps(conf_dict_copy, indent=4)
|
return json.dumps(conf_dict_copy, indent=4)
|
||||||
|
|
||||||
elif isinstance(config, dict):
|
elif isinstance(config, dict):
|
||||||
config_copy = copy.deepcopy(config)
|
return _mask_sensitive_recursive(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
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(e)
|
logger.exception(e)
|
||||||
return config
|
return config
|
||||||
@@ -356,10 +378,16 @@ def load_config():
|
|||||||
logger.info(" \\____\\___/ \\_/\\_//_/ \\_\\__, |\\___|_| |_|\\__|")
|
logger.info(" \\____\\___/ \\_/\\_//_/ \\_\\__, |\\___|_| |_|\\__|")
|
||||||
logger.info(" |___/ ")
|
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):
|
if not os.path.exists(config_path):
|
||||||
logger.info("config file not found, falling back to config-template.json")
|
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)
|
config_str = read_file(config_path)
|
||||||
logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str)))
|
logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str)))
|
||||||
@@ -599,6 +627,34 @@ def get_root():
|
|||||||
return os.path.dirname(os.path.abspath(__file__))
|
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):
|
def read_file(path):
|
||||||
with open(path, mode="r", encoding="utf-8-sig") as f:
|
with open(path, mode="r", encoding="utf-8-sig") as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
@@ -609,13 +665,29 @@ def conf():
|
|||||||
|
|
||||||
|
|
||||||
def get_appdata_dir():
|
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):
|
if not os.path.exists(data_path):
|
||||||
logger.info("[INIT] data path not exists, create it: {}".format(data_path))
|
logger.info("[INIT] data path not exists, create it: {}".format(data_path))
|
||||||
os.makedirs(data_path)
|
os.makedirs(data_path)
|
||||||
return 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():
|
def subscribe_msg():
|
||||||
trigger_prefix = conf().get("single_chat_prefix", [""])[0]
|
trigger_prefix = conf().get("single_chat_prefix", [""])[0]
|
||||||
msg = conf().get("subscribe_msg", "")
|
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
|
||||||
229
desktop/src/renderer/src/pages/ChatPage.tsx
Normal file
229
desktop/src/renderer/src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import React, { useEffect, useRef, useCallback, useState } from 'react'
|
||||||
|
import { ChevronUp, Loader2 } from 'lucide-react'
|
||||||
|
import MessageBubble from '../components/MessageBubble'
|
||||||
|
import ChatInput, { type ChatInputHandle } from '../components/ChatInput'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import type { Attachment, ChatMessage } from '../types'
|
||||||
|
import { useChatStore } from '../store/chatStore'
|
||||||
|
import { useSessionStore } from '../store/sessionStore'
|
||||||
|
|
||||||
|
interface ChatPageProps {
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUGGESTIONS = ['example_sys', 'example_task', 'example_code'] as const
|
||||||
|
|
||||||
|
const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||||
|
const activeId = useSessionStore((s) => s.activeId)
|
||||||
|
const newSession = useSessionStore((s) => s.newSession)
|
||||||
|
const loadSessions = useSessionStore((s) => s.loadSessions)
|
||||||
|
|
||||||
|
const session = useChatStore((s) => s.sessions[activeId])
|
||||||
|
const send = useChatStore((s) => s.send)
|
||||||
|
const cancel = useChatStore((s) => s.cancel)
|
||||||
|
const regenerate = useChatStore((s) => s.regenerate)
|
||||||
|
const editUserMessage = useChatStore((s) => s.editUserMessage)
|
||||||
|
const deleteMessage = useChatStore((s) => s.deleteMessage)
|
||||||
|
const loadHistory = useChatStore((s) => s.loadHistory)
|
||||||
|
const ensureSession = useChatStore((s) => s.ensureSession)
|
||||||
|
|
||||||
|
const messages = session?.messages ?? []
|
||||||
|
const isStreaming = session?.isStreaming ?? false
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
|
const inputResetRef = useRef<ChatInputHandle>(null)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const titlePendingRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.setBaseUrl(baseUrl)
|
||||||
|
}, [baseUrl])
|
||||||
|
|
||||||
|
// Load history when switching to a session that hasn't been loaded yet.
|
||||||
|
useEffect(() => {
|
||||||
|
ensureSession(activeId)
|
||||||
|
const s = useChatStore.getState().sessions[activeId]
|
||||||
|
if (s && !s.historyLoaded && !s.isStreaming) {
|
||||||
|
loadHistory(activeId, 1)
|
||||||
|
}
|
||||||
|
}, [activeId, ensureSession, loadHistory])
|
||||||
|
|
||||||
|
const scrollToBottom = useCallback((smooth = true) => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
// Jump straight to the bottom; instant for session switches, smooth for streaming.
|
||||||
|
if (smooth) {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
} else {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Snap to the bottom instantly when switching sessions (no top-to-bottom animation).
|
||||||
|
// History may load a frame later, so keep snapping instantly until content arrives.
|
||||||
|
const lastSessionRef = useRef('')
|
||||||
|
const lastLenRef = useRef(0)
|
||||||
|
const pendingSnapRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
if (lastSessionRef.current !== activeId) {
|
||||||
|
lastSessionRef.current = activeId
|
||||||
|
lastLenRef.current = messages.length
|
||||||
|
pendingSnapRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingSnapRef.current) {
|
||||||
|
// Instant snap on switch and on the first content that lands afterwards.
|
||||||
|
lastLenRef.current = messages.length
|
||||||
|
requestAnimationFrame(() => scrollToBottom(false))
|
||||||
|
if (messages.length > 0) pendingSnapRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
||||||
|
const grew = messages.length !== lastLenRef.current
|
||||||
|
lastLenRef.current = messages.length
|
||||||
|
if (nearBottom || grew) scrollToBottom(true)
|
||||||
|
}, [messages, activeId, scrollToBottom])
|
||||||
|
|
||||||
|
const handleSend = useCallback(
|
||||||
|
async (text: string, attachments: Attachment[]) => {
|
||||||
|
const sid = activeId
|
||||||
|
const isFirst = (useChatStore.getState().sessions[sid]?.messages.length ?? 0) === 0
|
||||||
|
titlePendingRef.current = isFirst
|
||||||
|
await send(sid, text, attachments)
|
||||||
|
// After the first message, refresh the list and ask backend to title it.
|
||||||
|
if (isFirst) {
|
||||||
|
try {
|
||||||
|
await apiClient.generateSessionTitle(sid, text)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
loadSessions(1)
|
||||||
|
titlePendingRef.current = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeId, send, loadSessions]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleNewChat = useCallback(() => {
|
||||||
|
const id = newSession()
|
||||||
|
ensureSession(id)
|
||||||
|
loadHistory(id, 1)
|
||||||
|
}, [newSession, ensureSession, loadHistory])
|
||||||
|
|
||||||
|
const handleClearContext = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.clearContext(activeId)
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, [activeId])
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => cancel(activeId), [cancel, activeId])
|
||||||
|
|
||||||
|
const handleRegenerate = useCallback((id: string) => regenerate(activeId, id), [regenerate, activeId])
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const result = editUserMessage(activeId, id)
|
||||||
|
if (result && inputResetRef.current) inputResetRef.current(result.text, result.attachments)
|
||||||
|
},
|
||||||
|
[editUserMessage, activeId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
(msg: ChatMessage) => {
|
||||||
|
if (msg.userSeq != null) deleteMessage(activeId, msg.userSeq, true)
|
||||||
|
},
|
||||||
|
[deleteMessage, activeId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleScroll = useCallback(
|
||||||
|
async (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const el = e.currentTarget
|
||||||
|
const s = useChatStore.getState().sessions[activeId]
|
||||||
|
if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) {
|
||||||
|
setLoadingMore(true)
|
||||||
|
const prevHeight = el.scrollHeight
|
||||||
|
await loadHistory(activeId, s.historyPage + 1)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
// preserve scroll position after prepending older messages
|
||||||
|
el.scrollTop = el.scrollHeight - prevHeight
|
||||||
|
setLoadingMore(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeId, loadHistory, loadingMore, isStreaming]
|
||||||
|
)
|
||||||
|
|
||||||
|
const isEmpty = messages.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
<div ref={scrollRef} className="flex-1 overflow-y-auto" onScroll={handleScroll}>
|
||||||
|
{loadingMore && (
|
||||||
|
<div className="flex items-center justify-center py-3 text-content-tertiary">
|
||||||
|
<Loader2 size={16} className="animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEmpty ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full px-6 py-12">
|
||||||
|
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mb-5 shadow-md" />
|
||||||
|
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
||||||
|
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||||
|
{SUGGESTIONS.map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => handleSend(t(`${key}_text` as Parameters<typeof t>[0]), [])}
|
||||||
|
className="text-left bg-surface border border-default rounded-xl p-3.5 cursor-pointer hover:border-accent hover:shadow-sm transition-all"
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm text-content mb-1">
|
||||||
|
{t(`${key}_title` as Parameters<typeof t>[0])}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">
|
||||||
|
{t(`${key}_text` as Parameters<typeof t>[0])}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-3 max-w-3xl mx-auto">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<MessageBubble
|
||||||
|
key={msg.id}
|
||||||
|
message={msg}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Jump-to-bottom affordance could go here in a later pass */}
|
||||||
|
|
||||||
|
<ChatInput
|
||||||
|
onSend={handleSend}
|
||||||
|
onNewChat={handleNewChat}
|
||||||
|
onStop={handleStop}
|
||||||
|
onClearContext={handleClearContext}
|
||||||
|
isStreaming={isStreaming}
|
||||||
|
sessionId={activeId}
|
||||||
|
ref={inputResetRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChatPage
|
||||||
385
desktop/src/renderer/src/pages/KnowledgePage.tsx
Normal file
385
desktop/src/renderer/src/pages/KnowledgePage.tsx
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
FileText,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
MessageSquarePlus,
|
||||||
|
Network,
|
||||||
|
Files,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import type { KnowledgeDir, KnowledgeFile, KnowledgeList, KnowledgeGraph as KnowledgeGraphData } from '../types'
|
||||||
|
import Markdown from '../components/Markdown'
|
||||||
|
import KnowledgeGraph from '../components/KnowledgeGraph'
|
||||||
|
|
||||||
|
interface KnowledgePageProps {
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'docs' | 'graph'
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the first document (root files first, then a DFS over the tree).
|
||||||
|
function firstFile(list: KnowledgeList): { path: string; title: string } | null {
|
||||||
|
const root = list.root_files?.[0]
|
||||||
|
if (root) return { path: root.name, title: root.title || root.name }
|
||||||
|
const walk = (dir: KnowledgeDir, prefix: string): { path: string; title: string } | null => {
|
||||||
|
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||||
|
const f = dir.files[0]
|
||||||
|
if (f) return { path: `${dirPath}/${f.name}`, title: f.title || f.name }
|
||||||
|
for (const c of dir.children) {
|
||||||
|
const hit = walk(c, dirPath)
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
for (const d of list.tree || []) {
|
||||||
|
const hit = walk(d, '')
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [tab, setTab] = useState<Tab>('docs')
|
||||||
|
const [data, setData] = useState<KnowledgeList | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const [activePath, setActivePath] = useState<string | null>(null)
|
||||||
|
const [docTitle, setDocTitle] = useState('')
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [docLoading, setDocLoading] = useState(false)
|
||||||
|
|
||||||
|
const [graph, setGraph] = useState<KnowledgeGraphData | null>(null)
|
||||||
|
const [graphLoading, setGraphLoading] = useState(false)
|
||||||
|
|
||||||
|
const openDoc = useCallback(async (path: string, title: string) => {
|
||||||
|
setActivePath(path)
|
||||||
|
setDocTitle(title)
|
||||||
|
setDocLoading(true)
|
||||||
|
setContent('')
|
||||||
|
try {
|
||||||
|
const res = await apiClient.readKnowledge(path)
|
||||||
|
setContent(res.content || '')
|
||||||
|
} catch {
|
||||||
|
setContent(`> ${t('knowledge_doc_load_error')}`)
|
||||||
|
} finally {
|
||||||
|
setDocLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.setBaseUrl(baseUrl)
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const fresh = await apiClient.getKnowledgeList()
|
||||||
|
if (cancelled) return
|
||||||
|
setData(fresh)
|
||||||
|
// Auto-open the first document so the viewer isn't empty on entry.
|
||||||
|
const first = firstFile(fresh)
|
||||||
|
if (first) void openDoc(first.path, first.title)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load knowledge:', e)
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [baseUrl, openDoc])
|
||||||
|
|
||||||
|
const loadGraph = useCallback(async () => {
|
||||||
|
if (graph) return
|
||||||
|
setGraphLoading(true)
|
||||||
|
try {
|
||||||
|
setGraph(await apiClient.getKnowledgeGraph())
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load graph:', e)
|
||||||
|
setGraph({ nodes: [], links: [] })
|
||||||
|
} finally {
|
||||||
|
setGraphLoading(false)
|
||||||
|
}
|
||||||
|
}, [graph])
|
||||||
|
|
||||||
|
const switchTab = (next: Tab) => {
|
||||||
|
setTab(next)
|
||||||
|
if (next === 'graph') void loadGraph()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jump from a graph node to its document.
|
||||||
|
const onGraphSelect = useCallback(
|
||||||
|
(id: string, label: string) => {
|
||||||
|
setTab('docs')
|
||||||
|
void openDoc(id, label)
|
||||||
|
},
|
||||||
|
[openDoc]
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalPages = data?.stats?.pages ?? 0
|
||||||
|
const statsLabel = useMemo(() => {
|
||||||
|
if (!data) return ''
|
||||||
|
return t('knowledge_stats')
|
||||||
|
.replace('{pages}', String(data.stats?.pages ?? 0))
|
||||||
|
.replace('{size}', formatSize(data.stats?.size ?? 0))
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-content-tertiary">
|
||||||
|
<Loader2 size={18} className="animate-spin mr-2" />
|
||||||
|
{t('knowledge_loading')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = !data || totalPages === 0
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center px-6 text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mb-5">
|
||||||
|
<Files size={26} />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-content mb-2">
|
||||||
|
{data?.enabled === false ? t('knowledge_disabled') : t('knowledge_empty')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-content-tertiary max-w-md mb-6">{t('knowledge_empty_guide')}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<MessageSquarePlus size={15} />
|
||||||
|
{t('knowledge_go_chat')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 pt-5 pb-3 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-content">{t('knowledge_title')}</h2>
|
||||||
|
<p className="text-xs text-content-tertiary mt-1">{statsLabel}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 bg-inset rounded-btn p-0.5">
|
||||||
|
<TabBtn icon={Files} label={t('knowledge_tab_docs')} active={tab === 'docs'} onClick={() => switchTab('docs')} />
|
||||||
|
<TabBtn
|
||||||
|
icon={Network}
|
||||||
|
label={t('knowledge_tab_graph')}
|
||||||
|
active={tab === 'graph'}
|
||||||
|
onClick={() => switchTab('graph')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'docs' ? (
|
||||||
|
<div className="flex-1 flex min-h-0 border-t border-default">
|
||||||
|
{/* Tree sidebar */}
|
||||||
|
<div className="w-72 flex-shrink-0 flex flex-col border-r border-default min-h-0">
|
||||||
|
<div className="p-3 flex-shrink-0">
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-tertiary" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder={t('knowledge_search')}
|
||||||
|
className="w-full pl-8 pr-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-2 pb-3">
|
||||||
|
<Tree
|
||||||
|
data={data}
|
||||||
|
search={search.trim().toLowerCase()}
|
||||||
|
activePath={activePath}
|
||||||
|
onOpen={openDoc}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document viewer */}
|
||||||
|
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||||
|
{!activePath ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-content-tertiary">
|
||||||
|
<FileText size={28} className="mb-3 opacity-50" />
|
||||||
|
<p className="text-sm">{t('knowledge_select_hint')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||||
|
<h1 className="text-lg font-semibold text-content mb-1">{docTitle}</h1>
|
||||||
|
<p className="text-xs text-content-tertiary mb-5 font-mono">{activePath}</p>
|
||||||
|
{docLoading ? (
|
||||||
|
<div className="flex items-center text-content-tertiary py-8">
|
||||||
|
<Loader2 size={16} className="animate-spin mr-2" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Markdown content={content} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 border-t border-default relative">
|
||||||
|
{graphLoading ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-content-tertiary">
|
||||||
|
<Loader2 size={18} className="animate-spin mr-2" />
|
||||||
|
</div>
|
||||||
|
) : graph && graph.nodes.length > 0 ? (
|
||||||
|
<KnowledgeGraph data={graph} onSelect={onGraphSelect} />
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-content-tertiary text-sm">
|
||||||
|
{t('knowledge_graph_empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabBtn: React.FC<{
|
||||||
|
icon: LucideIcon
|
||||||
|
label: string
|
||||||
|
active: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}> = ({ icon: Icon, label, active, onClick }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[6px] text-sm font-medium cursor-pointer transition-colors ${
|
||||||
|
active ? 'bg-surface text-content shadow-sm' : 'text-content-tertiary hover:text-content-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Tree rendering --------------------------------------------------------
|
||||||
|
|
||||||
|
const Tree: React.FC<{
|
||||||
|
data: KnowledgeList
|
||||||
|
search: string
|
||||||
|
activePath: string | null
|
||||||
|
onOpen: (path: string, title: string) => void
|
||||||
|
}> = ({ data, search, activePath, onOpen }) => {
|
||||||
|
const matches = (f: KnowledgeFile) => !search || f.title.toLowerCase().includes(search) || f.name.toLowerCase().includes(search)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{(data.root_files || []).filter(matches).map((f) => (
|
||||||
|
<FileLeaf
|
||||||
|
key={f.name}
|
||||||
|
path={f.name}
|
||||||
|
title={f.title || f.name}
|
||||||
|
active={activePath === f.name}
|
||||||
|
onOpen={onOpen}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{(data.tree || []).map((dir) => (
|
||||||
|
<DirNode key={dir.dir} dir={dir} prefix="" search={search} activePath={activePath} onOpen={onOpen} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count files in a dir subtree that match the search.
|
||||||
|
function countMatches(dir: KnowledgeDir, search: string): number {
|
||||||
|
const own = dir.files.filter(
|
||||||
|
(f) => !search || f.title.toLowerCase().includes(search) || f.name.toLowerCase().includes(search)
|
||||||
|
).length
|
||||||
|
return own + dir.children.reduce((acc, c) => acc + countMatches(c, search), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const DirNode: React.FC<{
|
||||||
|
dir: KnowledgeDir
|
||||||
|
prefix: string
|
||||||
|
search: string
|
||||||
|
activePath: string | null
|
||||||
|
onOpen: (path: string, title: string) => void
|
||||||
|
}> = ({ dir, prefix, search, activePath, onOpen }) => {
|
||||||
|
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||||
|
const [open, setOpen] = useState(true)
|
||||||
|
const matchCount = search ? countMatches(dir, search) : dir.files.length + dir.children.length
|
||||||
|
if (search && matchCount === 0) return null
|
||||||
|
|
||||||
|
const visibleFiles = dir.files.filter(
|
||||||
|
(f) => !search || f.title.toLowerCase().includes(search) || f.name.toLowerCase().includes(search)
|
||||||
|
)
|
||||||
|
const expanded = open || !!search
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="w-full flex items-center gap-1 px-2 py-1.5 rounded-btn text-sm text-content-secondary hover:bg-surface-2 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||||
|
<span className="truncate font-medium">{dir.dir}</span>
|
||||||
|
<span className="ml-auto text-xs text-content-tertiary">{matchCount}</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="ml-3 border-l border-default pl-1.5 space-y-0.5">
|
||||||
|
{visibleFiles.map((f) => {
|
||||||
|
const fpath = `${dirPath}/${f.name}`
|
||||||
|
return (
|
||||||
|
<FileLeaf
|
||||||
|
key={fpath}
|
||||||
|
path={fpath}
|
||||||
|
title={f.title || f.name}
|
||||||
|
active={activePath === fpath}
|
||||||
|
onOpen={onOpen}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{dir.children.map((c) => (
|
||||||
|
<DirNode
|
||||||
|
key={c.dir}
|
||||||
|
dir={c}
|
||||||
|
prefix={dirPath}
|
||||||
|
search={search}
|
||||||
|
activePath={activePath}
|
||||||
|
onOpen={onOpen}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileLeaf: React.FC<{
|
||||||
|
path: string
|
||||||
|
title: string
|
||||||
|
active: boolean
|
||||||
|
onOpen: (path: string, title: string) => void
|
||||||
|
}> = ({ path, title, active, onOpen }) => (
|
||||||
|
<button
|
||||||
|
onClick={() => onOpen(path, title)}
|
||||||
|
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-sm cursor-pointer transition-colors text-left ${
|
||||||
|
active ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FileText size={13} className="flex-shrink-0 opacity-70" />
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default KnowledgePage
|
||||||
104
desktop/src/renderer/src/pages/LogsPage.tsx
Normal file
104
desktop/src/renderer/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
|
||||||
|
interface LogsPageProps {
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const LogsPage: React.FC<LogsPageProps> = ({ baseUrl }) => {
|
||||||
|
const [logs, setLogs] = useState<string[]>([])
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.setBaseUrl(baseUrl)
|
||||||
|
|
||||||
|
const es = apiClient.createLogStream()
|
||||||
|
|
||||||
|
es.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data)
|
||||||
|
if (data.type === 'init' && data.content) {
|
||||||
|
setLogs(data.content.split('\n').filter(Boolean))
|
||||||
|
} else if (data.type === 'line' && data.content) {
|
||||||
|
setLogs((prev) => {
|
||||||
|
const next = [...prev, data.content]
|
||||||
|
if (next.length > 2000) return next.slice(-1500)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => es.close()
|
||||||
|
}, [baseUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoScroll && containerRef.current) {
|
||||||
|
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [logs, autoScroll])
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!containerRef.current) return
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = containerRef.current
|
||||||
|
setAutoScroll(scrollHeight - scrollTop - clientHeight < 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLogColor = (line: string) => {
|
||||||
|
if (line.includes('ERROR') || line.includes('error')) return 'text-red-400'
|
||||||
|
if (line.includes('WARNING') || line.includes('warn')) return 'text-amber-400'
|
||||||
|
if (line.includes('DEBUG')) return 'text-slate-500'
|
||||||
|
return 'text-slate-300'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="max-w-5xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('logs_title')}</h2>
|
||||||
|
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">{t('logs_desc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terminal-style log viewer */}
|
||||||
|
<div className="bg-slate-900 rounded-xl border border-slate-700 overflow-hidden shadow-lg">
|
||||||
|
{/* Terminal header */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-800 border-b border-slate-700">
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-red-500/80" />
|
||||||
|
<span className="w-3 h-3 rounded-full bg-amber-500/80" />
|
||||||
|
<span className="w-3 h-3 rounded-full bg-emerald-500/80" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400 ml-2 font-mono">run.log</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
<span className="text-xs text-slate-500">{t('logs_live')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log content */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
className="p-4 overflow-y-auto font-mono text-xs leading-relaxed whitespace-pre-wrap break-all"
|
||||||
|
style={{ height: 'calc(100vh - 272px)' }}
|
||||||
|
>
|
||||||
|
{logs.length > 0 ? (
|
||||||
|
logs.map((line, i) => (
|
||||||
|
<div key={i} className={getLogColor(line)}>{line}</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-500">{t('logs_connecting')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LogsPage
|
||||||
256
desktop/src/renderer/src/pages/MemoryPage.tsx
Normal file
256
desktop/src/renderer/src/pages/MemoryPage.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Loader2, ArrowLeft, Brain, Sprout, FileText, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import type { MemoryItem, MemoryCategory } from '../types'
|
||||||
|
import Markdown from '../components/Markdown'
|
||||||
|
|
||||||
|
interface MemoryPageProps {
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'files' | 'evolution'
|
||||||
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
const formatSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a file's `type` to its display badge.
|
||||||
|
const typeBadge = (type: string): { label: string; cls: string } => {
|
||||||
|
switch (type) {
|
||||||
|
case 'global':
|
||||||
|
return { label: t('memory_type_global'), cls: 'bg-accent-soft text-accent' }
|
||||||
|
case 'evolution':
|
||||||
|
return { label: t('memory_type_evolution'), cls: 'bg-inset text-success' }
|
||||||
|
case 'dream':
|
||||||
|
return { label: t('memory_type_dream'), cls: 'bg-inset text-info' }
|
||||||
|
default:
|
||||||
|
return { label: t('memory_type_daily'), cls: 'bg-inset text-content-secondary' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MemoryPage: React.FC<MemoryPageProps> = ({ baseUrl }) => {
|
||||||
|
const [tab, setTab] = useState<Tab>('files')
|
||||||
|
const [items, setItems] = useState<MemoryItem[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const [viewing, setViewing] = useState<string | null>(null)
|
||||||
|
const [content, setContent] = useState('')
|
||||||
|
const [docLoading, setDocLoading] = useState(false)
|
||||||
|
|
||||||
|
const category: MemoryCategory = tab === 'evolution' ? 'evolution' : 'memory'
|
||||||
|
|
||||||
|
const loadList = useCallback(
|
||||||
|
async (cat: MemoryCategory, p: number) => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await apiClient.getMemoryList(p, PAGE_SIZE, cat)
|
||||||
|
setItems(data.list || [])
|
||||||
|
setTotal(data.total || 0)
|
||||||
|
setPage(data.page || p)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load memory:', err)
|
||||||
|
setItems([])
|
||||||
|
setTotal(0)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.setBaseUrl(baseUrl)
|
||||||
|
void loadList(category, 1)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [baseUrl, tab])
|
||||||
|
|
||||||
|
const openFile = async (item: MemoryItem) => {
|
||||||
|
// In the evolution tab a file lives in its own dir (dream vs evolution).
|
||||||
|
const fileCategory: MemoryCategory =
|
||||||
|
item.type === 'dream' || item.type === 'evolution' ? (item.type as MemoryCategory) : category
|
||||||
|
setViewing(item.filename)
|
||||||
|
setDocLoading(true)
|
||||||
|
setContent('')
|
||||||
|
try {
|
||||||
|
const text = await apiClient.getMemoryContent(item.filename, fileCategory)
|
||||||
|
setContent(text)
|
||||||
|
} catch {
|
||||||
|
setContent(`> ${t('memory_doc_load_error')}`)
|
||||||
|
} finally {
|
||||||
|
setDocLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 pt-5 pb-3 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-content">{t('memory_title')}</h2>
|
||||||
|
<p className="text-xs text-content-tertiary mt-1">{t('memory_desc')}</p>
|
||||||
|
</div>
|
||||||
|
{!viewing && (
|
||||||
|
<div className="flex items-center gap-1 bg-inset rounded-btn p-0.5">
|
||||||
|
<TabBtn icon={Brain} label={t('memory_tab_files')} active={tab === 'files'} onClick={() => setTab('files')} />
|
||||||
|
<TabBtn
|
||||||
|
icon={Sprout}
|
||||||
|
label={t('memory_tab_dreams')}
|
||||||
|
active={tab === 'evolution'}
|
||||||
|
onClick={() => setTab('evolution')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{viewing ? (
|
||||||
|
/* File viewer */
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 border-t border-default">
|
||||||
|
<div className="flex items-center gap-3 px-6 py-3 flex-shrink-0 border-b border-subtle">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewing(null)}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-sm text-content-secondary hover:bg-inset border border-strong transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={14} />
|
||||||
|
{t('memory_back')}
|
||||||
|
</button>
|
||||||
|
<h3 className="text-sm font-semibold text-content font-mono truncate">{viewing}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="max-w-3xl mx-auto px-6 py-6">
|
||||||
|
{docLoading ? (
|
||||||
|
<div className="flex items-center text-content-tertiary py-8">
|
||||||
|
<Loader2 size={16} className="animate-spin mr-2" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Markdown content={content} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* List */
|
||||||
|
<div className="flex-1 overflow-y-auto border-t border-default">
|
||||||
|
<div className="max-w-4xl 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('memory_loading')}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-content-tertiary">
|
||||||
|
{tab === 'evolution' ? <Sprout size={28} className="mb-3 opacity-60" /> : <Brain size={28} className="mb-3 opacity-60" />}
|
||||||
|
<p className="text-sm">{tab === 'evolution' ? t('memory_empty_evolution') : t('memory_empty_files')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-card border border-default overflow-hidden bg-surface">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-default">
|
||||||
|
<Th>{t('memory_col_name')}</Th>
|
||||||
|
<Th>{t('memory_col_type')}</Th>
|
||||||
|
<Th>{t('memory_col_size')}</Th>
|
||||||
|
<Th>{t('memory_col_updated')}</Th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => {
|
||||||
|
const badge = typeBadge(item.type)
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={item.filename}
|
||||||
|
onClick={() => openFile(item)}
|
||||||
|
className="border-b border-subtle last:border-0 hover:bg-inset cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-sm font-mono text-content-secondary">
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<FileText size={13} className="text-content-tertiary flex-shrink-0" />
|
||||||
|
{item.filename}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${badge.cls}`}>{badge.label}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-content-tertiary">{formatSize(item.size)}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-content-tertiary">{item.updated_at}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between mt-4 text-sm text-content-tertiary">
|
||||||
|
<span>
|
||||||
|
{page} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<PageBtn icon={ChevronLeft} label={t('memory_prev')} disabled={page <= 1} onClick={() => loadList(category, page - 1)} />
|
||||||
|
<PageBtn
|
||||||
|
icon={ChevronRight}
|
||||||
|
label={t('memory_next')}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => loadList(category, page + 1)}
|
||||||
|
iconRight
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Th: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||||
|
<th className="text-left px-4 py-2.5 text-xs font-semibold uppercase tracking-wider text-content-tertiary">{children}</th>
|
||||||
|
)
|
||||||
|
|
||||||
|
const TabBtn: React.FC<{ icon: LucideIcon; label: string; active: boolean; onClick: () => void }> = ({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[6px] text-sm font-medium cursor-pointer transition-colors ${
|
||||||
|
active ? 'bg-surface text-content shadow-sm' : 'text-content-tertiary hover:text-content-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
const PageBtn: React.FC<{
|
||||||
|
icon: LucideIcon
|
||||||
|
label: string
|
||||||
|
disabled: boolean
|
||||||
|
onClick: () => void
|
||||||
|
iconRight?: boolean
|
||||||
|
}> = ({ icon: Icon, label, disabled, onClick, iconRight }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1 rounded-btn border border-strong text-xs text-content-secondary hover:bg-inset disabled:opacity-40 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{!iconRight && <Icon size={13} />}
|
||||||
|
{label}
|
||||||
|
{iconRight && <Icon size={13} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default MemoryPage
|
||||||
20
desktop/src/renderer/src/pages/PlaceholderPage.tsx
Normal file
20
desktop/src/renderer/src/pages/PlaceholderPage.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Construction } from 'lucide-react'
|
||||||
|
|
||||||
|
interface PlaceholderPageProps {
|
||||||
|
title: string
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Temporary page for routes that will be implemented in later phases. */
|
||||||
|
const PlaceholderPage: React.FC<PlaceholderPageProps> = ({ title, hint }) => (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center px-8">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-surface-2 flex items-center justify-center mb-4">
|
||||||
|
<Construction size={26} className="text-content-tertiary" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-content mb-1">{title}</h2>
|
||||||
|
<p className="text-sm text-content-tertiary max-w-sm">{hint || 'Coming soon in this iteration.'}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default PlaceholderPage
|
||||||
60
desktop/src/renderer/src/pages/SettingsPage.tsx
Normal file
60
desktop/src/renderer/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import BasicSettings from './settings/BasicSettings'
|
||||||
|
import ModelsTab from './settings/ModelsTab'
|
||||||
|
|
||||||
|
interface SettingsPageProps {
|
||||||
|
baseUrl: string
|
||||||
|
onLangChange?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tab = 'basic' | 'models'
|
||||||
|
|
||||||
|
const SettingsPage: React.FC<SettingsPageProps> = ({ baseUrl, onLangChange }) => {
|
||||||
|
const location = useLocation()
|
||||||
|
// Allow deep-linking to the models tab via /settings?tab=models.
|
||||||
|
const initial: Tab = new URLSearchParams(location.search).get('tab') === 'models' ? 'models' : 'basic'
|
||||||
|
const [tab, setTab] = useState<Tab>(initial)
|
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string }[] = [
|
||||||
|
{ key: 'basic', label: t('settings_tab_basic') },
|
||||||
|
{ key: 'models', label: t('settings_tab_models') },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="mb-5">
|
||||||
|
<h2 className="text-xl font-bold text-content">{t('menu_settings')}</h2>
|
||||||
|
<p className="text-sm text-content-tertiary mt-1">{t('config_desc')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex items-center gap-1 mb-6 border-b border-default">
|
||||||
|
{tabs.map((tb) => (
|
||||||
|
<button
|
||||||
|
key={tb.key}
|
||||||
|
onClick={() => setTab(tb.key)}
|
||||||
|
className={`relative px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors -mb-px border-b-2 ${
|
||||||
|
tab === tb.key
|
||||||
|
? 'text-accent border-accent'
|
||||||
|
: 'text-content-tertiary border-transparent hover:text-content-secondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tb.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'basic' ? (
|
||||||
|
<BasicSettings baseUrl={baseUrl} onLangChange={onLangChange} onOpenModels={() => setTab('models')} />
|
||||||
|
) : (
|
||||||
|
<ModelsTab baseUrl={baseUrl} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsPage
|
||||||
142
desktop/src/renderer/src/pages/SkillsPage.tsx
Normal file
142
desktop/src/renderer/src/pages/SkillsPage.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Loader2, Wrench, Zap, Puzzle } from 'lucide-react'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import type { ToolInfo, SkillInfo } from '../types'
|
||||||
|
import { Toggle } from './settings/primitives'
|
||||||
|
|
||||||
|
interface SkillsPageProps {
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SKILL_HUB_URL = 'https://skills.cowagent.ai/'
|
||||||
|
|
||||||
|
const SkillsPage: React.FC<SkillsPageProps> = ({ baseUrl }) => {
|
||||||
|
const [tools, setTools] = useState<ToolInfo[]>([])
|
||||||
|
const [skills, setSkills] = useState<SkillInfo[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const [toolsData, skillsData] = await Promise.all([apiClient.getTools(), apiClient.getSkills()])
|
||||||
|
setTools(toolsData || [])
|
||||||
|
setSkills(skillsData || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load skills:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.setBaseUrl(baseUrl)
|
||||||
|
void loadData()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [baseUrl])
|
||||||
|
|
||||||
|
const toggle = async (skill: SkillInfo, enabled: boolean) => {
|
||||||
|
// Optimistic flip; revert on failure.
|
||||||
|
setSkills((prev) => prev.map((s) => (s.name === skill.name ? { ...s, enabled } : s)))
|
||||||
|
try {
|
||||||
|
const res = await apiClient.toggleSkill(skill.name, enabled ? 'open' : 'close')
|
||||||
|
if (res.status !== 'success') throw new Error()
|
||||||
|
} catch {
|
||||||
|
setSkills((prev) => prev.map((s) => (s.name === skill.name ? { ...s, enabled: !enabled } : s)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
<div className="flex items-center justify-between px-6 pt-5 pb-3 flex-shrink-0">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-content">{t('skills_title')}</h2>
|
||||||
|
<p className="text-xs text-content-tertiary mt-1">{t('skills_desc')}</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={SKILL_HUB_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn text-xs font-medium text-accent bg-accent-soft hover:bg-accent-soft transition-colors"
|
||||||
|
>
|
||||||
|
<Puzzle size={12} />
|
||||||
|
{t('skills_hub_btn')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto border-t border-default">
|
||||||
|
<div className="max-w-4xl 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('skills_loading')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<Section title={t('tools_section_title')} count={tools.length}>
|
||||||
|
{tools.length === 0 ? (
|
||||||
|
<Empty text={t('tools_empty')} />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{tools.map((tool) => (
|
||||||
|
<div key={tool.name} className="rounded-card border border-default bg-surface p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
|
<Wrench size={13} className="text-content-tertiary flex-shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-content font-mono truncate">{tool.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">{tool.description || '--'}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('skills_section_title')} count={skills.length}>
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<Empty text={t('skills_empty')} />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<div key={skill.name} className="rounded-card border border-default bg-surface p-4 flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 rounded-lg bg-inset flex items-center justify-center flex-shrink-0">
|
||||||
|
<Zap size={15} className={skill.enabled ? 'text-accent' : 'text-content-tertiary'} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium text-content truncate flex-1">
|
||||||
|
{skill.display_name || skill.name}
|
||||||
|
</span>
|
||||||
|
<Toggle checked={skill.enabled} onChange={(v) => toggle(skill, v)} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">{skill.description || '--'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Section: React.FC<{ title: string; count: number; children: React.ReactNode }> = ({ title, count, children }) => (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-content-tertiary">{title}</span>
|
||||||
|
{count > 0 && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-full text-xs bg-inset text-content-tertiary min-w-[20px] text-center">{count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Empty: React.FC<{ text: string }> = ({ text }) => (
|
||||||
|
<p className="text-sm text-content-tertiary py-2">{text}</p>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default SkillsPage
|
||||||
314
desktop/src/renderer/src/pages/TasksPage.tsx
Normal file
314
desktop/src/renderer/src/pages/TasksPage.tsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { Loader2, Clock, CalendarClock } from 'lucide-react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import apiClient from '../api/client'
|
||||||
|
import type { SchedulerTask, TaskSchedule, TaskAction } from '../types'
|
||||||
|
import { Modal, Btn, Toggle, TextInput, Dropdown } from './settings/primitives'
|
||||||
|
|
||||||
|
interface TasksPageProps {
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Human-readable schedule summary, mirroring the web console.
|
||||||
|
const scheduleSummary = (s: TaskSchedule): string => {
|
||||||
|
if (s.type === 'cron') return s.expression || 'cron'
|
||||||
|
if (s.type === 'interval') {
|
||||||
|
const sec = s.seconds || 0
|
||||||
|
const h = Math.floor(sec / 3600)
|
||||||
|
const m = Math.floor((sec % 3600) / 60)
|
||||||
|
const r = sec % 60
|
||||||
|
const parts: string[] = []
|
||||||
|
if (h) parts.push(`${h}h`)
|
||||||
|
if (m) parts.push(`${m}m`)
|
||||||
|
if (r || parts.length === 0) parts.push(`${r}s`)
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
return s.type || 'once'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatNextRun = (iso?: string): string => {
|
||||||
|
if (!iso) return '--'
|
||||||
|
const d = new Date(iso)
|
||||||
|
return isNaN(d.getTime()) ? '--' : d.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const TasksPage: React.FC<TasksPageProps> = ({ baseUrl }) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [tasks, setTasks] = useState<SchedulerTask[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [editing, setEditing] = useState<SchedulerTask | null>(null)
|
||||||
|
|
||||||
|
const loadTasks = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await apiClient.getSchedulerTasks()
|
||||||
|
setTasks(data || [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load tasks:', err)
|
||||||
|
setTasks([])
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.setBaseUrl(baseUrl)
|
||||||
|
void loadTasks()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [baseUrl])
|
||||||
|
|
||||||
|
const toggle = async (task: SchedulerTask, enabled: boolean) => {
|
||||||
|
// Optimistic flip; revert on failure.
|
||||||
|
setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, enabled } : x)))
|
||||||
|
try {
|
||||||
|
await apiClient.toggleTask(task.id, enabled)
|
||||||
|
} catch {
|
||||||
|
setTasks((prev) => prev.map((x) => (x.id === task.id ? { ...x, enabled: !enabled } : x)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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('tasks_title')}</h2>
|
||||||
|
<p className="text-xs text-content-tertiary mt-1">{t('tasks_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('tasks_loading')}
|
||||||
|
</div>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<CalendarClock size={32} className="mb-3 text-content-tertiary opacity-60" />
|
||||||
|
<p className="text-content font-medium mb-1">{t('tasks_empty')}</p>
|
||||||
|
<p className="text-sm text-content-tertiary max-w-sm mb-5">{t('tasks_empty_guide')}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
{t('tasks_go_chat')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{tasks.map((task) => {
|
||||||
|
const content = task.action?.content || task.action?.task_description || ''
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => setEditing(task)}
|
||||||
|
className={`rounded-card border border-default bg-surface p-4 cursor-pointer hover:border-strong transition-colors ${
|
||||||
|
task.enabled ? '' : 'opacity-60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${task.enabled ? 'bg-accent' : 'bg-content-tertiary'}`} />
|
||||||
|
<span className="font-medium text-sm text-content truncate">{task.name || task.id}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className="text-xs font-mono text-content-tertiary">{scheduleSummary(task.schedule)}</span>
|
||||||
|
</div>
|
||||||
|
{content && <p className="text-xs text-content-secondary mb-2 line-clamp-2">{content}</p>}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 text-xs text-content-tertiary"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Clock size={12} />
|
||||||
|
<span>
|
||||||
|
{t('tasks_next_run')}: {formatNextRun(task.next_run_at)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Toggle checked={task.enabled} onChange={(v) => toggle(task, v)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<TaskEditModal
|
||||||
|
task={editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditing(null)
|
||||||
|
void loadTasks()
|
||||||
|
}}
|
||||||
|
onDeleted={() => {
|
||||||
|
setEditing(null)
|
||||||
|
void loadTasks()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskEditModal: React.FC<{
|
||||||
|
task: SchedulerTask
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => void
|
||||||
|
onDeleted: () => void
|
||||||
|
}> = ({ task, onClose, onSaved, onDeleted }) => {
|
||||||
|
const [name, setName] = useState(task.name || '')
|
||||||
|
const [enabled, setEnabled] = useState(task.enabled)
|
||||||
|
const [schedType, setSchedType] = useState<TaskSchedule['type']>(task.schedule.type || 'cron')
|
||||||
|
const [cron, setCron] = useState(task.schedule.expression || '')
|
||||||
|
const [interval, setIntervalVal] = useState(task.schedule.seconds ? String(task.schedule.seconds) : '')
|
||||||
|
const [runAt, setRunAt] = useState(task.schedule.run_at ? task.schedule.run_at.slice(0, 16) : '')
|
||||||
|
const [actionType, setActionType] = useState<TaskAction['type']>(task.action.type || 'send_message')
|
||||||
|
const [content, setContent] = useState(task.action.content || task.action.task_description || '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const buildSchedule = (): TaskSchedule => {
|
||||||
|
if (schedType === 'cron') return { type: 'cron', expression: cron.trim() }
|
||||||
|
if (schedType === 'interval') return { type: 'interval', seconds: Number(interval) || 0 }
|
||||||
|
return { type: 'once', run_at: runAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildAction = (): TaskAction => {
|
||||||
|
const a: TaskAction = { ...task.action, type: actionType }
|
||||||
|
if (actionType === 'send_message') a.content = content
|
||||||
|
else a.task_description = content
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await apiClient.updateTask(task.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
enabled,
|
||||||
|
schedule: buildSchedule(),
|
||||||
|
action: buildAction(),
|
||||||
|
})
|
||||||
|
onSaved()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : t('task_save_error'))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const del = async () => {
|
||||||
|
if (!window.confirm(t('task_delete_confirm'))) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await apiClient.deleteTask(task.id)
|
||||||
|
onDeleted()
|
||||||
|
} catch {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title={t('task_edit_title')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Btn variant="danger" onClick={del} disabled={saving} className="mr-auto">
|
||||||
|
{t('task_delete')}
|
||||||
|
</Btn>
|
||||||
|
<Btn variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
|
{t('task_cancel')}
|
||||||
|
</Btn>
|
||||||
|
<Btn variant="primary" onClick={save} disabled={saving}>
|
||||||
|
{t('task_save')}
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Field label={t('task_name')}>
|
||||||
|
<TextInput value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-content-secondary">{t('task_enabled')}</span>
|
||||||
|
<Toggle checked={enabled} onChange={setEnabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label={t('task_schedule_type')}>
|
||||||
|
<Dropdown
|
||||||
|
value={schedType}
|
||||||
|
onChange={(v) => setSchedType(v as TaskSchedule['type'])}
|
||||||
|
options={[
|
||||||
|
{ value: 'cron', label: t('task_type_cron') },
|
||||||
|
{ value: 'interval', label: t('task_type_interval') },
|
||||||
|
{ value: 'once', label: t('task_type_once') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{schedType === 'cron' && (
|
||||||
|
<Field label={t('task_cron_expr')} hint={t('task_cron_hint')}>
|
||||||
|
<TextInput value={cron} onChange={(e) => setCron(e.target.value)} placeholder="0 9 * * *" className="font-mono" />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{schedType === 'interval' && (
|
||||||
|
<Field label={t('task_interval_seconds')}>
|
||||||
|
<TextInput type="number" value={interval} onChange={(e) => setIntervalVal(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{schedType === 'once' && (
|
||||||
|
<Field label={t('task_once_time')}>
|
||||||
|
<TextInput type="datetime-local" value={runAt} onChange={(e) => setRunAt(e.target.value)} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label={t('task_action_type')}>
|
||||||
|
<Dropdown
|
||||||
|
value={actionType}
|
||||||
|
onChange={(v) => setActionType(v as TaskAction['type'])}
|
||||||
|
options={[
|
||||||
|
{ value: 'send_message', label: t('task_action_send') },
|
||||||
|
{ value: 'agent_task', label: t('task_action_agent') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label={actionType === 'send_message' ? t('task_message_content') : t('task_task_description')}>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors resize-none"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Channel and receiver are channel-bound and read-only after creation. */}
|
||||||
|
{(task.action.channel_type || task.action.receiver) && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Field label={t('task_channel')}>
|
||||||
|
<TextInput value={task.action.channel_type || 'web'} disabled />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('task_receiver')}>
|
||||||
|
<TextInput value={task.action.receiver_name || task.action.receiver || '--'} disabled />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-content-tertiary">{t('task_channel_locked')}</p>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-danger">{error}</p>}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Field: React.FC<{ label: string; hint?: string; children: React.ReactNode }> = ({ label, hint, children }) => (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-content-secondary mb-1.5">{label}</label>
|
||||||
|
{children}
|
||||||
|
{hint && <p className="text-xs text-content-tertiary mt-1">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default TasksPage
|
||||||
331
desktop/src/renderer/src/pages/settings/BasicSettings.tsx
Normal file
331
desktop/src/renderer/src/pages/settings/BasicSettings.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { Cpu, Bot, ShieldCheck, Languages, Eye, EyeOff, ArrowRight, Loader2 } from 'lucide-react'
|
||||||
|
import { t, getLang, setLang, localizedLabel, type Lang } from '../../i18n'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
import type { ConfigData, ProviderMeta } from '../../types'
|
||||||
|
import { Card, Field, Dropdown, Toggle, TextInput, SaveRow, MASK_RE } from './primitives'
|
||||||
|
|
||||||
|
interface BasicSettingsProps {
|
||||||
|
baseUrl: string
|
||||||
|
onLangChange?: () => void
|
||||||
|
onOpenModels?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, onOpenModels }) => {
|
||||||
|
const [config, setConfig] = useState<ConfigData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// model card — credentials (key/base) now live in the Models tab
|
||||||
|
const [provider, setProvider] = useState('')
|
||||||
|
const [model, setModel] = useState('')
|
||||||
|
const [customModel, setCustomModel] = useState('')
|
||||||
|
const [showCustom, setShowCustom] = useState(false)
|
||||||
|
const [modelStatus, setModelStatus] = useState('')
|
||||||
|
|
||||||
|
// agent card
|
||||||
|
const [maxTokens, setMaxTokens] = useState(100000)
|
||||||
|
const [maxTurns, setMaxTurns] = useState(20)
|
||||||
|
const [maxSteps, setMaxSteps] = useState(20)
|
||||||
|
const [thinking, setThinking] = useState(false)
|
||||||
|
const [evolution, setEvolution] = useState(false)
|
||||||
|
const [agentStatus, setAgentStatus] = useState('')
|
||||||
|
|
||||||
|
// security card
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [pwDirty, setPwDirty] = useState(false)
|
||||||
|
const [pwVisible, setPwVisible] = useState(false)
|
||||||
|
const [pwStatus, setPwStatus] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.setBaseUrl(baseUrl)
|
||||||
|
loadConfig()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [baseUrl])
|
||||||
|
|
||||||
|
const providerMeta = (id: string): ProviderMeta | undefined => config?.providers?.[id] as ProviderMeta | undefined
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await apiClient.getConfig()
|
||||||
|
setConfig(data)
|
||||||
|
setModel(data.model || '')
|
||||||
|
setMaxTokens(data.agent_max_context_tokens ?? 100000)
|
||||||
|
setMaxTurns(data.agent_max_context_turns ?? 20)
|
||||||
|
setMaxSteps(data.agent_max_steps ?? 20)
|
||||||
|
setThinking(!!data.enable_thinking)
|
||||||
|
setEvolution(!!data.self_evolution_enabled)
|
||||||
|
setPassword(data.web_password_masked || '')
|
||||||
|
setPwDirty(false)
|
||||||
|
|
||||||
|
const ids = data.providers ? Object.keys(data.providers) : []
|
||||||
|
const current = data.use_linkai ? 'linkai' : data.bot_type || ids[0] || ''
|
||||||
|
setProvider(current)
|
||||||
|
const meta = data.providers?.[current] as ProviderMeta | undefined
|
||||||
|
const presets = meta?.models || []
|
||||||
|
if (data.model && presets.length && !presets.includes(data.model)) {
|
||||||
|
setShowCustom(true)
|
||||||
|
setCustomModel(data.model)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load config:', err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProviderChange = (id: string) => {
|
||||||
|
setProvider(id)
|
||||||
|
setShowCustom(false)
|
||||||
|
setCustomModel('')
|
||||||
|
if (config) {
|
||||||
|
const meta = config.providers?.[id] as ProviderMeta | undefined
|
||||||
|
const models = meta?.models || []
|
||||||
|
setModel(models[0] || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModelChange = (val: string) => {
|
||||||
|
if (val === '__custom__') {
|
||||||
|
setShowCustom(true)
|
||||||
|
setModel('')
|
||||||
|
} else {
|
||||||
|
setShowCustom(false)
|
||||||
|
setModel(val)
|
||||||
|
setCustomModel('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveModelConfig = async () => {
|
||||||
|
const finalModel = showCustom ? customModel.trim() : model
|
||||||
|
const isLinkai = provider === 'linkai'
|
||||||
|
try {
|
||||||
|
await apiClient.updateConfig({
|
||||||
|
model: finalModel,
|
||||||
|
use_linkai: isLinkai,
|
||||||
|
bot_type: isLinkai ? '' : provider,
|
||||||
|
})
|
||||||
|
setModelStatus(t('config_saved'))
|
||||||
|
const fresh = await apiClient.getConfig()
|
||||||
|
setConfig(fresh)
|
||||||
|
} catch {
|
||||||
|
setModelStatus(t('config_save_error'))
|
||||||
|
}
|
||||||
|
setTimeout(() => setModelStatus(''), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveAgentConfig = async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.updateConfig({
|
||||||
|
agent_max_context_tokens: maxTokens,
|
||||||
|
agent_max_context_turns: maxTurns,
|
||||||
|
agent_max_steps: maxSteps,
|
||||||
|
enable_thinking: thinking,
|
||||||
|
self_evolution_enabled: evolution,
|
||||||
|
})
|
||||||
|
setAgentStatus(t('config_saved'))
|
||||||
|
} catch {
|
||||||
|
setAgentStatus(t('config_save_error'))
|
||||||
|
}
|
||||||
|
setTimeout(() => setAgentStatus(''), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePassword = async () => {
|
||||||
|
if (!pwDirty || MASK_RE.test(password)) return
|
||||||
|
try {
|
||||||
|
await apiClient.updateConfig({ web_password: password })
|
||||||
|
setPwStatus(password ? t('config_password_saved') : t('config_password_cleared'))
|
||||||
|
setPwDirty(false)
|
||||||
|
} catch {
|
||||||
|
setPwStatus(t('config_save_error'))
|
||||||
|
}
|
||||||
|
setTimeout(() => setPwStatus(''), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeLanguage = async (lang: Lang) => {
|
||||||
|
setLang(lang)
|
||||||
|
onLangChange?.()
|
||||||
|
try {
|
||||||
|
await apiClient.updateConfig({ cow_lang: lang })
|
||||||
|
} catch {
|
||||||
|
/* non-blocking */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20 text-content-tertiary">
|
||||||
|
<Loader2 size={18} className="animate-spin mr-2" />
|
||||||
|
{t('skills_loading')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A provider counts as configured when its key field holds a value.
|
||||||
|
// Custom providers (no key field) carry their own credential, so treat as configured.
|
||||||
|
const isConfigured = (id: string): boolean => {
|
||||||
|
const meta = providerMeta(id)
|
||||||
|
const f = meta?.api_key_field
|
||||||
|
if (!f) return true
|
||||||
|
return !!config?.api_keys?.[f]
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerIds = config?.providers ? Object.keys(config.providers) : []
|
||||||
|
const providerOptions = providerIds.map((id) => ({
|
||||||
|
value: id,
|
||||||
|
label: localizedLabel(providerMeta(id)?.label) || id,
|
||||||
|
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
|
||||||
|
}))
|
||||||
|
const currentMeta = providerMeta(provider)
|
||||||
|
const currentUnconfigured = !!provider && !isConfigured(provider)
|
||||||
|
const modelOptions = [
|
||||||
|
...(currentMeta?.models || []).map((m) => ({ value: m, label: m })),
|
||||||
|
{ value: '__custom__', label: t('config_custom_option') },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5">
|
||||||
|
{/* Model — provider/model selection only; credentials live in Models tab */}
|
||||||
|
<Card icon={<Cpu size={16} />} title={t('config_model')}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label={t('config_provider')}>
|
||||||
|
<Dropdown value={provider} options={providerOptions} onChange={handleProviderChange} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t('config_model_name')}>
|
||||||
|
<Dropdown value={showCustom ? '__custom__' : model} options={modelOptions} onChange={handleModelChange} />
|
||||||
|
{showCustom && (
|
||||||
|
<TextInput
|
||||||
|
className="mt-2 font-mono"
|
||||||
|
value={customModel}
|
||||||
|
onChange={(e) => setCustomModel(e.target.value)}
|
||||||
|
placeholder={t('config_custom_model_hint')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* Guide users to the Models tab for API key / base config.
|
||||||
|
When the selected provider has no credentials, surface a warning. */}
|
||||||
|
{onOpenModels && (
|
||||||
|
<button
|
||||||
|
onClick={onOpenModels}
|
||||||
|
className={`w-full flex items-center justify-between gap-2 rounded-btn border px-3 py-2.5 cursor-pointer transition-colors text-left ${
|
||||||
|
currentUnconfigured
|
||||||
|
? 'border-danger-border bg-danger-soft hover:border-danger'
|
||||||
|
: 'border-default bg-inset hover:border-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`text-xs ${currentUnconfigured ? 'text-danger' : 'text-content-tertiary'}`}>
|
||||||
|
{currentUnconfigured ? t('config_provider_unconfigured_hint') : t('config_credentials_link')}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`flex-shrink-0 inline-flex items-center gap-1 text-xs ${
|
||||||
|
currentUnconfigured ? 'text-danger font-medium' : 'text-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('config_goto_models')}
|
||||||
|
<ArrowRight size={13} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SaveRow status={modelStatus} onSave={saveModelConfig} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Agent */}
|
||||||
|
<Card icon={<Bot size={16} />} title={t('config_agent')}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label={t('config_max_tokens')} hint={t('config_max_tokens_hint')}>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
className="font-mono"
|
||||||
|
value={maxTokens}
|
||||||
|
onChange={(e) => setMaxTokens(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('config_max_turns')} hint={t('config_max_turns_hint')}>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
className="font-mono"
|
||||||
|
value={maxTurns}
|
||||||
|
onChange={(e) => setMaxTurns(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('config_max_steps')} hint={t('config_max_steps_hint')}>
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
className="font-mono"
|
||||||
|
value={maxSteps}
|
||||||
|
onChange={(e) => setMaxSteps(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-content">{t('config_thinking')}</div>
|
||||||
|
<div className="text-xs text-content-tertiary mt-0.5">{t('config_thinking_hint')}</div>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={thinking} onChange={setThinking} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-1">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-content">{t('config_evolution')}</div>
|
||||||
|
<div className="text-xs text-content-tertiary mt-0.5">{t('config_evolution_hint')}</div>
|
||||||
|
</div>
|
||||||
|
<Toggle checked={evolution} onChange={setEvolution} />
|
||||||
|
</div>
|
||||||
|
<SaveRow status={agentStatus} onSave={saveAgentConfig} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Security */}
|
||||||
|
<Card icon={<ShieldCheck size={16} />} title={t('config_security')}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label={t('config_password')} hint={t('config_password_hint')}>
|
||||||
|
<div className="relative">
|
||||||
|
<TextInput
|
||||||
|
type={pwVisible ? 'text' : 'password'}
|
||||||
|
className="pr-10"
|
||||||
|
value={password}
|
||||||
|
placeholder={t('config_password_placeholder')}
|
||||||
|
onFocus={() => {
|
||||||
|
if (!pwDirty && MASK_RE.test(password)) setPassword('')
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!pwDirty) setPassword(config?.web_password_masked || '')
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPassword(e.target.value)
|
||||||
|
setPwDirty(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPwVisible((v) => !v)}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-tertiary hover:text-content-secondary cursor-pointer p-1"
|
||||||
|
>
|
||||||
|
{pwVisible ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<SaveRow status={pwStatus} onSave={savePassword} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Language */}
|
||||||
|
<Card icon={<Languages size={16} />} title={t('config_language')}>
|
||||||
|
<Field label={t('config_language')} hint={t('config_language_hint')}>
|
||||||
|
<Dropdown
|
||||||
|
value={getLang()}
|
||||||
|
options={[
|
||||||
|
{ value: 'zh', label: '简体中文' },
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
]}
|
||||||
|
onChange={(v) => changeLanguage(v as Lang)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BasicSettings
|
||||||
159
desktop/src/renderer/src/pages/settings/CapabilityCard.tsx
Normal file
159
desktop/src/renderer/src/pages/settings/CapabilityCard.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React, { useMemo, useState } from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { t } from '../../i18n'
|
||||||
|
import type { CapabilityState, ModelsData } from '../../types'
|
||||||
|
import { Card, Field, Dropdown, TextInput, type DropdownOption } from './primitives'
|
||||||
|
import { resolveModels, providerLabel, CUSTOM_OPTION } from './modelsHelpers'
|
||||||
|
|
||||||
|
// Generic provider+model capability card used by chat/vision/asr/embedding/image.
|
||||||
|
// tts (voice) and search have bespoke cards.
|
||||||
|
|
||||||
|
export interface CapabilityCardProps {
|
||||||
|
icon: LucideIcon
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
capKey: string
|
||||||
|
state: CapabilityState
|
||||||
|
data: ModelsData | null
|
||||||
|
// whether picking "no provider" (auto / disabled) is allowed
|
||||||
|
allowAuto?: boolean
|
||||||
|
autoLabel?: string
|
||||||
|
// whether to allow a free-form custom model entry
|
||||||
|
allowCustomModel?: boolean
|
||||||
|
busy?: boolean
|
||||||
|
status?: string
|
||||||
|
onSave: (providerId: string, model: string) => void
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const CapabilityCard: React.FC<CapabilityCardProps> = ({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
state,
|
||||||
|
data,
|
||||||
|
allowAuto,
|
||||||
|
autoLabel,
|
||||||
|
allowCustomModel,
|
||||||
|
busy,
|
||||||
|
status,
|
||||||
|
onSave,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const [provider, setProvider] = useState(state.current_provider || '')
|
||||||
|
const [model, setModel] = useState(state.current_model || '')
|
||||||
|
const [customModel, setCustomModel] = useState('')
|
||||||
|
const [showCustom, setShowCustom] = useState(false)
|
||||||
|
|
||||||
|
// A provider is configured when it has credentials (custom providers always
|
||||||
|
// carry their own). Unconfigured ones stay selectable but are flagged so the
|
||||||
|
// user is guided to set up the API key.
|
||||||
|
const isConfigured = (id: string): boolean => {
|
||||||
|
const p = data?.providers?.find((x) => x.id === id)
|
||||||
|
if (!p) return true
|
||||||
|
return p.configured || (p.is_custom && !!p.custom_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerOptions: DropdownOption[] = useMemo(() => {
|
||||||
|
const opts = (state.providers || []).map((id) => ({
|
||||||
|
value: id,
|
||||||
|
label: providerLabel(data, id),
|
||||||
|
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
|
||||||
|
}))
|
||||||
|
if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts]
|
||||||
|
return opts
|
||||||
|
}, [state.providers, data, allowAuto, autoLabel])
|
||||||
|
|
||||||
|
const currentUnconfigured = !!provider && !isConfigured(provider)
|
||||||
|
|
||||||
|
const modelOptions: DropdownOption[] = useMemo(() => {
|
||||||
|
const list = resolveModels(data, provider, state.provider_models).map((o) => ({
|
||||||
|
value: o.value,
|
||||||
|
label: o.value,
|
||||||
|
hint: o.hint,
|
||||||
|
}))
|
||||||
|
// Keep the currently-saved model selectable even if it's not in the preset list.
|
||||||
|
if (model && !showCustom && !list.some((o) => o.value === model)) {
|
||||||
|
list.unshift({ value: model, label: model, hint: undefined })
|
||||||
|
}
|
||||||
|
if (allowCustomModel) list.push({ value: CUSTOM_OPTION, label: t('config_custom_option'), hint: undefined })
|
||||||
|
return list
|
||||||
|
}, [data, state.provider_models, provider, allowCustomModel, model, showCustom])
|
||||||
|
|
||||||
|
const handleProvider = (id: string) => {
|
||||||
|
setProvider(id)
|
||||||
|
setShowCustom(false)
|
||||||
|
setCustomModel('')
|
||||||
|
const first = resolveModels(data, id, state.provider_models)[0]
|
||||||
|
setModel(first?.value || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModel = (val: string) => {
|
||||||
|
if (val === CUSTOM_OPTION) {
|
||||||
|
setShowCustom(true)
|
||||||
|
setModel('')
|
||||||
|
} else {
|
||||||
|
setShowCustom(false)
|
||||||
|
setModel(val)
|
||||||
|
setCustomModel('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalModel = showCustom ? customModel.trim() : model
|
||||||
|
const isAuto = allowAuto && !provider
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card icon={<Icon size={16} />} title={title} subtitle={subtitle}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label={t('models_provider')}>
|
||||||
|
<Dropdown
|
||||||
|
value={provider}
|
||||||
|
options={providerOptions}
|
||||||
|
placeholder={t('models_select_provider')}
|
||||||
|
onChange={handleProvider}
|
||||||
|
/>
|
||||||
|
{/* The provider's API key is configured in the vendor cards above on
|
||||||
|
this same tab, so warn instead of linking elsewhere. */}
|
||||||
|
{currentUnconfigured && (
|
||||||
|
<p className="text-xs text-danger mt-1.5">{t('config_provider_unconfigured_hint')}</p>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{!isAuto && (
|
||||||
|
<Field label={t('models_model')}>
|
||||||
|
<Dropdown
|
||||||
|
value={showCustom ? CUSTOM_OPTION : model}
|
||||||
|
options={modelOptions}
|
||||||
|
placeholder={t('models_select_model')}
|
||||||
|
onChange={handleModel}
|
||||||
|
/>
|
||||||
|
{showCustom && (
|
||||||
|
<TextInput
|
||||||
|
className="mt-2 font-mono"
|
||||||
|
value={customModel}
|
||||||
|
onChange={(e) => setCustomModel(e.target.value)}
|
||||||
|
placeholder={t('config_custom_model_hint')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-1">
|
||||||
|
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onSave(provider, finalModel)}
|
||||||
|
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
{t('config_save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CapabilityCard
|
||||||
833
desktop/src/renderer/src/pages/settings/ModelsTab.tsx
Normal file
833
desktop/src/renderer/src/pages/settings/ModelsTab.tsx
Normal file
@@ -0,0 +1,833 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
MessageSquare,
|
||||||
|
Eye,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Mic,
|
||||||
|
Volume2,
|
||||||
|
Database,
|
||||||
|
Search as SearchIcon,
|
||||||
|
Plus,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
Eye as EyeIcon,
|
||||||
|
EyeOff,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { t, localizedLabel } from '../../i18n'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
import type { CapabilityState, ModelsData, ModelProvider, SearchCapabilityState } from '../../types'
|
||||||
|
import { Card, Field, Dropdown, TextInput, Modal, Btn, MASK_RE } from './primitives'
|
||||||
|
import CapabilityCard from './CapabilityCard'
|
||||||
|
import { normEntries, providerLabel, resolveVoices, CUSTOM_OPTION } from './modelsHelpers'
|
||||||
|
|
||||||
|
interface ModelsTabProps {
|
||||||
|
baseUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const REPLY_MODES: { value: 'off' | 'voice_if_voice' | 'always'; key: string }[] = [
|
||||||
|
{ value: 'off', key: 'models_tts_mode_off' },
|
||||||
|
{ value: 'voice_if_voice', key: 'models_tts_mode_if_voice' },
|
||||||
|
{ value: 'always', key: 'models_tts_mode_always' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ModelsTab: React.FC<ModelsTabProps> = ({ baseUrl }) => {
|
||||||
|
const [data, setData] = useState<ModelsData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [busy, setBusy] = useState<string>('') // capability key currently saving
|
||||||
|
const [statusMap, setStatusMap] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const fresh = await apiClient.getModels()
|
||||||
|
setData(fresh)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load models:', e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.setBaseUrl(baseUrl)
|
||||||
|
load()
|
||||||
|
}, [baseUrl, load])
|
||||||
|
|
||||||
|
const flash = (key: string, msg: string) => {
|
||||||
|
setStatusMap((m) => ({ ...m, [key]: msg }))
|
||||||
|
setTimeout(() => setStatusMap((m) => ({ ...m, [key]: '' })), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a models action, then refresh and flash a status for the given key.
|
||||||
|
const run = async (key: string, action: Parameters<typeof apiClient.modelsAction>[0]) => {
|
||||||
|
setBusy(key)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.modelsAction(action)
|
||||||
|
if (res.status === 'success') {
|
||||||
|
await load()
|
||||||
|
flash(key, t('config_saved'))
|
||||||
|
} else {
|
||||||
|
flash(key, (res.message as string) || t('config_save_error'))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
flash(key, t('config_save_error'))
|
||||||
|
} finally {
|
||||||
|
setBusy('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-20 text-content-tertiary">
|
||||||
|
<Loader2 size={18} className="animate-spin mr-2" />
|
||||||
|
{t('skills_loading')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
return <div className="text-center py-20 text-content-tertiary">{t('config_save_error')}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const caps = data.capabilities
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<VendorSection data={data} onChanged={load} statusMap={statusMap} flash={flash} />
|
||||||
|
|
||||||
|
{/* Chat */}
|
||||||
|
<CapabilityCard
|
||||||
|
icon={MessageSquare}
|
||||||
|
title={t('models_cap_chat')}
|
||||||
|
subtitle={t('models_cap_chat_sub')}
|
||||||
|
capKey="chat"
|
||||||
|
state={caps.chat}
|
||||||
|
data={data}
|
||||||
|
allowCustomModel
|
||||||
|
busy={busy === 'chat'}
|
||||||
|
status={statusMap.chat}
|
||||||
|
onSave={(p, m) => run('chat', { action: 'set_capability', capability: 'chat', provider_id: p, model: m })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Vision */}
|
||||||
|
<CapabilityCard
|
||||||
|
icon={Eye}
|
||||||
|
title={t('models_cap_vision')}
|
||||||
|
subtitle={t('models_cap_vision_sub')}
|
||||||
|
capKey="vision"
|
||||||
|
state={caps.vision}
|
||||||
|
data={data}
|
||||||
|
allowAuto
|
||||||
|
autoLabel={t('models_auto')}
|
||||||
|
busy={busy === 'vision'}
|
||||||
|
status={statusMap.vision}
|
||||||
|
onSave={(p, m) => run('vision', { action: 'set_capability', capability: 'vision', provider_id: p, model: m })}
|
||||||
|
>
|
||||||
|
<FallbackHint state={caps.vision} data={data} />
|
||||||
|
</CapabilityCard>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
<CapabilityCard
|
||||||
|
icon={ImageIcon}
|
||||||
|
title={t('models_cap_image')}
|
||||||
|
subtitle={t('models_cap_image_sub')}
|
||||||
|
capKey="image"
|
||||||
|
state={caps.image}
|
||||||
|
data={data}
|
||||||
|
allowAuto
|
||||||
|
autoLabel={t('models_auto')}
|
||||||
|
busy={busy === 'image'}
|
||||||
|
status={statusMap.image}
|
||||||
|
onSave={(p, m) => run('image', { action: 'set_capability', capability: 'image', provider_id: p, model: m })}
|
||||||
|
>
|
||||||
|
<FallbackHint state={caps.image} data={data} />
|
||||||
|
</CapabilityCard>
|
||||||
|
|
||||||
|
{/* ASR */}
|
||||||
|
<CapabilityCard
|
||||||
|
icon={Mic}
|
||||||
|
title={t('models_cap_asr')}
|
||||||
|
subtitle={t('models_cap_asr_sub')}
|
||||||
|
capKey="asr"
|
||||||
|
state={caps.asr}
|
||||||
|
data={data}
|
||||||
|
allowAuto
|
||||||
|
autoLabel={t('models_asr_auto')}
|
||||||
|
busy={busy === 'asr'}
|
||||||
|
status={statusMap.asr}
|
||||||
|
onSave={(p, m) => run('asr', { action: 'set_capability', capability: 'asr', provider_id: p, model: m })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TTS — bespoke (voice + reply mode) */}
|
||||||
|
<TtsCard
|
||||||
|
state={caps.tts}
|
||||||
|
data={data}
|
||||||
|
busy={busy === 'tts'}
|
||||||
|
status={statusMap.tts}
|
||||||
|
onSaveVoice={(p, m, v) =>
|
||||||
|
run('tts', { action: 'set_capability', capability: 'tts', provider_id: p, model: m, voice: v })
|
||||||
|
}
|
||||||
|
onSaveMode={(mode) => run('tts_mode', { action: 'set_voice_reply_mode', mode })}
|
||||||
|
modeStatus={statusMap.tts_mode}
|
||||||
|
modeBusy={busy === 'tts_mode'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Embedding */}
|
||||||
|
<EmbeddingCard
|
||||||
|
state={caps.embedding}
|
||||||
|
data={data}
|
||||||
|
busy={busy === 'embedding'}
|
||||||
|
status={statusMap.embedding}
|
||||||
|
onSave={(p, m) => run('embedding', { action: 'set_capability', capability: 'embedding', provider_id: p, model: m })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Search — bespoke */}
|
||||||
|
<SearchCard
|
||||||
|
state={caps.search}
|
||||||
|
busy={busy === 'search'}
|
||||||
|
status={statusMap.search}
|
||||||
|
onSaveStrategy={(strategy, provider) =>
|
||||||
|
run('search', { action: 'set_capability', capability: 'search', strategy, provider })
|
||||||
|
}
|
||||||
|
onSaveBochaKey={(key) => run('search_key', { action: 'set_search_credential', api_key: key })}
|
||||||
|
keyStatus={statusMap.search_key}
|
||||||
|
keyBusy={busy === 'search_key'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Layer 1 — vendor credentials
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface VendorSectionProps {
|
||||||
|
data: ModelsData
|
||||||
|
onChanged: () => Promise<void>
|
||||||
|
statusMap: Record<string, string>
|
||||||
|
flash: (key: string, msg: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const VendorSection: React.FC<VendorSectionProps> = ({ data, onChanged }) => {
|
||||||
|
// Edit an existing built-in vendor.
|
||||||
|
const [editing, setEditing] = useState<ModelProvider | null>(null)
|
||||||
|
// Add flow: open the vendor modal with a provider picker.
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
// Custom provider modal: 'new' to create, or a provider to edit.
|
||||||
|
const [customEditing, setCustomEditing] = useState<ModelProvider | 'new' | null>(null)
|
||||||
|
|
||||||
|
const isCustomCard = (p: ModelProvider) => p.is_custom && !!p.custom_name
|
||||||
|
// Unified grid: configured built-ins + all custom provider cards (web parity).
|
||||||
|
const shown = data.providers.filter((p) => p.configured || isCustomCard(p))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card icon={<Database size={16} />} title={t('models_vendors')} subtitle={t('models_vendors_sub')}>
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 rounded-btn border border-dashed border-default">
|
||||||
|
<p className="text-sm text-content-tertiary">{t('models_no_vendor')}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
className="mt-3 inline-flex items-center gap-1 px-3 py-1.5 rounded-btn text-xs font-medium bg-accent-soft text-accent hover:bg-accent-soft/70 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={12} /> {t('models_add_vendor')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||||
|
{shown.map((p) =>
|
||||||
|
isCustomCard(p) ? (
|
||||||
|
<VendorChip key={p.id} provider={p} onClick={() => setCustomEditing(p)} />
|
||||||
|
) : (
|
||||||
|
<VendorChip key={p.id} provider={p} onClick={() => setEditing(p)} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
className="flex items-center justify-center gap-1.5 px-3 py-2.5 rounded-btn border border-dashed border-default text-content-tertiary hover:border-accent hover:text-accent cursor-pointer transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<Plus size={14} /> {t('models_add_vendor')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<VendorModal
|
||||||
|
provider={editing}
|
||||||
|
addMode={adding}
|
||||||
|
data={data}
|
||||||
|
onClose={() => {
|
||||||
|
setEditing(null)
|
||||||
|
setAdding(false)
|
||||||
|
}}
|
||||||
|
onPickCustom={() => {
|
||||||
|
setAdding(false)
|
||||||
|
setCustomEditing('new')
|
||||||
|
}}
|
||||||
|
onSaved={onChanged}
|
||||||
|
/>
|
||||||
|
<CustomProviderModal target={customEditing} onClose={() => setCustomEditing(null)} onSaved={onChanged} />
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const VendorChip: React.FC<{ provider: ModelProvider; onClick: () => void }> = ({ provider, onClick }) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="group flex items-center gap-2.5 px-3 py-2.5 rounded-btn border border-default bg-inset hover:border-accent cursor-pointer transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0 w-7 h-7 rounded-lg bg-surface-2 text-content-secondary flex items-center justify-center text-xs font-bold">
|
||||||
|
{(localizedLabel(provider.label) || provider.id || '?').slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 min-w-0 text-sm font-medium text-content truncate">{localizedLabel(provider.label)}</span>
|
||||||
|
<Pencil size={12} className="flex-shrink-0 text-content-tertiary group-hover:text-accent transition-colors" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CUSTOM_PICK = '__custom_new__'
|
||||||
|
|
||||||
|
const VendorModal: React.FC<{
|
||||||
|
provider: ModelProvider | null
|
||||||
|
addMode: boolean
|
||||||
|
data: ModelsData
|
||||||
|
onClose: () => void
|
||||||
|
onPickCustom: () => void
|
||||||
|
onSaved: () => Promise<void>
|
||||||
|
}> = ({ provider, addMode, data, onClose, onPickCustom, onSaved }) => {
|
||||||
|
const open = !!provider || addMode
|
||||||
|
|
||||||
|
// In add-mode the user first picks a built-in provider; that selection
|
||||||
|
// becomes the effective provider whose key/base fields we edit.
|
||||||
|
const builtins = useMemo(() => data.providers.filter((p) => !(p.is_custom && p.custom_name)), [data.providers])
|
||||||
|
const firstUnconfigured = builtins.find((p) => !p.configured) || builtins[0]
|
||||||
|
const [pickId, setPickId] = useState('')
|
||||||
|
|
||||||
|
const effective: ModelProvider | undefined = provider || builtins.find((p) => p.id === pickId)
|
||||||
|
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [keyDirty, setKeyDirty] = useState(false)
|
||||||
|
const [keyVisible, setKeyVisible] = useState(false)
|
||||||
|
const [apiBase, setApiBase] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// Load fields whenever the effective provider changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const init = provider || (addMode ? firstUnconfigured : undefined)
|
||||||
|
setPickId(provider ? provider.id : firstUnconfigured?.id || '')
|
||||||
|
setApiKey(init?.api_key_masked || '')
|
||||||
|
setApiBase(init?.api_base || '')
|
||||||
|
setKeyDirty(false)
|
||||||
|
setKeyVisible(false)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [provider, addMode, open])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const pickOptions = [
|
||||||
|
...builtins.map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: localizedLabel(p.label),
|
||||||
|
hint: p.configured ? t('models_configured') : undefined,
|
||||||
|
})),
|
||||||
|
{ value: CUSTOM_PICK, label: t('models_custom_vendor'), hint: t('models_add_custom_hint') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const onPick = (val: string) => {
|
||||||
|
if (val === CUSTOM_PICK) {
|
||||||
|
onPickCustom()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPickId(val)
|
||||||
|
const p = builtins.find((x) => x.id === val)
|
||||||
|
setApiKey(p?.api_key_masked || '')
|
||||||
|
setApiBase(p?.api_base || '')
|
||||||
|
setKeyDirty(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBase = !!effective?.api_base_field
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!effective) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload: { action: 'set_provider'; provider_id: string; api_key?: string; api_base?: string } = {
|
||||||
|
action: 'set_provider',
|
||||||
|
provider_id: effective.id,
|
||||||
|
}
|
||||||
|
if (keyDirty && apiKey && !MASK_RE.test(apiKey)) payload.api_key = apiKey
|
||||||
|
if (hasBase) payload.api_base = apiBase
|
||||||
|
await apiClient.modelsAction(payload)
|
||||||
|
await onSaved()
|
||||||
|
onClose()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = async () => {
|
||||||
|
if (!effective || !confirm(t('models_clear_confirm'))) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await apiClient.modelsAction({ action: 'delete_provider', provider_id: effective.id })
|
||||||
|
await onSaved()
|
||||||
|
onClose()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={addMode ? t('models_add_vendor') : localizedLabel(effective?.label)}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
{!addMode && effective?.configured && (
|
||||||
|
<Btn variant="danger" onClick={clear} disabled={saving}>
|
||||||
|
{t('models_clear')}
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
<Btn variant="ghost" onClick={onClose}>
|
||||||
|
{t('config_cancel')}
|
||||||
|
</Btn>
|
||||||
|
<Btn variant="primary" onClick={save} disabled={saving || !effective}>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{addMode && (
|
||||||
|
<Field label={t('models_provider')}>
|
||||||
|
<Dropdown value={pickId} options={pickOptions} onChange={onPick} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label="API Key">
|
||||||
|
<div className="relative">
|
||||||
|
<TextInput
|
||||||
|
type={keyVisible ? 'text' : 'password'}
|
||||||
|
className="pr-10 font-mono"
|
||||||
|
value={apiKey}
|
||||||
|
placeholder="sk-..."
|
||||||
|
onFocus={() => {
|
||||||
|
if (!keyDirty && MASK_RE.test(apiKey)) setApiKey('')
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!keyDirty) setApiKey(effective?.api_key_masked || '')
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
setApiKey(e.target.value)
|
||||||
|
setKeyDirty(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKeyVisible((v) => !v)}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-content-tertiary hover:text-content-secondary cursor-pointer p-1"
|
||||||
|
>
|
||||||
|
{keyVisible ? <EyeOff size={14} /> : <EyeIcon size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
{hasBase && (
|
||||||
|
<Field label="API Base">
|
||||||
|
<TextInput
|
||||||
|
className="font-mono"
|
||||||
|
value={apiBase}
|
||||||
|
onChange={(e) => setApiBase(e.target.value)}
|
||||||
|
placeholder={effective?.api_base_placeholder || 'https://...'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomProviderModal: React.FC<{
|
||||||
|
target: ModelProvider | 'new' | null
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: () => Promise<void>
|
||||||
|
}> = ({ target, onClose, onSaved }) => {
|
||||||
|
const editing = target && target !== 'new' ? target : null
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [apiBase, setApiBase] = useState('')
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [keyDirty, setKeyDirty] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!target) return
|
||||||
|
if (editing) {
|
||||||
|
setName(editing.custom_name || localizedLabel(editing.label))
|
||||||
|
setApiBase(editing.api_base || '')
|
||||||
|
setApiKey(editing.api_key_masked || '')
|
||||||
|
} else {
|
||||||
|
setName('')
|
||||||
|
setApiBase('')
|
||||||
|
setApiKey('')
|
||||||
|
}
|
||||||
|
setKeyDirty(false)
|
||||||
|
}, [target, editing])
|
||||||
|
|
||||||
|
if (!target) return null
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const payload: {
|
||||||
|
action: 'set_custom_provider'
|
||||||
|
name: string
|
||||||
|
id?: string
|
||||||
|
api_base: string
|
||||||
|
api_key?: string
|
||||||
|
} = {
|
||||||
|
action: 'set_custom_provider',
|
||||||
|
name: name.trim(),
|
||||||
|
api_base: apiBase.trim(),
|
||||||
|
}
|
||||||
|
if (editing) payload.id = editing.custom_id
|
||||||
|
if (keyDirty && apiKey && !MASK_RE.test(apiKey)) payload.api_key = apiKey
|
||||||
|
await apiClient.modelsAction(payload)
|
||||||
|
await onSaved()
|
||||||
|
onClose()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!editing || !confirm(t('models_delete_confirm'))) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await apiClient.modelsAction({ action: 'delete_custom_provider', id: editing.custom_id || '' })
|
||||||
|
await onSaved()
|
||||||
|
onClose()
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={!!target}
|
||||||
|
title={editing ? t('models_edit_custom') : t('models_add_custom')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
{editing && (
|
||||||
|
<Btn variant="danger" onClick={remove} disabled={saving}>
|
||||||
|
{t('models_delete')}
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
<Btn variant="ghost" onClick={onClose}>
|
||||||
|
{t('config_cancel')}
|
||||||
|
</Btn>
|
||||||
|
<Btn variant="primary" onClick={save} disabled={saving || !name.trim() || (!editing && !apiBase.trim())}>
|
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Field label={t('models_custom_name')}>
|
||||||
|
<TextInput value={name} onChange={(e) => setName(e.target.value)} placeholder="My Provider" />
|
||||||
|
</Field>
|
||||||
|
<Field label="API Base" hint={t('models_custom_base_hint')}>
|
||||||
|
<TextInput
|
||||||
|
className="font-mono"
|
||||||
|
value={apiBase}
|
||||||
|
onChange={(e) => setApiBase(e.target.value)}
|
||||||
|
placeholder="https://...../v1"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="API Key">
|
||||||
|
<TextInput
|
||||||
|
type="text"
|
||||||
|
className="font-mono"
|
||||||
|
value={apiKey}
|
||||||
|
placeholder="sk-..."
|
||||||
|
onFocus={() => {
|
||||||
|
if (!keyDirty && MASK_RE.test(apiKey)) setApiKey('')
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
setApiKey(e.target.value)
|
||||||
|
setKeyDirty(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Bespoke capability cards
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const FallbackHint: React.FC<{ state: CapabilityState; data: ModelsData }> = ({ state, data }) => {
|
||||||
|
if (!state.fallback_provider && !state.fallback_model) return null
|
||||||
|
const label = providerLabel(data, state.fallback_provider || '')
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-content-tertiary">
|
||||||
|
{t('models_fallback')}: {label} {state.fallback_model ? `· ${state.fallback_model}` : ''}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TtsCard: React.FC<{
|
||||||
|
state: CapabilityState
|
||||||
|
data: ModelsData
|
||||||
|
busy: boolean
|
||||||
|
status?: string
|
||||||
|
onSaveVoice: (provider: string, model: string, voice: string) => void
|
||||||
|
onSaveMode: (mode: 'off' | 'voice_if_voice' | 'always') => void
|
||||||
|
modeStatus?: string
|
||||||
|
modeBusy: boolean
|
||||||
|
}> = ({ state, data, busy, status, onSaveVoice, onSaveMode, modeStatus, modeBusy }) => {
|
||||||
|
const [provider, setProvider] = useState(state.current_provider || '')
|
||||||
|
const [model, setModel] = useState(state.current_model || '')
|
||||||
|
const [voice, setVoice] = useState(state.current_voice || '')
|
||||||
|
const [mode, setMode] = useState<'off' | 'voice_if_voice' | 'always'>(state.reply_mode || 'off')
|
||||||
|
|
||||||
|
const providerOptions = (state.providers || []).map((id) => ({ value: id, label: providerLabel(data, id) }))
|
||||||
|
const modelOptions = normEntries(state.provider_models?.[provider]).map((o) => ({
|
||||||
|
value: o.value,
|
||||||
|
label: o.value,
|
||||||
|
hint: o.hint,
|
||||||
|
}))
|
||||||
|
const voiceOptions = resolveVoices(provider, model, state.provider_voices).map((o) => ({
|
||||||
|
value: o.value,
|
||||||
|
label: o.value,
|
||||||
|
hint: o.hint,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleProvider = (id: string) => {
|
||||||
|
setProvider(id)
|
||||||
|
const first = normEntries(state.provider_models?.[id])[0]
|
||||||
|
const fm = first?.value || ''
|
||||||
|
setModel(fm)
|
||||||
|
setVoice(resolveVoices(id, fm, state.provider_voices)[0]?.value || '')
|
||||||
|
}
|
||||||
|
const handleModel = (m: string) => {
|
||||||
|
setModel(m)
|
||||||
|
setVoice(resolveVoices(provider, m, state.provider_voices)[0]?.value || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card icon={<Volume2 size={16} />} title={t('models_cap_tts')} subtitle={t('models_cap_tts_sub')}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Reply mode — saved immediately */}
|
||||||
|
<Field label={t('models_tts_reply_mode')} hint={t('models_tts_reply_mode_hint')}>
|
||||||
|
<Dropdown
|
||||||
|
value={mode}
|
||||||
|
options={REPLY_MODES.map((m) => ({ value: m.value, label: t(m.key) }))}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = v as 'off' | 'voice_if_voice' | 'always'
|
||||||
|
setMode(next)
|
||||||
|
onSaveMode(next)
|
||||||
|
}}
|
||||||
|
disabled={modeBusy}
|
||||||
|
/>
|
||||||
|
{modeStatus && <span className="text-xs text-accent">{modeStatus}</span>}
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{mode !== 'off' && (
|
||||||
|
<>
|
||||||
|
<Field label={t('models_provider')}>
|
||||||
|
<Dropdown
|
||||||
|
value={provider}
|
||||||
|
options={providerOptions}
|
||||||
|
placeholder={t('models_select_provider')}
|
||||||
|
onChange={handleProvider}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('models_model')}>
|
||||||
|
<Dropdown
|
||||||
|
value={model}
|
||||||
|
options={modelOptions}
|
||||||
|
placeholder={t('models_select_model')}
|
||||||
|
onChange={handleModel}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{voiceOptions.length > 0 && (
|
||||||
|
<Field label={t('models_voice')}>
|
||||||
|
<Dropdown value={voice} options={voiceOptions} placeholder={t('models_select_voice')} onChange={setVoice} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-1">
|
||||||
|
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onSaveVoice(provider, model, voice)}
|
||||||
|
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
{t('config_save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmbeddingCard: React.FC<{
|
||||||
|
state: CapabilityState
|
||||||
|
data: ModelsData
|
||||||
|
busy: boolean
|
||||||
|
status?: string
|
||||||
|
onSave: (provider: string, model: string) => void
|
||||||
|
}> = ({ state, data, busy, status, onSave }) => (
|
||||||
|
<CapabilityCard
|
||||||
|
icon={Database}
|
||||||
|
title={t('models_cap_embedding')}
|
||||||
|
subtitle={t('models_cap_embedding_sub')}
|
||||||
|
capKey="embedding"
|
||||||
|
state={state}
|
||||||
|
data={data}
|
||||||
|
allowAuto
|
||||||
|
autoLabel={t('models_disabled')}
|
||||||
|
busy={busy}
|
||||||
|
status={status}
|
||||||
|
onSave={onSave}
|
||||||
|
>
|
||||||
|
{state.current_dim != null && (
|
||||||
|
<p className="text-xs text-content-tertiary">
|
||||||
|
{t('models_embedding_dim')}: {state.current_dim} · {t('models_embedding_rebuild_hint')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CapabilityCard>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SearchCard: React.FC<{
|
||||||
|
state: SearchCapabilityState
|
||||||
|
busy: boolean
|
||||||
|
status?: string
|
||||||
|
onSaveStrategy: (strategy: string, provider: string) => void
|
||||||
|
onSaveBochaKey: (key: string) => void
|
||||||
|
keyStatus?: string
|
||||||
|
keyBusy: boolean
|
||||||
|
}> = ({ state, busy, status, onSaveStrategy, onSaveBochaKey, keyStatus, keyBusy }) => {
|
||||||
|
const [strategy, setStrategy] = useState<string>(state.strategy || 'auto')
|
||||||
|
const [provider, setProvider] = useState<string>(state.fixed_provider || state.current_provider || '')
|
||||||
|
const [bochaOpen, setBochaOpen] = useState(false)
|
||||||
|
|
||||||
|
const providerOptions = useMemo(
|
||||||
|
() => state.providers.map((p) => ({ value: p.id, label: localizedLabel(p.label) })),
|
||||||
|
[state.providers]
|
||||||
|
)
|
||||||
|
const bocha = state.providers.find((p) => p.id === 'bocha')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card icon={<SearchIcon size={16} />} title={t('models_cap_search')} subtitle={t('models_cap_search_sub')}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Field label={t('models_search_strategy')}>
|
||||||
|
<Dropdown
|
||||||
|
value={strategy}
|
||||||
|
options={[
|
||||||
|
{ value: 'auto', label: t('models_search_auto') },
|
||||||
|
{ value: 'fixed', label: t('models_search_fixed') },
|
||||||
|
]}
|
||||||
|
onChange={setStrategy}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{strategy === 'fixed' && (
|
||||||
|
<Field label={t('models_search_provider')}>
|
||||||
|
<Dropdown
|
||||||
|
value={provider}
|
||||||
|
options={providerOptions}
|
||||||
|
placeholder={t('models_select_provider')}
|
||||||
|
onChange={setProvider}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => setBochaOpen(true)}
|
||||||
|
className="text-xs text-accent hover:text-accent-hover cursor-pointer inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{t('models_search_bocha_key')}
|
||||||
|
{bocha?.configured && <Check size={12} />}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`text-xs text-accent transition-opacity ${status ? 'opacity-100' : 'opacity-0'}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
disabled={busy || (strategy === 'fixed' && !provider)}
|
||||||
|
onClick={() => onSaveStrategy(strategy, provider)}
|
||||||
|
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
{t('config_save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BochaKeyModal
|
||||||
|
open={bochaOpen}
|
||||||
|
masked={bocha?.api_key_masked || ''}
|
||||||
|
busy={keyBusy}
|
||||||
|
status={keyStatus}
|
||||||
|
onClose={() => setBochaOpen(false)}
|
||||||
|
onSave={(k) => {
|
||||||
|
onSaveBochaKey(k)
|
||||||
|
setBochaOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const BochaKeyModal: React.FC<{
|
||||||
|
open: boolean
|
||||||
|
masked: string
|
||||||
|
busy: boolean
|
||||||
|
status?: string
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (key: string) => void
|
||||||
|
}> = ({ open, masked, busy, onClose, onSave }) => {
|
||||||
|
const [key, setKey] = useState('')
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setKey(masked)
|
||||||
|
setDirty(false)
|
||||||
|
}
|
||||||
|
}, [open, masked])
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={t('models_search_bocha_key')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Btn variant="ghost" onClick={onClose}>
|
||||||
|
{t('config_cancel')}
|
||||||
|
</Btn>
|
||||||
|
<Btn variant="primary" disabled={busy} onClick={() => onSave(dirty && !MASK_RE.test(key) ? key : '')}>
|
||||||
|
{busy ? <Loader2 size={14} className="animate-spin" /> : t('config_save')}
|
||||||
|
</Btn>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Field label="Bocha API Key" hint={t('models_search_bocha_hint')}>
|
||||||
|
<TextInput
|
||||||
|
className="font-mono"
|
||||||
|
value={key}
|
||||||
|
placeholder="sk-..."
|
||||||
|
onFocus={() => {
|
||||||
|
if (!dirty && MASK_RE.test(key)) setKey('')
|
||||||
|
}}
|
||||||
|
onChange={(e) => {
|
||||||
|
setKey(e.target.value)
|
||||||
|
setDirty(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModelsTab
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user