mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf0c26d3c4 | ||
|
|
65970c564c | ||
|
|
ce09efe640 | ||
|
|
adaf9a7813 | ||
|
|
06f9492518 | ||
|
|
530042675e | ||
|
|
ca404aeb24 | ||
|
|
a6c975f92c | ||
|
|
dd3cadbd81 | ||
|
|
efbabfcace | ||
|
|
09c71ef1d9 | ||
|
|
0bb8208f36 | ||
|
|
56571c77ca | ||
|
|
6c353d389b | ||
|
|
583217d396 | ||
|
|
93162d2f10 | ||
|
|
2e74295807 | ||
|
|
93bf8844de | ||
|
|
fcc520df47 | ||
|
|
a94f4e3c18 | ||
|
|
de9e7f0e84 | ||
|
|
ed5b2d6ce6 | ||
|
|
2cd3fa7981 | ||
|
|
f01cc3a0b4 | ||
|
|
51bf09208d | ||
|
|
bf0831a664 | ||
|
|
96b1fccf76 | ||
|
|
4b57971d33 | ||
|
|
d531e14fbf | ||
|
|
8df38a23d2 | ||
|
|
38105e6539 | ||
|
|
14c6577d51 | ||
|
|
f051a58db5 | ||
|
|
825d990312 | ||
|
|
cb31013584 | ||
|
|
dd74d1dabe | ||
|
|
75f3952ac6 | ||
|
|
37423fbb31 | ||
|
|
00c3436d48 | ||
|
|
377b4e5cb8 | ||
|
|
a427586b89 | ||
|
|
a951494489 | ||
|
|
a871c0437d | ||
|
|
013960cd5a | ||
|
|
60aebf41a8 | ||
|
|
2cf521e57e | ||
|
|
dad3a84efb | ||
|
|
ae864c7ff9 | ||
|
|
3b33114a40 | ||
|
|
e0f49ac619 | ||
|
|
01ec49afd2 | ||
|
|
b44154fe02 | ||
|
|
b8dad38622 | ||
|
|
80fea77c86 | ||
|
|
e5f3eb48d4 | ||
|
|
ca876b0c65 | ||
|
|
0a762b8c08 | ||
|
|
fd90a89b45 | ||
|
|
f82eb39d23 | ||
|
|
2786148153 | ||
|
|
2959cfea32 | ||
|
|
e536232963 | ||
|
|
778d78cebe | ||
|
|
538281da51 | ||
|
|
12cd626949 | ||
|
|
ff64a7930e | ||
|
|
5d726fe340 | ||
|
|
e1834124d4 | ||
|
|
f49e965736 | ||
|
|
936eaf5939 | ||
|
|
7047b30e27 | ||
|
|
5c67e970d1 | ||
|
|
8023c4e8b7 | ||
|
|
641b84519c | ||
|
|
0c8cb974e2 | ||
|
|
915edbe145 | ||
|
|
0c20c5c159 | ||
|
|
6211e63f90 | ||
|
|
44b61684ed | ||
|
|
ab6f49a822 | ||
|
|
02517e4a01 | ||
|
|
2599966cf7 | ||
|
|
6c68931892 | ||
|
|
41855ed511 | ||
|
|
02bc91f4af | ||
|
|
e9352e6984 | ||
|
|
ec4c36f450 | ||
|
|
9ea0017778 | ||
|
|
f1cdc2d2cc | ||
|
|
215ed24401 | ||
|
|
c432681b2b | ||
|
|
49452e035d | ||
|
|
5db2998e3d | ||
|
|
d1336b872e | ||
|
|
e1e29b32e9 | ||
|
|
214dcaf141 | ||
|
|
77a196de8b | ||
|
|
108d04398b | ||
|
|
0bc0f2b930 | ||
|
|
c9c16298ec | ||
|
|
2ef31d5d33 | ||
|
|
e9d9b566a4 | ||
|
|
3baa3252bc | ||
|
|
90d9db0f83 | ||
|
|
8bff4f1658 | ||
|
|
a0e20ef311 | ||
|
|
6996215d3b | ||
|
|
03ffa2db7d | ||
|
|
75e3110e8c | ||
|
|
033480eef1 | ||
|
|
8ddfcbb125 | ||
|
|
a5aaecc48d | ||
|
|
84d6848e67 | ||
|
|
a1e733080d | ||
|
|
3bc6e89b74 |
116
.github/scripts/register-releases.mjs
vendored
Normal file
116
.github/scripts/register-releases.mjs
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
// Build the D1 upsert SQL for a desktop release from the files in a directory.
|
||||
//
|
||||
// Each mac release has TWO artifacts that map to a SINGLE D1 row:
|
||||
// - <name>-<arch>.dmg -> manual download (filename / size / sha512)
|
||||
// - <name>-<arch>.zip -> auto-update (update_filename / update_size /
|
||||
// update_sha512)
|
||||
// electron-updater's MacUpdater can only consume a zip, never a dmg, so the
|
||||
// feed serves the zip while the website serves the dmg. Windows has only the
|
||||
// .exe (stored in the main columns; it's both the download and the update).
|
||||
//
|
||||
// We emit ONE `INSERT OR REPLACE` per (version, platform) carrying BOTH halves,
|
||||
// because two replaces on the same primary key would drop whichever came first.
|
||||
//
|
||||
// Usage:
|
||||
// node register-releases.mjs --dir dist --version 1.2.0 \
|
||||
// --sql out.sql [--latest]
|
||||
//
|
||||
// --latest mark these rows is_latest=1 AND clear the previous latest for
|
||||
// each platform (used by the publish/promote workflow). Without it
|
||||
// rows are written unpublished (is_latest=0) — the build stage.
|
||||
//
|
||||
// sha512 is base64 (the exact format electron-updater validates).
|
||||
|
||||
import { execSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
|
||||
function arg(name, fallback = undefined) {
|
||||
const i = process.argv.indexOf(`--${name}`)
|
||||
if (i === -1) return fallback
|
||||
const next = process.argv[i + 1]
|
||||
// Boolean flag (no value or next token is another flag).
|
||||
if (next === undefined || next.startsWith('--')) return true
|
||||
return next
|
||||
}
|
||||
|
||||
const dir = arg('dir', 'dist')
|
||||
const version = arg('version')
|
||||
const sqlPath = arg('sql', 'd1.sql')
|
||||
const makeLatest = arg('latest', false) === true
|
||||
|
||||
if (!version) {
|
||||
console.error('register-releases: --version is required')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const sha512 = (f) =>
|
||||
execSync(`openssl dgst -sha512 -binary "${f}" | openssl base64 -A`, {
|
||||
shell: '/bin/bash',
|
||||
})
|
||||
.toString()
|
||||
.trim()
|
||||
|
||||
// SQL-escape single quotes (base64/keys shouldn't contain them, but be safe).
|
||||
const q = (s) => String(s).replace(/'/g, "''")
|
||||
|
||||
// platform -> { main: {key,size,sha}, upd: {key,size,sha} }
|
||||
const rows = {}
|
||||
|
||||
for (const base of fs.readdirSync(dir)) {
|
||||
const f = `${dir}/${base}`
|
||||
if (fs.statSync(f).isDirectory()) continue
|
||||
|
||||
let platform
|
||||
let slot
|
||||
if (/arm64\.dmg$/.test(base)) {
|
||||
platform = 'mac-arm64'
|
||||
slot = 'main'
|
||||
} else if (/x64\.dmg$/.test(base)) {
|
||||
platform = 'mac-x64'
|
||||
slot = 'main'
|
||||
} else if (/arm64\.zip$/.test(base)) {
|
||||
platform = 'mac-arm64'
|
||||
slot = 'upd'
|
||||
} else if (/x64\.zip$/.test(base)) {
|
||||
platform = 'mac-x64'
|
||||
slot = 'upd'
|
||||
} else if (/\.exe$/.test(base)) {
|
||||
platform = 'win'
|
||||
slot = 'main'
|
||||
} else {
|
||||
console.log('Skipping unrecognized artifact:', base)
|
||||
continue
|
||||
}
|
||||
|
||||
rows[platform] ||= {}
|
||||
rows[platform][slot] = {
|
||||
key: `v${version}/${base}`,
|
||||
size: fs.statSync(f).size,
|
||||
sha: sha512(f),
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(rows).length === 0) {
|
||||
console.error('register-releases: no recognized artifacts in', dir)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const isLatest = makeLatest ? 1 : 0
|
||||
const sql = []
|
||||
for (const [platform, r] of Object.entries(rows)) {
|
||||
const m = r.main || { key: '', size: 0, sha: '' }
|
||||
const u = r.upd || { key: '', size: 0, sha: '' }
|
||||
if (makeLatest) {
|
||||
// Clear the previous latest for this platform before promoting the new row.
|
||||
sql.push(`UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';`)
|
||||
}
|
||||
sql.push(
|
||||
`INSERT OR REPLACE INTO releases ` +
|
||||
`(version, platform, filename, size, sha512, update_filename, update_size, update_sha512, is_latest) ` +
|
||||
`VALUES ('${version}', '${platform}', '${q(m.key)}', ${m.size}, '${q(m.sha)}', ` +
|
||||
`'${q(u.key)}', ${u.size}, '${q(u.sha)}', ${isLatest});`
|
||||
)
|
||||
}
|
||||
|
||||
fs.writeFileSync(sqlPath, sql.join('\n') + '\n')
|
||||
console.log(`register-releases: wrote ${sql.length} statement(s) to ${sqlPath}`)
|
||||
154
.github/workflows/publish-desktop.yml
vendored
Normal file
154
.github/workflows/publish-desktop.yml
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
name: Publish Desktop
|
||||
|
||||
# STAGE 3 of the decoupled release pipeline: PROMOTE a built + notarized version
|
||||
# to "live". By this point:
|
||||
# - stage 1 (Release Desktop) built the installers, mirrored them to R2, and
|
||||
# registered them in D1 as unpublished (is_latest=0);
|
||||
# - stage 2 (local desktop/build/notarize-dmg.sh) notarized + stapled the mac
|
||||
# dmgs and re-uploaded the stapled bytes to R2.
|
||||
#
|
||||
# This workflow, triggered manually with the version to publish:
|
||||
# 1. pulls every artifact for that version back from R2,
|
||||
# 2. recomputes sha512 from the real (stapled) bytes and updates D1,
|
||||
# 3. flips is_latest=1 for that version (clearing the previous latest per
|
||||
# platform) UNLESS it's a pre-release, which is recorded but never latest,
|
||||
# 4. creates/updates the GitHub Release and attaches the installers.
|
||||
#
|
||||
# Run only after the mac dmgs for this version are notarized + re-uploaded.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to publish (e.g. 1.2.0). Must already be built + notarized."
|
||||
type: string
|
||||
required: true
|
||||
make_latest:
|
||||
description: "Mark this version as latest on the site (uncheck for a dry re-hash only)."
|
||||
type: boolean
|
||||
default: true
|
||||
github_release:
|
||||
description: "Create/update the GitHub Release and attach installers."
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish ${{ github.event.inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Guard on Cloudflare secrets
|
||||
id: guard
|
||||
env:
|
||||
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$CF_TOKEN" ]; then
|
||||
echo "::error::CLOUDFLARE_API_TOKEN not set — cannot publish."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve version artifacts from D1
|
||||
id: rows
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
VER: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
# The build stage inserted one row per artifact with filename = v<VER>/<base>.
|
||||
# Read them back so we know exactly which objects to pull from R2.
|
||||
out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \
|
||||
--command "SELECT platform, filename, update_filename FROM releases WHERE version = '${VER}';")"
|
||||
echo "$out"
|
||||
echo "$out" | node -e '
|
||||
const fs = require("fs");
|
||||
const data = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const rows = (Array.isArray(data) ? data : [data])
|
||||
.flatMap(r => (r.results || []));
|
||||
if (!rows.length) {
|
||||
console.error("No D1 rows for this version — did stage 1 (build) run?");
|
||||
process.exit(1);
|
||||
}
|
||||
fs.writeFileSync(process.env.GITHUB_OUTPUT, "count=" + rows.length + "\n", { flag: "a" });
|
||||
fs.writeFileSync("rows.json", JSON.stringify(rows));
|
||||
'
|
||||
|
||||
- name: Download version artifacts from R2
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
# Pull BOTH the manual-download file (filename: dmg/exe) and the mac
|
||||
# auto-update file (update_filename: zip) for every row. Keys are
|
||||
# "v<VER>/<base>"; the R2 key is "desktop/<key>".
|
||||
for key in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => { if (r.filename) console.log(r.filename); if (r.update_filename) console.log(r.update_filename); })'); do
|
||||
base="$(basename "$key")"
|
||||
r2key="desktop/${key}"
|
||||
echo "==> Downloading r2://${R2_BUCKET}/${r2key} -> dist/${base}"
|
||||
npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${r2key}" \
|
||||
--file "dist/${base}" --remote
|
||||
done
|
||||
echo "Downloaded:"; ls -la dist
|
||||
|
||||
- name: Reminder — mac dmgs must be notarized before publishing
|
||||
run: |
|
||||
# Stapling can only be validated on macOS (xcrun stapler validate),
|
||||
# which this Linux runner doesn't have. The authoritative check runs in
|
||||
# stage 2 (desktop/build/notarize-dmg.sh) before re-uploading to R2.
|
||||
# This step is just a loud reminder in the log.
|
||||
echo "::notice::Publishing assumes the mac dmgs pulled from R2 are already notarized + stapled (stage 2). If you skipped stage 2, users will hit Gatekeeper warnings."
|
||||
ls -la dist/*.dmg 2>/dev/null || echo "(no dmg in this version — win-only publish)"
|
||||
|
||||
- name: Update D1 (recompute sha512 + set latest)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
VER: ${{ github.event.inputs.version }}
|
||||
MAKE_LATEST: ${{ github.event.inputs.make_latest }}
|
||||
run: |
|
||||
# Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never
|
||||
# become latest, so the site keeps serving the last stable build.
|
||||
case "$VER" in
|
||||
*-*) is_pre=1 ;;
|
||||
*) is_pre=0 ;;
|
||||
esac
|
||||
if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then
|
||||
latest_flag="--latest"; echo "==> Publishing $VER as latest."
|
||||
else
|
||||
latest_flag=""; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)."
|
||||
fi
|
||||
|
||||
# Re-hash the real (stapled) bytes and re-store every row with both the
|
||||
# dmg (manual) and mac zip (auto-update) columns. Same script as the
|
||||
# build stage; --latest also clears the previous latest per platform.
|
||||
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag
|
||||
echo "==> D1 statements:"; cat d1.sql
|
||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||
|
||||
- name: Create/update GitHub Release and attach installers
|
||||
if: github.event.inputs.github_release == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VER: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
tag="v${VER}"
|
||||
case "$VER" in
|
||||
*-*) prerelease="--prerelease" ;;
|
||||
*) prerelease="" ;;
|
||||
esac
|
||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release create "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--title "$tag" --generate-notes $prerelease
|
||||
fi
|
||||
# --clobber so re-runs overwrite the stapled/updated assets. The mac
|
||||
# zip is the auto-update artifact; attach it too so the GitHub Release
|
||||
# is a complete mirror (nullglob avoids errors when a type is absent).
|
||||
shopt -s nullglob
|
||||
gh release upload "$tag" dist/*.dmg dist/*.zip dist/*.exe \
|
||||
--repo "$GITHUB_REPOSITORY" --clobber
|
||||
301
.github/workflows/release.yml
vendored
Normal file
301
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,301 @@
|
||||
name: Release Desktop
|
||||
|
||||
# STAGE 1 of the decoupled release pipeline: BUILD ONLY.
|
||||
# Builds the desktop client for macOS (arm64 + x64) and Windows (x64), mirrors
|
||||
# the installers to R2, and registers them in D1 as UNPUBLISHED (is_latest=0)
|
||||
# so the website keeps serving the previous release. It does NOT notarize
|
||||
# (Apple's notary service stalls this large bundle for hours) and does NOT
|
||||
# create a GitHub Release.
|
||||
#
|
||||
# Full flow:
|
||||
# 1. (this workflow) build + upload to R2 + D1 as unpublished.
|
||||
# 2. (local) download the mac dmgs, run desktop/build/notarize-dmg.sh to
|
||||
# notarize + staple + re-upload the stapled dmgs to R2.
|
||||
# 3. (Publish Desktop workflow) flip D1 is_latest=1 and attach GitHub
|
||||
# Release assets — makes the version live on the site.
|
||||
#
|
||||
# Manual only: run stage 1 via workflow_dispatch. Tag pushes do NOT trigger a
|
||||
# build, so cutting a release tag never rebuilds installers or overwrites R2.
|
||||
on:
|
||||
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
|
||||
|
||||
# Compile renderer + main in its OWN step, alone, so the npm.cmd batch
|
||||
# wrapper (see the note on the build step below) can't take out anything
|
||||
# after it.
|
||||
- name: Compile (vite + tsc)
|
||||
working-directory: desktop
|
||||
shell: bash
|
||||
run: npm run build
|
||||
|
||||
- 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 }}
|
||||
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||
run: |
|
||||
# Pick the signing cert for THIS platform only. The mac and win secrets
|
||||
# are both present in the job env, but a mac cert must never leak into a
|
||||
# Windows build (electron-builder would try to load it and fail), and
|
||||
# vice versa. electron-builder reads a single CSC_LINK/CSC_KEY_PASSWORD
|
||||
# pair, so we set it per-platform. An empty CSC_LINK is treated by
|
||||
# electron-builder as a broken cert path, so we leave it entirely unset
|
||||
# for an unsigned build.
|
||||
#
|
||||
# NOTE: we only ever `export`, never `unset`, GitHub-injected env vars
|
||||
# (an `unset` can return non-zero and abort under errexit).
|
||||
case "${{ matrix.platform }}" in
|
||||
mac)
|
||||
if [ -n "$MAC_CSC_LINK" ]; then
|
||||
export CSC_LINK="$MAC_CSC_LINK"
|
||||
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
||||
fi
|
||||
;;
|
||||
win)
|
||||
if [ -n "$WIN_CSC_LINK" ]; then
|
||||
export CSC_LINK="$WIN_CSC_LINK"
|
||||
export CSC_KEY_PASSWORD="$WIN_CSC_KEY_PASSWORD"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Never let electron-builder publish: our publish target is a generic
|
||||
# (read-only) feed served from R2/D1, which it can't upload to. We mirror
|
||||
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
||||
# `--publish never` still emits the latest*.yml files.
|
||||
#
|
||||
# CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to
|
||||
# inject mac.binaries (the backend Mach-O files to hardened-sign for
|
||||
# notarization) — it's a pure no-op on Windows. Passing --config on
|
||||
# Windows was what silently broke the Windows build (it produced no
|
||||
# installer while the job still reported success; Windows worked fine
|
||||
# before --config was introduced). So Windows uses the plain
|
||||
# package.json build config and only mac uses the dynamic one.
|
||||
#
|
||||
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
|
||||
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
|
||||
# make bash return before the wrapped process finishes. node skips it.
|
||||
case "${{ matrix.platform }}" in
|
||||
mac) config_arg="--config electron-builder.js" ;;
|
||||
*) config_arg="" ;;
|
||||
esac
|
||||
node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never
|
||||
|
||||
# Upload artifacts regardless of outcome, so a failed run still surfaces
|
||||
# the built installers (and, on success, the notarized+stapled dmg).
|
||||
- name: Upload artifacts
|
||||
if: always()
|
||||
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/*.zip
|
||||
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 + their .blockmap (used by electron-updater for
|
||||
# differential downloads) from every per-platform artifact dir. The
|
||||
# .yml feed is generated dynamically by the /update Function from D1,
|
||||
# so the yml files themselves don't need to go to R2.
|
||||
# .zip is the mac auto-update artifact (electron-updater's MacUpdater
|
||||
# can ONLY consume zip, not dmg — the dmg is for manual downloads).
|
||||
find artifacts -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' \) -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: |
|
||||
# This build job ALWAYS registers rows as unpublished (is_latest=0), so
|
||||
# /download/<p>/latest keeps serving the previous release and the new
|
||||
# version stays invisible on the site. macOS dmgs still need to be
|
||||
# notarized+stapled locally (build/notarize-dmg.sh) before they're
|
||||
# safe to ship. Promotion to latest happens later, only after
|
||||
# notarization, via the separate "Publish Desktop" workflow.
|
||||
echo "==> Registering $VER as unpublished (is_latest=0)."
|
||||
|
||||
# Build one upsert per (version, platform) carrying both the dmg
|
||||
# (manual download) and the mac zip (auto-update) columns. See
|
||||
# .github/scripts/register-releases.mjs for the mapping. No --latest
|
||||
# here: rows stay unpublished until the publish workflow promotes them.
|
||||
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
|
||||
echo "==> D1 statements:"; cat d1.sql
|
||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -45,3 +45,20 @@ dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.cow.pid
|
||||
|
||||
# Desktop backend packaging: keep the source files (spec/requirements/script)
|
||||
# tracked even though the generic build/ rule above ignores them, but never
|
||||
# track the build outputs or local venv.
|
||||
!desktop/build/
|
||||
desktop/build/*
|
||||
!desktop/build/cowagent-backend.spec
|
||||
!desktop/build/requirements-desktop.txt
|
||||
!desktop/build/build-backend.sh
|
||||
!desktop/build/entitlements.mac.plist
|
||||
!desktop/build/notarize-dmg.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/
|
||||
|
||||
.wrangler/
|
||||
|
||||
11
README.md
11
README.md
@@ -12,7 +12,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
[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/zh/README-Hant.md">繁體中文</a>] | [<a href="docs/ja/README.md">日本語</a>]
|
||||
</p>
|
||||
|
||||
**CowAgent** is an open-source super AI assistant that proactively plans tasks, controls your computer and external services, creates and runs Skills, builds a personal knowledge base and long-term memory, and grows alongside you through self-evolution — a reference implementation of Agent Harness engineering.
|
||||
@@ -24,6 +24,7 @@ CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major
|
||||
<a href="https://docs.cowagent.ai/intro/index">📖 Docs</a> ·
|
||||
<a href="https://docs.cowagent.ai/guide/quick-start">🚀 Quick Start</a> ·
|
||||
<a href="https://skills.cowagent.ai/">🧩 Skill Hub</a> ·
|
||||
<a href="https://cowagent.ai/download/">💻 Download</a> ·
|
||||
<a href="https://link-ai.tech/cowagent/create">☁️ Try Online</a>
|
||||
</p>
|
||||
|
||||
@@ -95,6 +96,8 @@ cow skill install <name> # install a skill
|
||||
cow install-browser # install browser automation
|
||||
```
|
||||
|
||||
> 💻 Desktop client: download the **[CowAgent Desktop client](https://cowagent.ai/download/)** (macOS / Windows) — the backend is bundled, ready to use out of the box.
|
||||
|
||||
<br/>
|
||||
|
||||
## 🤖 Models
|
||||
@@ -103,13 +106,13 @@ CowAgent supports all mainstream LLM providers. **Chat, vision, image generation
|
||||
|
||||
| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding |
|
||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
||||
| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
||||
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||
@@ -199,6 +202,8 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
|
||||
|
||||
## 🏷 Changelog
|
||||
|
||||
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [Desktop client](https://cowagent.ai/download/) for macOS / Windows, knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models.
|
||||
|
||||
> **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.
|
||||
|
||||
@@ -424,6 +424,11 @@ def run_evolution_for_session(
|
||||
enable_skills=True,
|
||||
runtime_info=getattr(agent, "runtime_info", None),
|
||||
)
|
||||
# Mark this as a restricted review agent so runtime MCP reconciliation
|
||||
# (ToolManager.sync_mcp_into_agent) will NOT silently re-inject MCP tools
|
||||
# that _select_tools()/_guard_tools() intentionally withheld. Without this
|
||||
# flag the review boundary would be re-opened on the first LLM turn.
|
||||
review_agent._evolution_restricted = True
|
||||
# Reuse the live model so it follows the user's configured model.
|
||||
review_agent.model = agent.model
|
||||
# Inject the evolution task brief AFTER the full system prompt: the agent
|
||||
|
||||
@@ -17,6 +17,7 @@ import shutil
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional, Iterable
|
||||
from urllib.parse import quote
|
||||
|
||||
from common.log import logger
|
||||
from config import conf
|
||||
@@ -32,6 +33,10 @@ class KnowledgeService:
|
||||
|
||||
PROTECTED_FILES = {"index.md", "log.md"}
|
||||
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
|
||||
IMPORT_EXTENSIONS = {".md", ".txt"}
|
||||
MAX_IMPORT_FILES = 100
|
||||
MAX_IMPORT_FILE_SIZE = 10 * 1024 * 1024
|
||||
MAX_IMPORT_TOTAL_SIZE = 200 * 1024 * 1024
|
||||
|
||||
def __init__(self, workspace_root: str, memory_manager=None):
|
||||
self.workspace_root = os.path.abspath(workspace_root)
|
||||
@@ -75,7 +80,14 @@ class KnowledgeService:
|
||||
|
||||
def _manager(self):
|
||||
if self._memory_manager is None:
|
||||
self._memory_manager = MemoryManager(MemoryConfig(workspace_root=self.workspace_root))
|
||||
# Reuse the shared embedding provider selection so knowledge index
|
||||
# sync gets vectors too, instead of degrading to keyword-only.
|
||||
from agent.memory.embedding import create_default_embedding_provider
|
||||
embedding_provider = create_default_embedding_provider()
|
||||
self._memory_manager = MemoryManager(
|
||||
MemoryConfig(workspace_root=self.workspace_root),
|
||||
embedding_provider=embedding_provider,
|
||||
)
|
||||
return self._memory_manager
|
||||
|
||||
@staticmethod
|
||||
@@ -100,9 +112,9 @@ class KnowledgeService:
|
||||
raise error[0]
|
||||
return result[0] if result else None
|
||||
|
||||
def _sync_index(self, old_paths: Iterable[str]):
|
||||
def _sync_index(self, old_paths: Iterable[str], force: bool = False):
|
||||
old_paths = sorted(set(old_paths))
|
||||
if not old_paths:
|
||||
if not old_paths and not force:
|
||||
return
|
||||
manager = self._manager()
|
||||
for rel_path in old_paths:
|
||||
@@ -110,6 +122,195 @@ class KnowledgeService:
|
||||
manager.mark_dirty()
|
||||
self._run_sync(manager.sync())
|
||||
|
||||
@staticmethod
|
||||
def _extract_title(md_path: Path, fallback: str) -> str:
|
||||
"""Read a markdown file's H1 title, falling back to the file stem."""
|
||||
try:
|
||||
with open(md_path, "r", encoding="utf-8") as f:
|
||||
for _ in range(20):
|
||||
line = f.readline()
|
||||
if not line:
|
||||
break
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("# "):
|
||||
return stripped[2:].strip() or fallback
|
||||
except Exception:
|
||||
pass
|
||||
return fallback
|
||||
|
||||
def rebuild_index_md(self) -> bool:
|
||||
"""Regenerate knowledge/index.md from the actual directory tree.
|
||||
|
||||
Keeps the index in sync with real files so it never drifts or loses
|
||||
documents. Returns True when the file was (re)written.
|
||||
"""
|
||||
root = Path(self.knowledge_dir)
|
||||
if not root.is_dir():
|
||||
return False
|
||||
|
||||
def collect(dir_path: Path) -> list:
|
||||
# Return sorted (rel_path, title) tuples for *.md under dir_path,
|
||||
# excluding protected files at the knowledge root and dot files.
|
||||
entries = []
|
||||
for md in sorted(dir_path.rglob("*.md")):
|
||||
rel = md.relative_to(root).as_posix()
|
||||
if any(part.startswith(".") for part in md.relative_to(root).parts):
|
||||
continue
|
||||
if rel in self.PROTECTED_FILES:
|
||||
continue
|
||||
entries.append((rel, self._extract_title(md, md.stem)))
|
||||
return entries
|
||||
|
||||
all_entries = collect(root)
|
||||
|
||||
def link(rel: str) -> str:
|
||||
# Encode each path segment so spaces / special chars stay valid in
|
||||
# markdown links, while keeping the slashes between segments.
|
||||
encoded = "/".join(quote(part) for part in rel.split("/"))
|
||||
return f"./{encoded}"
|
||||
|
||||
lines = ["# 知识库目录", ""]
|
||||
# Root-level documents first (no category dir).
|
||||
root_docs = [(rel, title) for rel, title in all_entries if "/" not in rel]
|
||||
for rel, title in root_docs:
|
||||
lines.append(f"- [{title}]({link(rel)})")
|
||||
if root_docs:
|
||||
lines.append("")
|
||||
|
||||
# Group remaining documents by their top-level category.
|
||||
categories = {}
|
||||
for rel, title in all_entries:
|
||||
if "/" not in rel:
|
||||
continue
|
||||
category = rel.split("/", 1)[0]
|
||||
categories.setdefault(category, []).append((rel, title))
|
||||
|
||||
for category in sorted(categories.keys()):
|
||||
lines.append(f"## {category}")
|
||||
for rel, title in categories[category]:
|
||||
lines.append(f"- [{title}]({link(rel)})")
|
||||
lines.append("")
|
||||
|
||||
content = "\n".join(lines).rstrip() + "\n"
|
||||
index_path = root / "index.md"
|
||||
try:
|
||||
index_path.write_text(content, encoding="utf-8")
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.warning(f"[KnowledgeService] Failed to rebuild index.md: {exc}")
|
||||
return False
|
||||
|
||||
def _sanitize_document_name(self, filename: str) -> str:
|
||||
name = os.path.basename((filename or "").replace("\\", "/")).strip()
|
||||
if not name:
|
||||
raise ValueError("filename is required")
|
||||
stem, ext = os.path.splitext(name)
|
||||
if ext.lower() not in self.IMPORT_EXTENSIONS:
|
||||
raise ValueError(f"unsupported file type: {ext or name}")
|
||||
if not stem or stem in (".", "..") or self.INVALID_NAME_RE.search(stem):
|
||||
raise ValueError("invalid filename")
|
||||
safe_name = f"{stem}.md"
|
||||
self._ensure_not_protected(safe_name)
|
||||
return safe_name
|
||||
|
||||
@staticmethod
|
||||
def _decode_document_content(content) -> str:
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if not isinstance(content, (bytes, bytearray)):
|
||||
raise ValueError("document content is required")
|
||||
return bytes(content).decode("utf-8-sig", errors="replace")
|
||||
|
||||
def _resolve_import_destination(self, target_category: str, filename: str,
|
||||
conflict_strategy: str) -> tuple:
|
||||
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}")
|
||||
|
||||
safe_name = self._sanitize_document_name(filename)
|
||||
destination = target_full / safe_name
|
||||
rel_path = f"{target_rel}/{safe_name}"
|
||||
|
||||
if destination.exists():
|
||||
if conflict_strategy == "skip":
|
||||
return rel_path, destination, "skip"
|
||||
if conflict_strategy == "rename":
|
||||
stem = destination.stem
|
||||
suffix = destination.suffix
|
||||
for index in range(1, 1000):
|
||||
candidate = target_full / f"{stem}-{index}{suffix}"
|
||||
if not candidate.exists():
|
||||
candidate_rel = f"{target_rel}/{candidate.name}"
|
||||
return candidate_rel, candidate, "write"
|
||||
raise FileExistsError(f"target already exists: {rel_path}")
|
||||
if conflict_strategy != "overwrite":
|
||||
raise ValueError("invalid conflict strategy")
|
||||
return rel_path, destination, "write"
|
||||
|
||||
def create_document(self, path: str, content: str = "", overwrite: bool = False) -> dict:
|
||||
rel_path, full_path = self._resolve_path(path, kind="document")
|
||||
self._ensure_not_protected(rel_path)
|
||||
if len((content or "").encode("utf-8")) > self.MAX_IMPORT_FILE_SIZE:
|
||||
raise ValueError("file too large")
|
||||
if full_path.exists() and not overwrite:
|
||||
raise FileExistsError(f"target already exists: {rel_path}")
|
||||
old_paths = [rel_path] if full_path.exists() else []
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content or "", encoding="utf-8")
|
||||
# Keep index.md in sync before reindexing so it is indexed too.
|
||||
self.rebuild_index_md()
|
||||
self._sync_index(old_paths, force=True)
|
||||
return {"path": rel_path, "created": True, "overwritten": bool(old_paths)}
|
||||
|
||||
def import_documents(self, target_category: str, files: Iterable[dict],
|
||||
conflict_strategy: str = "skip") -> dict:
|
||||
if not isinstance(files, list):
|
||||
raise ValueError("files must be a list")
|
||||
if len(files) > self.MAX_IMPORT_FILES:
|
||||
raise ValueError(f"too many files: max {self.MAX_IMPORT_FILES}")
|
||||
results = []
|
||||
old_paths = []
|
||||
imported = skipped = failed = 0
|
||||
total_size = 0
|
||||
|
||||
for item in files:
|
||||
filename = item.get("filename") if isinstance(item, dict) else None
|
||||
try:
|
||||
content_bytes = item.get("content") if isinstance(item, dict) else None
|
||||
size = len(content_bytes.encode("utf-8")) if isinstance(content_bytes, str) else len(content_bytes or b"")
|
||||
total_size += size
|
||||
if total_size > self.MAX_IMPORT_TOTAL_SIZE:
|
||||
raise ValueError("import batch too large")
|
||||
if size > self.MAX_IMPORT_FILE_SIZE:
|
||||
raise ValueError("file too large")
|
||||
rel_path, destination, mode = self._resolve_import_destination(
|
||||
target_category, filename, conflict_strategy
|
||||
)
|
||||
if mode == "skip":
|
||||
skipped += 1
|
||||
results.append({"filename": filename, "path": rel_path, "status": "skipped",
|
||||
"reason": "target_exists"})
|
||||
continue
|
||||
|
||||
old_exists = destination.exists()
|
||||
content = self._decode_document_content(content_bytes)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(content, encoding="utf-8")
|
||||
if old_exists:
|
||||
old_paths.append(rel_path)
|
||||
imported += 1
|
||||
results.append({"filename": filename, "path": rel_path, "status": "imported",
|
||||
"overwritten": old_exists})
|
||||
except Exception as exc:
|
||||
failed += 1
|
||||
results.append({"filename": filename or "", "status": "failed", "reason": str(exc)})
|
||||
|
||||
if imported:
|
||||
# Keep index.md in sync before reindexing so it is indexed too.
|
||||
self.rebuild_index_md()
|
||||
self._sync_index(old_paths, force=True)
|
||||
return {"results": results, "imported": imported, "skipped": skipped, "failed": failed}
|
||||
|
||||
def create_category(self, path: str) -> dict:
|
||||
rel_path, full_path = self._resolve_path(path, kind="category")
|
||||
if full_path.exists():
|
||||
@@ -283,14 +484,18 @@ class KnowledgeService:
|
||||
if not is_root:
|
||||
stats["pages"] += 1
|
||||
stats["size"] += size
|
||||
title = name.replace(".md", "")
|
||||
try:
|
||||
with open(full, "r", encoding="utf-8") as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line.startswith("# "):
|
||||
title = first_line[2:].strip()
|
||||
except Exception:
|
||||
pass
|
||||
# Prefer the H1 heading as a readable title for normal docs.
|
||||
# System files (index.md / log.md) keep their filename so the
|
||||
# tree never hides what they actually are.
|
||||
title = name[:-3]
|
||||
if name not in self.PROTECTED_FILES:
|
||||
try:
|
||||
with open(full, "r", encoding="utf-8") as f:
|
||||
first_line = f.readline().strip()
|
||||
if first_line.startswith("# "):
|
||||
title = first_line[2:].strip() or title
|
||||
except Exception:
|
||||
pass
|
||||
files.append({"name": name, "title": title, "size": size})
|
||||
return files, children
|
||||
|
||||
@@ -416,6 +621,15 @@ class KnowledgeService:
|
||||
result = self.delete_documents(payload.get("paths") or [])
|
||||
elif action == "move_documents":
|
||||
result = self.move_documents(payload.get("paths") or [], payload.get("target_category"))
|
||||
elif action == "create_document":
|
||||
result = self.create_document(payload.get("path"), payload.get("content", ""),
|
||||
payload.get("overwrite", False))
|
||||
elif action == "import_documents":
|
||||
result = self.import_documents(
|
||||
payload.get("target_category"),
|
||||
payload.get("files") or [],
|
||||
payload.get("conflict_strategy", "skip"),
|
||||
)
|
||||
else:
|
||||
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
||||
return {"action": action, "code": 200, "message": "success", "payload": result}
|
||||
|
||||
@@ -7,7 +7,7 @@ conversation history persistence (SQLite).
|
||||
|
||||
from agent.memory.manager import MemoryManager
|
||||
from agent.memory.config import MemoryConfig, get_default_memory_config, set_global_memory_config
|
||||
from agent.memory.embedding import create_embedding_provider
|
||||
from agent.memory.embedding import create_embedding_provider, create_default_embedding_provider
|
||||
from agent.memory.conversation_store import ConversationStore, get_conversation_store
|
||||
from agent.memory.summarizer import ensure_daily_memory_file
|
||||
|
||||
@@ -17,6 +17,7 @@ __all__ = [
|
||||
'get_default_memory_config',
|
||||
'set_global_memory_config',
|
||||
'create_embedding_provider',
|
||||
'create_default_embedding_provider',
|
||||
'ConversationStore',
|
||||
'get_conversation_store',
|
||||
'ensure_daily_memory_file',
|
||||
|
||||
@@ -16,6 +16,7 @@ from agent.memory.embedding.provider import (
|
||||
OpenAIEmbeddingProvider,
|
||||
create_embedding_provider,
|
||||
)
|
||||
from agent.memory.embedding.factory import create_default_embedding_provider
|
||||
from agent.memory.embedding.rebuild import (
|
||||
RebuildResult,
|
||||
clear_index,
|
||||
@@ -33,6 +34,7 @@ __all__ = [
|
||||
"EmbeddingProvider",
|
||||
"OpenAIEmbeddingProvider",
|
||||
"create_embedding_provider",
|
||||
"create_default_embedding_provider",
|
||||
"RebuildResult",
|
||||
"clear_index",
|
||||
"rebuild_in_process",
|
||||
|
||||
209
agent/memory/embedding/factory.py
Normal file
209
agent/memory/embedding/factory.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Shared embedding provider factory.
|
||||
|
||||
Resolves the embedding provider purely from config.json, so every caller
|
||||
(agent initialization, knowledge base sync, index rebuild, ...) selects the
|
||||
same provider instead of silently degrading to keyword-only search.
|
||||
|
||||
Two paths:
|
||||
A. Default (no `embedding_provider` in config.json):
|
||||
Auto-init OpenAI -> LinkAI fallback.
|
||||
B. Explicit (`embedding_provider` is set):
|
||||
Initialize the requested vendor with unified dim (default per vendor).
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from common.log import logger
|
||||
|
||||
# Track whether the embedding model log has been printed in this process,
|
||||
# so we avoid spamming it once per session/caller.
|
||||
_embedding_logged: bool = False
|
||||
|
||||
|
||||
def create_default_embedding_provider():
|
||||
"""Build the embedding provider from config, or None for keyword-only mode."""
|
||||
from config import conf
|
||||
|
||||
explicit_provider = (conf().get("embedding_provider") or "").strip().lower()
|
||||
if not explicit_provider:
|
||||
return _init_legacy_provider()
|
||||
return _init_explicit_provider(explicit_provider)
|
||||
|
||||
|
||||
def _init_legacy_provider():
|
||||
"""Legacy auto-init path: OpenAI -> LinkAI."""
|
||||
from agent.memory.embedding.provider import create_embedding_provider
|
||||
from config import conf
|
||||
|
||||
embedding_provider = None
|
||||
embedding_model = None
|
||||
|
||||
openai_api_key = conf().get("open_ai_api_key", "")
|
||||
openai_api_base = conf().get("open_ai_api_base", "")
|
||||
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||
try:
|
||||
model = "text-embedding-3-small"
|
||||
embedding_provider = create_embedding_provider(
|
||||
provider="openai",
|
||||
model=model,
|
||||
api_key=openai_api_key,
|
||||
api_base=openai_api_base or "https://api.openai.com/v1",
|
||||
)
|
||||
embedding_model = f"openai/{model}"
|
||||
except Exception as e:
|
||||
logger.warning(f"[EmbeddingFactory] OpenAI embedding failed: {e}")
|
||||
|
||||
if embedding_provider is None:
|
||||
linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "")
|
||||
linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
|
||||
if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||
try:
|
||||
model = "text-embedding-3-small"
|
||||
embedding_provider = create_embedding_provider(
|
||||
provider="linkai",
|
||||
model=model,
|
||||
api_key=linkai_api_key,
|
||||
api_base=f"{linkai_api_base}/v1",
|
||||
)
|
||||
embedding_model = f"linkai/{model}"
|
||||
except Exception as e:
|
||||
logger.warning(f"[EmbeddingFactory] LinkAI embedding failed: {e}")
|
||||
|
||||
if embedding_provider is not None and embedding_model:
|
||||
_log_provider_once(f"{embedding_model} (dim={embedding_provider.dimensions})")
|
||||
|
||||
return embedding_provider
|
||||
|
||||
|
||||
def _init_explicit_provider(provider_key: str):
|
||||
"""Explicit-provider path: build the configured vendor."""
|
||||
from agent.memory.embedding.provider import EMBEDDING_VENDORS, create_embedding_provider
|
||||
from config import conf
|
||||
|
||||
# Custom providers ("custom:<id>") resolve credentials from custom_providers.
|
||||
resolved_provider_key = provider_key
|
||||
if provider_key.startswith("custom:"):
|
||||
resolved_provider_key = "custom"
|
||||
|
||||
meta = EMBEDDING_VENDORS.get(resolved_provider_key)
|
||||
if meta is None:
|
||||
logger.error(
|
||||
f"[EmbeddingFactory] Unknown embedding_provider '{provider_key}'. "
|
||||
f"Supported: {sorted(EMBEDDING_VENDORS.keys())}. "
|
||||
f"Memory will run in keyword-only mode."
|
||||
)
|
||||
return None
|
||||
|
||||
api_key = _resolve_api_key(provider_key)
|
||||
api_base = _resolve_api_base(provider_key, meta["default_base_url"])
|
||||
|
||||
if not api_key:
|
||||
logger.error(
|
||||
f"[EmbeddingFactory] embedding_provider='{provider_key}' is set but its "
|
||||
f"API key is missing. Memory will run in keyword-only mode."
|
||||
)
|
||||
return None
|
||||
|
||||
model = (conf().get("embedding_model") or "").strip()
|
||||
# Custom providers without a model fall back to the provider's default.
|
||||
if not model and resolved_provider_key == "custom":
|
||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||
_, custom_id = parse_custom_bot_type(provider_key)
|
||||
if custom_id:
|
||||
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||
if entry and entry.get("model"):
|
||||
model = entry["model"]
|
||||
if not model and resolved_provider_key != "custom":
|
||||
model = meta["default_model"]
|
||||
|
||||
try:
|
||||
cfg_dim = int(conf().get("embedding_dimensions") or 0)
|
||||
except (TypeError, ValueError):
|
||||
cfg_dim = 0
|
||||
dim = cfg_dim if cfg_dim > 0 else meta["default_dimensions"]
|
||||
|
||||
try:
|
||||
provider = create_embedding_provider(
|
||||
provider=resolved_provider_key,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
dimensions=dim,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[EmbeddingFactory] Failed to init embedding provider "
|
||||
f"'{provider_key}/{model}': {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
_log_provider_once(f"{provider_key}/{model} (dim={provider.dimensions})")
|
||||
return provider
|
||||
|
||||
|
||||
def _resolve_api_key(provider_key: str) -> str:
|
||||
"""Pick the API key for an explicit embedding provider from config."""
|
||||
from config import conf
|
||||
|
||||
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:
|
||||
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||
if entry:
|
||||
return entry.get("api_key", "")
|
||||
return ""
|
||||
|
||||
key_map = {
|
||||
"openai": "open_ai_api_key",
|
||||
"linkai": "linkai_api_key",
|
||||
"dashscope": "dashscope_api_key",
|
||||
"doubao": "ark_api_key",
|
||||
"zhipu": "zhipu_ai_api_key",
|
||||
}
|
||||
field = key_map.get(provider_key)
|
||||
if not field:
|
||||
return ""
|
||||
value = conf().get(field, "") or ""
|
||||
if value in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def _resolve_api_base(provider_key: str, default_base: str) -> str:
|
||||
"""Pick the API base for an explicit embedding provider from config."""
|
||||
from config import conf
|
||||
|
||||
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:
|
||||
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||
if entry and entry.get("api_base"):
|
||||
return entry["api_base"]
|
||||
return default_base
|
||||
|
||||
base_map = {
|
||||
"openai": "open_ai_api_base",
|
||||
"linkai": "linkai_api_base",
|
||||
"doubao": "ark_base_url",
|
||||
"zhipu": "zhipu_ai_api_base",
|
||||
}
|
||||
field = base_map.get(provider_key)
|
||||
if not field:
|
||||
return default_base
|
||||
value = (conf().get(field) or "").strip()
|
||||
if not value:
|
||||
return default_base
|
||||
if provider_key == "linkai" and not value.rstrip("/").endswith("/v1"):
|
||||
return f"{value.rstrip('/')}/v1"
|
||||
return value
|
||||
|
||||
|
||||
def _log_provider_once(detail: str):
|
||||
global _embedding_logged
|
||||
if not _embedding_logged:
|
||||
logger.info(f"[EmbeddingFactory] Embedding model in use: {detail}")
|
||||
_embedding_logged = True
|
||||
@@ -7,10 +7,14 @@ Supports multiple OpenAI-compatible embedding vendors:
|
||||
- dashscope (Aliyun Tongyi text-embedding-v4)
|
||||
- doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark)
|
||||
- zhipu (ZhipuAI embedding-3)
|
||||
- custom (any OpenAI-compatible endpoint)
|
||||
|
||||
Vendor keys here intentionally match the project's bot_type constants in
|
||||
common.const (OPENAI, LINKAI, QWEN_DASHSCOPE, DOUBAO, ZHIPU_AI).
|
||||
|
||||
Custom providers (bot_type "custom" or "custom:<id>") reuse the same
|
||||
OpenAI-compatible REST client with user-supplied api_key / api_base.
|
||||
|
||||
All providers share a single OpenAI-compatible REST client. Vendor-specific
|
||||
behaviors (truncation, query instruction prefix) are configured via metadata.
|
||||
"""
|
||||
@@ -138,6 +142,22 @@ EMBEDDING_VENDORS = {
|
||||
"query_instruction": "",
|
||||
"max_batch_size": 64,
|
||||
},
|
||||
# Custom provider — any OpenAI-compatible /embeddings endpoint. The
|
||||
# user must supply api_key + api_base + model via the web console
|
||||
# (stored in custom_providers list or legacy custom_api_key / custom_api_base).
|
||||
# Dimensions defaults to 1024 but can be overridden via config's
|
||||
# embedding_dimensions. No dim-param support assumption — safest
|
||||
# default for unknown endpoints.
|
||||
"custom": {
|
||||
"default_base_url": "",
|
||||
"default_model": "",
|
||||
"default_dimensions": 1024,
|
||||
"supports_dim_param": False,
|
||||
"needs_client_truncate": False,
|
||||
"needs_client_normalize": True,
|
||||
"query_instruction": "",
|
||||
"max_batch_size": 64,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -472,10 +492,19 @@ def create_embedding_provider(
|
||||
)
|
||||
|
||||
final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"]
|
||||
resolved_model = model or meta["default_model"]
|
||||
resolved_base = api_base or meta["default_base_url"]
|
||||
# Custom providers require explicit api_base and model — they cannot
|
||||
# fall back to OpenAI defaults like built-in vendors do.
|
||||
if provider == "custom":
|
||||
if not resolved_base:
|
||||
raise ValueError("Custom embedding provider requires an api_base URL")
|
||||
if not resolved_model:
|
||||
raise ValueError("Custom embedding provider requires a model name")
|
||||
return OpenAIEmbeddingProvider(
|
||||
model=model or meta["default_model"],
|
||||
model=resolved_model,
|
||||
api_key=api_key,
|
||||
api_base=api_base or meta["default_base_url"],
|
||||
api_base=resolved_base,
|
||||
extra_headers=extra_headers,
|
||||
dimensions=final_dim,
|
||||
supports_dim_param=meta["supports_dim_param"],
|
||||
|
||||
@@ -163,10 +163,9 @@ def main() -> int:
|
||||
logger.info(f"[RebuildIndex] Workspace: {workspace_root}")
|
||||
logger.info(f"[RebuildIndex] Index db: {memory_config.get_db_path()}")
|
||||
|
||||
from bridge.agent_initializer import AgentInitializer
|
||||
from agent.memory.embedding import create_default_embedding_provider
|
||||
|
||||
initializer = AgentInitializer(bridge=None, agent_bridge=None)
|
||||
embedding_provider = initializer._init_embedding_provider(memory_config, session_id=None)
|
||||
embedding_provider = create_default_embedding_provider()
|
||||
if embedding_provider is None:
|
||||
logger.error(
|
||||
"[RebuildIndex] No embedding provider could be initialized. "
|
||||
|
||||
@@ -419,6 +419,17 @@ class MemoryFlushManager:
|
||||
lookback_days: How many days of daily files to read (default 1 for scheduled, 3 for manual)
|
||||
force: Skip input-hash dedup check (used by manual /memory dream trigger)
|
||||
"""
|
||||
# Config guard for scheduled runs. Manual trigger (force=True) always
|
||||
# runs since it is an explicit user action.
|
||||
if not force:
|
||||
try:
|
||||
from config import conf
|
||||
if not conf().get("deep_dream_enabled", True):
|
||||
logger.info("[DeepDream] deep_dream_enabled=false, skipping")
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not self.llm_model:
|
||||
logger.warning("[DeepDream] No LLM model available, skipping")
|
||||
return False
|
||||
|
||||
@@ -379,6 +379,12 @@ class AgentStreamExecutor:
|
||||
|
||||
self._emit_event("agent_start")
|
||||
|
||||
# Reset the run-scoped MCP tool-retrieval accumulator. On-demand tool
|
||||
# retrieval only grows this set within a run, so a tool that already
|
||||
# produced a tool_use never disappears from the schema mid-run (which
|
||||
# would make Claude/MiniMax raise a message-format error).
|
||||
self._retrieved_mcp_names = set()
|
||||
|
||||
final_response = ""
|
||||
turn = 0
|
||||
|
||||
@@ -702,6 +708,70 @@ class AgentStreamExecutor:
|
||||
|
||||
return final_response
|
||||
|
||||
def _select_tools_for_injection(self) -> list:
|
||||
"""Decide which tools to inject into the current LLM turn.
|
||||
|
||||
Built-in tools are ALWAYS injected in full (skills and core flows hard
|
||||
depend on them). MCP tools are also injected in full UNLESS on-demand
|
||||
retrieval is enabled AND the MCP tool count exceeds the configured
|
||||
threshold — then only the most relevant MCP tools are injected, unioned
|
||||
with those already selected earlier in this run (only-grows, so a tool
|
||||
that already produced a tool_use never vanishes from the schema).
|
||||
|
||||
Degrades safely: disabled feature, no embedding provider, embedding
|
||||
failure, count below threshold, or any error → inject all tools. Tools
|
||||
are never silently dropped.
|
||||
"""
|
||||
all_tools = list(self.tools.values())
|
||||
try:
|
||||
from config import conf
|
||||
if not conf().get("mcp_tool_retrieval_enabled", False):
|
||||
return all_tools
|
||||
|
||||
from agent.tools.mcp.mcp_tool import McpTool
|
||||
mcp_tools = [t for t in all_tools if isinstance(t, McpTool)]
|
||||
builtin_tools = [t for t in all_tools if not isinstance(t, McpTool)]
|
||||
|
||||
threshold = int(conf().get("mcp_tool_retrieval_threshold", 20) or 20)
|
||||
if len(mcp_tools) <= threshold:
|
||||
return all_tools
|
||||
|
||||
top_k = int(conf().get("mcp_tool_retrieval_top_k", 10) or 10)
|
||||
|
||||
from agent.tools import ToolManager
|
||||
from agent.tools.mcp.tool_retrieval import (
|
||||
build_retrieval_query,
|
||||
select_mcp_tools,
|
||||
)
|
||||
|
||||
tm = ToolManager()
|
||||
tool_vectors = tm.get_mcp_tool_vectors()
|
||||
query = build_retrieval_query(self.messages)
|
||||
query_vector = tm.embed_query(query)
|
||||
|
||||
selected = select_mcp_tools(
|
||||
query_vector,
|
||||
tool_vectors,
|
||||
top_k,
|
||||
getattr(self, "_retrieved_mcp_names", set()),
|
||||
)
|
||||
if selected is None:
|
||||
# No provider / empty index / error → full injection.
|
||||
return all_tools
|
||||
|
||||
# Persist the accumulated selection for subsequent turns.
|
||||
self._retrieved_mcp_names = selected
|
||||
|
||||
selected_mcp = [t for t in mcp_tools if t.name in selected]
|
||||
logger.info(
|
||||
f"[ToolRetrieval] Injecting {len(builtin_tools)} built-in + "
|
||||
f"{len(selected_mcp)}/{len(mcp_tools)} MCP tool(s) (top_k={top_k})"
|
||||
)
|
||||
return builtin_tools + selected_mcp
|
||||
except Exception as e:
|
||||
logger.debug(f"[ToolRetrieval] full injection (retrieval skipped): {e}")
|
||||
return all_tools
|
||||
|
||||
def _call_llm_stream(self, retry_on_empty=True, retry_count=0, max_retries=3,
|
||||
_overflow_retry: bool = False) -> Tuple[str, List[Dict]]:
|
||||
"""
|
||||
@@ -742,7 +812,7 @@ class AgentStreamExecutor:
|
||||
tools_schema = None
|
||||
if self.tools:
|
||||
tools_schema = []
|
||||
for tool in self.tools.values():
|
||||
for tool in self._select_tools_for_injection():
|
||||
input_schema = tool.params
|
||||
try:
|
||||
dynamic = (tool.get_json_schema() or {}).get("parameters") or {}
|
||||
|
||||
@@ -202,8 +202,12 @@ SAFETY:
|
||||
total_bytes = len(output.encode('utf-8'))
|
||||
|
||||
if total_bytes > DEFAULT_MAX_BYTES:
|
||||
# Save full output to temp file
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-') as f:
|
||||
# Save full output to temp file. encoding='utf-8' is required:
|
||||
# the default text-mode encoding is the platform locale (e.g.
|
||||
# cp936/GBK on Chinese Windows), which raises UnicodeEncodeError
|
||||
# for output containing emoji or other non-locale characters and
|
||||
# would discard an otherwise successful command result.
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-', encoding='utf-8') as f:
|
||||
f.write(output)
|
||||
temp_file_path = f.name
|
||||
|
||||
|
||||
@@ -15,15 +15,24 @@ Launch modes (configured under `tools.browser` in config.json):
|
||||
- fresh: Set `persistent` to false to fall back to a clean context every run.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
from typing import Dict, Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from agent.tools.base_tool import BaseTool, ToolResult
|
||||
from agent.tools.browser.browser_service import BrowserService
|
||||
from common.log import logger
|
||||
|
||||
|
||||
# Cloud-metadata endpoints worth blocking even though they are not link-local.
|
||||
# (169.254.169.254 — AWS/GCP/Azure IMDS — is already covered by is_link_local;
|
||||
# fd00:ec2::254 is the AWS IPv6 IMDS address.)
|
||||
_CLOUD_METADATA_IPS = frozenset({ipaddress.ip_address("fd00:ec2::254")})
|
||||
|
||||
|
||||
class BrowserTool(BaseTool):
|
||||
"""Single tool exposing all browser actions via an 'action' parameter."""
|
||||
|
||||
@@ -121,6 +130,61 @@ class BrowserTool(BaseTool):
|
||||
BrowserTool._shared_service = self._service
|
||||
return self._service
|
||||
|
||||
def _allow_private_targets(self) -> bool:
|
||||
"""Whether the link-local / cloud-metadata guard is disabled.
|
||||
|
||||
Defaults to False (guard active). Loopback and RFC1918/LAN targets are
|
||||
always reachable so local dev servers work out of the box; this opt-out
|
||||
only lifts the remaining block on link-local / cloud-metadata targets,
|
||||
for an operator who deliberately needs them, by setting
|
||||
``allow_private_targets: true`` under ``tools.browser`` in config.json.
|
||||
"""
|
||||
return bool(self.config.get("allow_private_targets", False))
|
||||
|
||||
@staticmethod
|
||||
def _validate_url_safe(url: str) -> None:
|
||||
"""Reject URLs that target link-local / cloud-metadata addresses (SSRF guard).
|
||||
|
||||
Resolves the hostname to its IP address(es) and blocks any that are
|
||||
link-local (169.254.0.0/16 — which includes the 169.254.169.254
|
||||
cloud-metadata endpoint — and IPv6 fe80::/10) or a known IPv6
|
||||
cloud-metadata address. Also rejects URLs with no host, non-HTTP(S)
|
||||
schemes, or hosts that fail DNS resolution.
|
||||
|
||||
Loopback and RFC1918/LAN targets are intentionally left reachable:
|
||||
unlike the vision/web_fetch tools, the browser legitimately opens local
|
||||
pages (a dev server on ``localhost`` / ``127.0.0.1`` / a LAN IP), so a
|
||||
blanket "block all internal" policy would break that core workflow.
|
||||
|
||||
Raises:
|
||||
ValueError: if the URL targets a disallowed address.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
raise ValueError("URL has no hostname")
|
||||
|
||||
try:
|
||||
# Resolve all addresses for the hostname.
|
||||
addr_infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
||||
except socket.gaierror:
|
||||
raise ValueError(f"Cannot resolve hostname: {hostname}")
|
||||
|
||||
for family, _, _, _, sockaddr in addr_infos:
|
||||
ip_str = sockaddr[0]
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
# Block only the high-risk targets — link-local (incl. the
|
||||
# 169.254.169.254 cloud-metadata endpoint) and the IPv6 metadata
|
||||
# address. Loopback and RFC1918/LAN stay reachable for local dev.
|
||||
if ip.is_link_local or ip in _CLOUD_METADATA_IPS:
|
||||
raise ValueError(
|
||||
f"URL resolves to a link-local / cloud-metadata address "
|
||||
f"({ip_str}), request blocked for security"
|
||||
)
|
||||
|
||||
def execute(self, args: Dict[str, Any]) -> ToolResult:
|
||||
action = args.get("action", "").strip().lower()
|
||||
if not action:
|
||||
@@ -148,6 +212,16 @@ class BrowserTool(BaseTool):
|
||||
# Only auto-prepend https:// for bare hosts; preserve file://, about:, data:, etc.
|
||||
if "://" not in url and not url.startswith(("about:", "data:")):
|
||||
url = "https://" + url
|
||||
# SSRF guard: for http(s) targets, reject hosts that resolve to
|
||||
# link-local / cloud-metadata addresses before the browser navigates
|
||||
# (and then auto-snapshots the page back to the model). Loopback and
|
||||
# RFC1918/LAN are allowed so local dev servers work. Non-HTTP schemes
|
||||
# (about:/data:/file:/chrome:) are not network-egress targets here.
|
||||
if url.split(":", 1)[0].lower() in ("http", "https") and not self._allow_private_targets():
|
||||
try:
|
||||
self._validate_url_safe(url)
|
||||
except ValueError as e:
|
||||
return ToolResult.fail(f"Error: {e}")
|
||||
timeout = args.get("timeout", 30000)
|
||||
service = self._get_service()
|
||||
result = service.navigate(url, timeout=timeout)
|
||||
|
||||
159
agent/tools/mcp/tool_retrieval.py
Normal file
159
agent/tools/mcp/tool_retrieval.py
Normal file
@@ -0,0 +1,159 @@
|
||||
# encoding:utf-8
|
||||
"""
|
||||
On-demand MCP tool retrieval.
|
||||
|
||||
Pure, stateless selection helpers used by the streaming executor to decide
|
||||
which MCP tools to inject into a given LLM turn. Vector precompute + caching
|
||||
live in ToolManager (the tool-lifecycle owner, a process-wide singleton);
|
||||
only the context-aware selection lives here, because only the executor knows
|
||||
the conversation context.
|
||||
|
||||
Invariants (per maintainer review of the feature proposal):
|
||||
* Built-in tools are never handled here — the caller injects them in full.
|
||||
* Any failure / missing input returns None so the caller falls back to
|
||||
full injection; tools must never be silently dropped.
|
||||
* Selection is union-accumulated across turns by the caller (only-grows),
|
||||
so a tool that already produced a tool_use in the message history can
|
||||
never disappear from the schema mid-run (which would make Claude/MiniMax
|
||||
raise a message-format error).
|
||||
"""
|
||||
import math
|
||||
from typing import Dict, List, Optional, Sequence, Set
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
_HAS_NUMPY = True
|
||||
except ImportError:
|
||||
_HAS_NUMPY = False
|
||||
|
||||
# How many trailing messages to concatenate into the retrieval query. Tool
|
||||
# needs drift across a multi-turn tool-call loop, so a single (initial) user
|
||||
# query is not enough; a short recent window captures the drift without
|
||||
# bloating the query with stale context.
|
||||
DEFAULT_QUERY_MESSAGES = 5
|
||||
|
||||
|
||||
def build_retrieval_query(messages: list, max_messages: int = DEFAULT_QUERY_MESSAGES) -> str:
|
||||
"""Concatenate the text of the most recent messages into a retrieval query.
|
||||
|
||||
Only ``text`` content blocks are kept; ``tool_use`` / ``tool_result`` blocks
|
||||
are skipped so the query stays short and focused on natural-language intent
|
||||
rather than large serialized tool payloads.
|
||||
|
||||
Args:
|
||||
messages: Claude-style message list, each ``{"role", "content"}`` where
|
||||
content is either a string or a list of typed blocks.
|
||||
max_messages: Size of the trailing window to consider.
|
||||
|
||||
Returns:
|
||||
A single string (possibly empty if no text is found).
|
||||
"""
|
||||
if not messages:
|
||||
return ""
|
||||
|
||||
parts: List[str] = []
|
||||
for message in messages[-max_messages:]:
|
||||
content = message.get("content") if isinstance(message, dict) else None
|
||||
if isinstance(content, str):
|
||||
if content.strip():
|
||||
parts.append(content.strip())
|
||||
continue
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
if block.get("type") == "text":
|
||||
text = block.get("text", "")
|
||||
if isinstance(text, str) and text.strip():
|
||||
parts.append(text.strip())
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def cosine_similarity(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
"""Cosine similarity of two equal-length vectors; 0.0 on degenerate input."""
|
||||
if not a or not b or len(a) != len(b):
|
||||
return 0.0
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = math.sqrt(sum(x * x for x in a))
|
||||
norm_b = math.sqrt(sum(y * y for y in b))
|
||||
if norm_a == 0 or norm_b == 0:
|
||||
return 0.0
|
||||
return dot / (norm_a * norm_b)
|
||||
|
||||
|
||||
def select_mcp_tools(
|
||||
query_vector: Optional[Sequence[float]],
|
||||
tool_vectors: Dict[str, Sequence[float]],
|
||||
top_k: int,
|
||||
already_selected: Optional[Set[str]] = None,
|
||||
) -> Optional[Set[str]]:
|
||||
"""Return the accumulated set of MCP tool names to inject this turn.
|
||||
|
||||
Computes cosine similarity between ``query_vector`` and each candidate
|
||||
tool vector, keeps the ``top_k`` best, and unions them with
|
||||
``already_selected`` so the injected set only ever grows within a run.
|
||||
|
||||
Args:
|
||||
query_vector: Embedding of the current retrieval query, or None.
|
||||
tool_vectors: ``{mcp_tool_name: vector}`` for candidate MCP tools.
|
||||
top_k: Max number of tools to add from this turn's ranking.
|
||||
already_selected: Names accumulated in previous turns of this run.
|
||||
|
||||
Returns:
|
||||
The union set of tool names to inject, or None to signal
|
||||
"fall back to full injection" (no query vector, empty/invalid index,
|
||||
or any unexpected error). This function never raises.
|
||||
"""
|
||||
accumulated: Set[str] = set(already_selected) if already_selected else set()
|
||||
|
||||
if not query_vector or not tool_vectors or top_k <= 0:
|
||||
return None
|
||||
|
||||
try:
|
||||
expected_dim = len(query_vector)
|
||||
# Only rank candidates whose vector dimensionality matches the query.
|
||||
# A dimension mismatch means the index was built with a different
|
||||
# embedding model; ranking across dims is meaningless.
|
||||
candidates = {
|
||||
name: vec
|
||||
for name, vec in tool_vectors.items()
|
||||
if vec and len(vec) == expected_dim
|
||||
}
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
ranked = _rank_by_similarity(query_vector, candidates)
|
||||
for name, _score in ranked[:top_k]:
|
||||
accumulated.add(name)
|
||||
return accumulated
|
||||
except Exception:
|
||||
# Selection must never break the agent — fall back to full injection.
|
||||
return None
|
||||
|
||||
|
||||
def _rank_by_similarity(
|
||||
query_vector: Sequence[float],
|
||||
candidates: Dict[str, Sequence[float]],
|
||||
) -> List[tuple]:
|
||||
"""Return ``[(name, score), ...]`` sorted by descending cosine similarity.
|
||||
|
||||
Uses numpy when available (vectorized, matching the memory-search path),
|
||||
with a pure-Python fallback so the feature works without numpy installed.
|
||||
"""
|
||||
names = list(candidates.keys())
|
||||
|
||||
if _HAS_NUMPY:
|
||||
matrix = np.array([candidates[n] for n in names], dtype=np.float32) # (N, D)
|
||||
q_vec = np.array(query_vector, dtype=np.float32) # (D,)
|
||||
dots = matrix @ q_vec # (N,)
|
||||
row_norms = np.linalg.norm(matrix, axis=1) # (N,)
|
||||
q_norm = float(np.linalg.norm(q_vec))
|
||||
denominators = row_norms * q_norm
|
||||
np.maximum(denominators, 1e-10, out=denominators) # avoid div-by-zero
|
||||
sims = dots / denominators
|
||||
order = np.argsort(sims)[::-1]
|
||||
return [(names[i], float(sims[i])) for i in order]
|
||||
|
||||
scored = [(n, cosine_similarity(query_vector, candidates[n])) for n in names]
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
return scored
|
||||
@@ -4,6 +4,8 @@ Memory get tool
|
||||
Allows agents to read specific sections from memory files
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from agent.tools.base_tool import BaseTool
|
||||
|
||||
|
||||
@@ -87,8 +89,13 @@ class MemoryGetTool(BaseTool):
|
||||
|
||||
file_path = (workspace_dir / path).resolve()
|
||||
workspace_resolved = workspace_dir.resolve()
|
||||
|
||||
if not str(file_path).startswith(str(workspace_resolved) + '/') and file_path != workspace_resolved:
|
||||
|
||||
# Use os.path.realpath + os.sep for cross-platform path validation.
|
||||
# str(Path).startswith(str + '/') fails on Windows where Path uses
|
||||
# backslashes — see MemoryService._resolve_path for the same pattern.
|
||||
real_file = os.path.realpath(str(file_path))
|
||||
real_workspace = os.path.realpath(str(workspace_resolved))
|
||||
if real_file != real_workspace and not real_file.startswith(real_workspace + os.sep):
|
||||
return ToolResult.fail(f"Error: Access denied: path outside workspace")
|
||||
|
||||
if not file_path.exists():
|
||||
|
||||
@@ -4,6 +4,7 @@ Supports text files, images (jpg, png, gif, webp), and PDF files
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,11 +13,17 @@ from agent.tools.utils.truncate import truncate_head, format_size, DEFAULT_MAX_L
|
||||
from common.utils import expand_path
|
||||
|
||||
|
||||
# Paths whose CONTENT mirrors the process environment (and thus any secrets
|
||||
# loaded from ~/.cow/.env). Reading them bypasses the env_config boundary.
|
||||
# Matches /proc/self/environ, /proc/thread-self/environ and /proc/<pid>/environ.
|
||||
_PROC_ENVIRON_RE = re.compile(r"^/proc/(\d+|self|thread-self)/environ$")
|
||||
|
||||
|
||||
class Read(BaseTool):
|
||||
"""Tool for reading file contents"""
|
||||
|
||||
name: str = "read"
|
||||
description: str = f"Read or inspect file contents. For text/PDF files, returns content (truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB). For images/videos/audio, returns metadata only (file info, size, type). Use offset/limit for large text files."
|
||||
description: str = f"Read or inspect file contents. For text/PDF/Word/Excel/PPT files, returns content (truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB). For images/videos/audio, returns metadata only (file info, size, type). Use offset/limit for large text files."
|
||||
|
||||
params: dict = {
|
||||
"type": "object",
|
||||
@@ -79,9 +86,9 @@ class Read(BaseTool):
|
||||
# Resolve path
|
||||
absolute_path = self._resolve_path(path)
|
||||
|
||||
# Security check: Prevent reading sensitive config files
|
||||
env_config_path = expand_path("~/.cow/.env")
|
||||
if os.path.abspath(absolute_path) == os.path.abspath(env_config_path):
|
||||
# Security check: block credential files and their aliases.
|
||||
# See issue #2913 (/proc/self/environ bypass) and #2863 (scope).
|
||||
if self._is_credential_path(absolute_path):
|
||||
return ToolResult.fail(
|
||||
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
||||
)
|
||||
@@ -140,7 +147,39 @@ class Read(BaseTool):
|
||||
if os.path.isabs(path):
|
||||
return path
|
||||
return os.path.abspath(os.path.join(self.cwd, path))
|
||||
|
||||
|
||||
def _is_credential_path(self, absolute_path: str) -> bool:
|
||||
"""Return True if *absolute_path* points at protected credential data.
|
||||
|
||||
Beyond the literal ~/.cow/.env file, this also blocks two real bypass
|
||||
surfaces reported in issue #2913:
|
||||
1. /proc/<pid|self|thread-self>/environ — a second view of the
|
||||
process environment that leaks secrets loaded from ~/.cow/.env.
|
||||
2. Symlinks resolving to ~/.cow/.env; the previous exact abspath
|
||||
match kept the link target and could be bypassed.
|
||||
|
||||
Scope is kept deliberately narrow (only the credential file and its
|
||||
environ aliases) so this does NOT re-broaden the block that #2863
|
||||
intentionally narrowed to ~/.cow/.env.
|
||||
"""
|
||||
# Compare on both the normalized path and the symlink-resolved path,
|
||||
# in POSIX form so the /proc regex matches regardless of os.sep.
|
||||
candidates = set()
|
||||
try:
|
||||
candidates.add(os.path.normpath(absolute_path).replace(os.sep, "/"))
|
||||
candidates.add(os.path.realpath(absolute_path).replace(os.sep, "/"))
|
||||
except OSError:
|
||||
candidates.add(absolute_path.replace(os.sep, "/"))
|
||||
|
||||
# 1. /proc environ aliases (checked on raw and symlink-resolved forms).
|
||||
for candidate in candidates:
|
||||
if _PROC_ENVIRON_RE.match(candidate):
|
||||
return True
|
||||
|
||||
# 2. The credential file itself, following symlinks on both sides.
|
||||
env_real = os.path.realpath(expand_path("~/.cow/.env")).replace(os.sep, "/")
|
||||
return env_real in candidates
|
||||
|
||||
def _return_file_metadata(self, absolute_path: str, file_type: str, file_size: int) -> ToolResult:
|
||||
"""
|
||||
Return file metadata for non-readable files (video, audio, binary, etc.)
|
||||
@@ -258,8 +297,15 @@ class Read(BaseTool):
|
||||
if offset is not None:
|
||||
if offset < 0:
|
||||
# Negative offset: read from end
|
||||
# -20 means "last 20 lines" → start from (total - 20)
|
||||
start_line = max(0, total_file_lines + offset)
|
||||
# -20 means "last 20 lines" → start from (total - 20).
|
||||
# A file ending in "\n" produces a trailing empty element
|
||||
# from split('\n'); exclude it so offset=-1 returns the
|
||||
# real last line instead of the empty string after the
|
||||
# final newline (and -N returns N real lines).
|
||||
effective_lines = total_file_lines
|
||||
if all_lines and all_lines[-1] == '':
|
||||
effective_lines -= 1
|
||||
start_line = max(0, effective_lines + offset)
|
||||
else:
|
||||
# Positive offset: read from start (1-indexed)
|
||||
start_line = max(0, offset - 1) # Convert to 0-indexed
|
||||
|
||||
@@ -54,6 +54,11 @@ class Send(BaseTool):
|
||||
if not path:
|
||||
return ToolResult.fail("Error: path parameter is required")
|
||||
|
||||
# Pass through remote URLs directly (no local file check): the client
|
||||
# renders the link inline, so no download is needed.
|
||||
if path.lower().startswith(("http://", "https://")):
|
||||
return self._build_url_result(path, message)
|
||||
|
||||
# Resolve path
|
||||
absolute_path = self._resolve_path(path)
|
||||
|
||||
@@ -112,6 +117,46 @@ class Send(BaseTool):
|
||||
|
||||
return ToolResult.success(result)
|
||||
|
||||
def _build_url_result(self, url: str, message: str) -> ToolResult:
|
||||
"""Build a file_to_send result for a remote http(s) URL.
|
||||
|
||||
The URL is passed through as both ``path`` and ``url`` so downstream
|
||||
channels render it inline without downloading it locally.
|
||||
"""
|
||||
# Infer file type from the URL path extension (ignore query string).
|
||||
from urllib.parse import urlparse
|
||||
url_path = urlparse(url).path
|
||||
file_ext = Path(url_path).suffix.lower()
|
||||
file_name = Path(url_path).name or "file"
|
||||
|
||||
if file_ext in self.image_extensions:
|
||||
file_type = "image"
|
||||
mime_type = self._get_image_mime_type(file_ext)
|
||||
elif file_ext in self.video_extensions:
|
||||
file_type = "video"
|
||||
mime_type = self._get_video_mime_type(file_ext)
|
||||
elif file_ext in self.audio_extensions:
|
||||
file_type = "audio"
|
||||
mime_type = self._get_audio_mime_type(file_ext)
|
||||
elif file_ext in self.document_extensions:
|
||||
file_type = "document"
|
||||
mime_type = self._get_document_mime_type(file_ext)
|
||||
else:
|
||||
# Default to image: most pass-through URLs are generated images.
|
||||
file_type = "image"
|
||||
mime_type = "image/jpeg"
|
||||
|
||||
result = {
|
||||
"type": "file_to_send",
|
||||
"file_type": file_type,
|
||||
"path": url,
|
||||
"url": url,
|
||||
"file_name": file_name,
|
||||
"mime_type": mime_type,
|
||||
"message": message or f"正在发送 {file_name}",
|
||||
}
|
||||
return ToolResult.success(result)
|
||||
|
||||
def _resolve_path(self, path: str) -> str:
|
||||
"""Resolve path to absolute path"""
|
||||
path = expand_path(path)
|
||||
|
||||
@@ -71,6 +71,22 @@ class ToolManager:
|
||||
if not hasattr(self, '_mcp_active_configs'):
|
||||
# server_name -> normalized config dict, for diff-based reload.
|
||||
self._mcp_active_configs: dict = {}
|
||||
if not hasattr(self, '_mcp_tool_vectors'):
|
||||
# mcp_tool_name -> embedding vector, used by on-demand tool
|
||||
# retrieval. Populated lazily on first retrieval so users who
|
||||
# never enable the feature pay zero embedding cost.
|
||||
self._mcp_tool_vectors: dict = {}
|
||||
if not hasattr(self, '_mcp_vector_lock'):
|
||||
# Guards incremental index builds so concurrent turns don't
|
||||
# double-embed the same newly-loaded MCP tools.
|
||||
self._mcp_vector_lock = threading.Lock()
|
||||
if not hasattr(self, '_embedding_provider_initialized'):
|
||||
# The embedding provider is created once, lazily, and reused for
|
||||
# both tool-index and per-query embeddings. None means keyword-only
|
||||
# mode (no provider configured) — retrieval then falls back to full
|
||||
# injection at the caller.
|
||||
self._embedding_provider_initialized = False
|
||||
self._embedding_provider = None
|
||||
|
||||
def load_tools(self, tools_dir: str = "", config_dict=None):
|
||||
"""
|
||||
@@ -523,6 +539,16 @@ class ToolManager:
|
||||
if agent is None or not hasattr(agent, "tools"):
|
||||
return ([], [])
|
||||
|
||||
# Never re-inject MCP tools into a restricted Self-Evolution review agent.
|
||||
# The review agent is created with a deliberately reduced, workspace-guarded
|
||||
# toolset; silently re-adding configured MCP tools here would bypass that
|
||||
# policy boundary (see agent/evolution/executor.py). The flag may live on
|
||||
# the agent itself (Agent) or on the wrapping stream executor's .agent.
|
||||
if getattr(agent, "_evolution_restricted", False) or getattr(
|
||||
getattr(agent, "agent", None), "_evolution_restricted", False
|
||||
):
|
||||
return ([], [])
|
||||
|
||||
from agent.tools.mcp.mcp_tool import McpTool
|
||||
current = self._mcp_tool_instances
|
||||
registry_names = set(current.keys())
|
||||
@@ -564,6 +590,91 @@ class ToolManager:
|
||||
|
||||
return (sorted(added), sorted(removed))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# On-demand MCP tool retrieval support
|
||||
#
|
||||
# The vector index and the embedding provider are owned here (singleton,
|
||||
# process-wide, aligned with the MCP tool lifecycle). The context-aware
|
||||
# selection itself lives in agent.tools.mcp.tool_retrieval, driven by the
|
||||
# executor which is the only place that knows the conversation context.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def count_mcp_tools(self) -> int:
|
||||
"""Return the number of currently loaded MCP tools."""
|
||||
return len(self._mcp_tool_instances)
|
||||
|
||||
def get_mcp_tool_vectors(self) -> dict:
|
||||
"""Return ``{mcp_tool_name: vector}`` for currently loaded MCP tools.
|
||||
|
||||
Lazily embeds any MCP tools not yet in the cache (MCP servers load
|
||||
asynchronously, so tools may appear over time). Returns an empty dict
|
||||
when no embedding provider is available or embedding fails — the caller
|
||||
then falls back to full injection. Never raises.
|
||||
"""
|
||||
try:
|
||||
self._ensure_mcp_tool_vectors()
|
||||
except Exception as e:
|
||||
logger.debug(f"[ToolManager] MCP tool vector build skipped: {e}")
|
||||
return dict(self._mcp_tool_vectors)
|
||||
|
||||
def embed_query(self, text: str):
|
||||
"""Embed a retrieval query with the shared provider.
|
||||
|
||||
Returns the embedding vector, or None if no provider is available or
|
||||
the call fails (caller falls back to full injection). Never raises.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
provider = self._get_embedding_provider()
|
||||
if provider is None:
|
||||
return None
|
||||
try:
|
||||
return provider.embed_query(text)
|
||||
except Exception as e:
|
||||
logger.debug(f"[ToolManager] query embedding failed: {e}")
|
||||
return None
|
||||
|
||||
def _ensure_mcp_tool_vectors(self) -> None:
|
||||
"""Incrementally embed MCP tools that are not yet cached."""
|
||||
# Snapshot to avoid concurrent-mutation while the async loader runs.
|
||||
current = dict(self._mcp_tool_instances)
|
||||
missing = [name for name in current if name not in self._mcp_tool_vectors]
|
||||
if not missing:
|
||||
return
|
||||
|
||||
provider = self._get_embedding_provider()
|
||||
if provider is None:
|
||||
return
|
||||
|
||||
with self._mcp_vector_lock:
|
||||
# Re-check under lock: another thread may have filled these in.
|
||||
missing = [name for name in current if name not in self._mcp_tool_vectors]
|
||||
if not missing:
|
||||
return
|
||||
texts = [self._mcp_tool_embed_text(current[name]) for name in missing]
|
||||
vectors = provider.embed_batch(texts)
|
||||
for name, vec in zip(missing, vectors):
|
||||
self._mcp_tool_vectors[name] = vec
|
||||
|
||||
@staticmethod
|
||||
def _mcp_tool_embed_text(tool) -> str:
|
||||
"""Build the text that represents an MCP tool for embedding."""
|
||||
name = getattr(tool, "name", "") or ""
|
||||
description = getattr(tool, "description", "") or ""
|
||||
return f"{name}: {description}".strip()
|
||||
|
||||
def _get_embedding_provider(self):
|
||||
"""Lazily create and cache the shared embedding provider (or None)."""
|
||||
if not self._embedding_provider_initialized:
|
||||
try:
|
||||
from agent.memory.embedding import create_default_embedding_provider
|
||||
self._embedding_provider = create_default_embedding_provider()
|
||||
except Exception as e:
|
||||
logger.warning(f"[ToolManager] embedding provider init failed: {e}")
|
||||
self._embedding_provider = None
|
||||
self._embedding_provider_initialized = True
|
||||
return self._embedding_provider
|
||||
|
||||
def create_tool(self, name: str) -> BaseTool:
|
||||
"""
|
||||
Get a new instance of a tool by name.
|
||||
|
||||
@@ -111,20 +111,46 @@ def fuzzy_find_text(content: str, old_text: str) -> FuzzyMatchResult:
|
||||
content_for_replacement=content
|
||||
)
|
||||
|
||||
# Try fuzzy match
|
||||
fuzzy_content = normalize_for_fuzzy_match(content)
|
||||
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
|
||||
|
||||
index = fuzzy_content.find(fuzzy_old_text)
|
||||
if index != -1:
|
||||
# Fuzzy match successful, use normalized content for replacement
|
||||
return FuzzyMatchResult(
|
||||
found=True,
|
||||
index=index,
|
||||
match_length=len(fuzzy_old_text),
|
||||
content_for_replacement=fuzzy_content
|
||||
)
|
||||
|
||||
# Fuzzy match: the exact substring was not found, most likely because the
|
||||
# whitespace differs (indentation, spaces around operators, trailing
|
||||
# spaces). Locate the region in the ORIGINAL content using a
|
||||
# whitespace-flexible pattern and return offsets into that original
|
||||
# content.
|
||||
#
|
||||
# This must NOT replace inside a whitespace-normalized copy of the file:
|
||||
# doing so previously returned the normalized copy as
|
||||
# content_for_replacement, which caused the whole file to be rewritten
|
||||
# with collapsed indentation (every untouched line got reformatted).
|
||||
stripped = old_text.strip('\n')
|
||||
if stripped.strip():
|
||||
source_lines = stripped.split('\n')
|
||||
line_patterns = []
|
||||
for i, line in enumerate(source_lines):
|
||||
tokens = line.split()
|
||||
if not tokens:
|
||||
line_patterns.append(r'[ \t]*')
|
||||
continue
|
||||
# Tolerate any run of blanks between tokens.
|
||||
core = r'[ \t]+'.join(re.escape(tok) for tok in tokens)
|
||||
# First-line leading whitespace is folded into the match only when
|
||||
# old_text itself was indented here; otherwise it stays OUTSIDE the
|
||||
# match so a no-indent old_text preserves (does not swallow and drop)
|
||||
# the file's existing indentation -- mirroring an exact substring
|
||||
# match. Inner lines always tolerate indentation: it sits inside the
|
||||
# matched region and is re-supplied by new_text.
|
||||
if i > 0 or line[:1] in (' ', '\t'):
|
||||
core = r'[ \t]*' + core
|
||||
line_patterns.append(core + r'[ \t]*')
|
||||
pattern = '\n'.join(line_patterns)
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
return FuzzyMatchResult(
|
||||
found=True,
|
||||
index=match.start(),
|
||||
match_length=match.end() - match.start(),
|
||||
content_for_replacement=content
|
||||
)
|
||||
|
||||
# Not found
|
||||
return FuzzyMatchResult(found=False)
|
||||
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
"""
|
||||
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.
|
||||
SSRF protection is OPT-IN and disabled by default, because legitimate use
|
||||
cases (local dev servers, LAN services, proxy fake-ip resolution) need to
|
||||
reach non-public addresses. Enable it by setting the config option
|
||||
``web_security_ssrf_protection: true`` (or env ``WEB_SECURITY_SSRF_PROTECTION``).
|
||||
|
||||
When enabled, 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 os
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _ssrf_protection_enabled() -> bool:
|
||||
"""Return True only when SSRF protection is explicitly turned on.
|
||||
|
||||
Disabled by default. Reads the env var first, then falls back to the
|
||||
global config; any failure to read config is treated as "disabled" so
|
||||
the guard never breaks normal fetching.
|
||||
"""
|
||||
env = os.getenv("WEB_SECURITY_SSRF_PROTECTION")
|
||||
if env is not None:
|
||||
return env.strip().lower() in ("1", "true", "yes", "on")
|
||||
try:
|
||||
from config import conf
|
||||
return bool(conf().get("web_security_ssrf_protection", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
|
||||
"""Return True if the address is not safe to connect to (non-public)."""
|
||||
return (
|
||||
@@ -28,8 +51,11 @@ def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
|
||||
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.
|
||||
No-op when SSRF protection is disabled (the default). Used to re-validate
|
||||
the concrete address a redirect resolved to.
|
||||
"""
|
||||
if not _ssrf_protection_enabled():
|
||||
return
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
if _is_blocked_ip(ip):
|
||||
raise ValueError(
|
||||
@@ -41,13 +67,17 @@ def assert_public_ip(ip_str: str) -> None:
|
||||
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
|
||||
No-op when SSRF protection is disabled (the default). When enabled,
|
||||
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.
|
||||
"""
|
||||
if not _ssrf_protection_enabled():
|
||||
return
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
||||
|
||||
@@ -53,7 +53,7 @@ _DISCOVERABLE_MODELS = [
|
||||
("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"),
|
||||
("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"),
|
||||
("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN37_PLUS, "DashScope"),
|
||||
("claude_api_key", const.CLAUDEAPI, const.CLAUDE_4_6_SONNET, "Claude"),
|
||||
("claude_api_key", const.CLAUDEAPI, const.CLAUDE_SONNET_5, "Claude"),
|
||||
("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"),
|
||||
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
|
||||
("zhipu_ai_api_key", const.ZHIPU_AI, const.GLM_4_7, "ZhipuAI"),
|
||||
@@ -162,7 +162,7 @@ class Vision(BaseTool):
|
||||
"Error: No model available for Vision.\n"
|
||||
"The main model does not support vision and no other API keys are configured.\n"
|
||||
"Options:\n"
|
||||
" 1. Switch to a multimodal model (e.g. ernie-4.5-turbo-vl, qwen3.7-plus, claude-sonnet-4-6, gemini-2.0-flash)\n"
|
||||
" 1. Switch to a multimodal model (e.g. claude-sonnet-5, qwen3.7-plus, gemini-2.0-flash, ernie-4.5-turbo-vl)\n"
|
||||
" 2. Configure OPENAI_API_KEY: env_config(action=\"set\", key=\"OPENAI_API_KEY\", value=\"your-key\")\n"
|
||||
" 3. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")"
|
||||
)
|
||||
@@ -331,6 +331,12 @@ class Vision(BaseTool):
|
||||
- None : unknown provider id, or the bot can't be created.
|
||||
Caller falls through to model-name-based routing.
|
||||
"""
|
||||
# Custom OpenAI-compatible providers — read credentials from
|
||||
# custom_providers list, same pattern as embedding.
|
||||
if provider_id.startswith("custom:"):
|
||||
p = self._build_custom_provider(provider_id, user_model)
|
||||
return [p] if p else None
|
||||
|
||||
display_name = _PROVIDER_ID_TO_DISPLAY.get(provider_id)
|
||||
if not display_name:
|
||||
return None
|
||||
@@ -596,6 +602,34 @@ class Vision(BaseTool):
|
||||
model_override=preferred_model,
|
||||
)
|
||||
|
||||
def _build_custom_provider(self, provider_id: str, preferred_model: Optional[str] = None) -> Optional[VisionProvider]:
|
||||
"""Build a VisionProvider from a custom:<id> entry in custom_providers.
|
||||
Uses the standard OpenAI /chat/completions endpoint — any
|
||||
OpenAI-compatible multimodal endpoint works."""
|
||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
||||
_, custom_id = parse_custom_bot_type(provider_id)
|
||||
if not custom_id:
|
||||
return None
|
||||
entry = _find_provider_by_id(get_custom_providers(), custom_id)
|
||||
if not entry:
|
||||
logger.warning(f"[Vision] custom provider '{provider_id}' not found in custom_providers")
|
||||
return None
|
||||
api_key = (entry.get("api_key") or "").strip()
|
||||
api_base = (entry.get("api_base") or "").strip()
|
||||
if not api_key or not api_base:
|
||||
logger.warning(f"[Vision] custom provider '{provider_id}' missing api_key or api_base")
|
||||
return None
|
||||
model = preferred_model or entry.get("model") or ""
|
||||
if not model:
|
||||
logger.warning(f"[Vision] custom provider '{provider_id}' has no model configured")
|
||||
return None
|
||||
return VisionProvider(
|
||||
name=entry.get("name") or provider_id,
|
||||
api_key=api_key,
|
||||
api_base=self._ensure_v1(api_base.rstrip("/")),
|
||||
model_override=model,
|
||||
)
|
||||
|
||||
def _call_via_bot(self, model: str, question: str, image_content: dict,
|
||||
provider: Optional[VisionProvider] = None) -> ToolResult:
|
||||
"""
|
||||
|
||||
29
app.py
29
app.py
@@ -15,6 +15,11 @@ import threading
|
||||
|
||||
_channel_mgr = None
|
||||
|
||||
# Desktop mode: a lighter runtime for the packaged Electron client. Plugins are
|
||||
# loaded in a background thread (so command plugins like cow_cli/godcmd work
|
||||
# without slowing startup), while MCP warmup is still skipped to keep it fast.
|
||||
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
|
||||
|
||||
|
||||
def get_channel_manager():
|
||||
return _channel_mgr
|
||||
@@ -76,7 +81,15 @@ class ChannelManager:
|
||||
self._primary_channel = channels[0][1]
|
||||
|
||||
if first_start:
|
||||
PluginManager().load_plugins()
|
||||
if DESKTOP_MODE:
|
||||
# Load plugins in the background so command plugins
|
||||
# (cow_cli / godcmd, e.g. /status, #help) work in the
|
||||
# desktop client, without blocking web-service readiness.
|
||||
threading.Thread(
|
||||
target=PluginManager().load_plugins, daemon=True
|
||||
).start()
|
||||
else:
|
||||
PluginManager().load_plugins()
|
||||
|
||||
# Cloud client is optional. It is only started when
|
||||
# use_linkai=True AND cloud_deployment_id is set.
|
||||
@@ -364,10 +377,18 @@ def run():
|
||||
_sync_builtin_skills()
|
||||
|
||||
# Kick off MCP server loading in the background so first-message
|
||||
# latency isn't dominated by npx package downloads.
|
||||
_warmup_mcp_tools()
|
||||
# latency isn't dominated by npx package downloads. Skipped in desktop
|
||||
# mode (MCP relies on external npx/uvx runtimes that aren't bundled).
|
||||
if not DESKTOP_MODE:
|
||||
_warmup_mcp_tools()
|
||||
|
||||
_warmup_scheduler()
|
||||
if DESKTOP_MODE:
|
||||
# Defer the (heavy) AgentBridge/scheduler warmup to a background
|
||||
# thread so the web API becomes available within a couple seconds.
|
||||
# The scheduler still starts; it just doesn't block UI readiness.
|
||||
threading.Thread(target=_warmup_scheduler, daemon=True).start()
|
||||
else:
|
||||
_warmup_scheduler()
|
||||
|
||||
logger.info(f"[App] Starting channels: {channel_names}")
|
||||
|
||||
|
||||
@@ -684,11 +684,21 @@ class AgentBridge:
|
||||
"""
|
||||
file_type = file_info.get("file_type", "file")
|
||||
file_path = file_info.get("path")
|
||||
|
||||
# Remote URLs are passed through as-is; local paths get a file:// prefix
|
||||
# so the channel can read them from disk.
|
||||
remote_url = file_info.get("url", "")
|
||||
is_remote = bool(remote_url) and remote_url.lower().startswith(("http://", "https://"))
|
||||
|
||||
def _to_channel_url(p: str) -> str:
|
||||
if is_remote:
|
||||
return remote_url
|
||||
if p and p.lower().startswith(("http://", "https://")):
|
||||
return p
|
||||
return f"file://{p}"
|
||||
|
||||
# For images, use IMAGE_URL type (channel will handle upload)
|
||||
if file_type == "image":
|
||||
# Convert local path to file:// URL for channel processing
|
||||
file_url = f"file://{file_path}"
|
||||
file_url = _to_channel_url(file_path)
|
||||
logger.info(f"[AgentBridge] Sending image: {file_url}")
|
||||
reply = Reply(ReplyType.IMAGE_URL, file_url)
|
||||
# Attach text message if present (for channels that support text+image)
|
||||
@@ -698,7 +708,7 @@ class AgentBridge:
|
||||
|
||||
# For all file types (document, video, audio), use FILE type
|
||||
if file_type in ["document", "video", "audio"]:
|
||||
file_url = f"file://{file_path}"
|
||||
file_url = _to_channel_url(file_path)
|
||||
logger.info(f"[AgentBridge] Sending {file_type}: {file_url}")
|
||||
reply = Reply(ReplyType.FILE, file_url)
|
||||
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
|
||||
@@ -708,7 +718,7 @@ class AgentBridge:
|
||||
return reply
|
||||
|
||||
# For all other file types (tar.gz, zip, etc.), also use FILE type
|
||||
file_url = f"file://{file_path}"
|
||||
file_url = _to_channel_url(file_path)
|
||||
logger.info(f"[AgentBridge] Sending generic file: {file_url}")
|
||||
reply = Reply(ReplyType.FILE, file_url)
|
||||
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
|
||||
|
||||
@@ -17,10 +17,6 @@ from common.utils import expand_path
|
||||
# Module-level lock to serialize scheduler init across concurrent sessions
|
||||
_scheduler_init_lock = threading.Lock()
|
||||
|
||||
# Track whether the embedding model log has been printed in this process,
|
||||
# so we avoid spamming it once per session.
|
||||
_embedding_logged: bool = False
|
||||
|
||||
|
||||
class AgentInitializer:
|
||||
"""
|
||||
@@ -306,186 +302,16 @@ class AgentInitializer:
|
||||
"""
|
||||
Initialize the embedding provider for memory.
|
||||
|
||||
Two paths:
|
||||
Delegates to the shared factory so agent init, knowledge sync and
|
||||
index rebuild all select the same provider:
|
||||
A. Default (no `embedding_provider` in config.json):
|
||||
Auto-init OpenAI -> LinkAI fallback. Existing 1536-dim indices
|
||||
keep working.
|
||||
Auto-init OpenAI -> LinkAI fallback.
|
||||
B. Explicit (`embedding_provider` is set):
|
||||
Initialize the requested vendor with unified dim (default 1024).
|
||||
If the index was built with a different dim, vector search will
|
||||
quietly return no results (cosine returns 0) and keyword search
|
||||
takes over until the user runs /memory rebuild-index.
|
||||
Initialize the requested vendor.
|
||||
"""
|
||||
from agent.memory import create_embedding_provider
|
||||
from config import conf
|
||||
from agent.memory import create_default_embedding_provider
|
||||
return create_default_embedding_provider()
|
||||
|
||||
explicit_provider = (conf().get("embedding_provider") or "").strip().lower()
|
||||
|
||||
if not explicit_provider:
|
||||
return self._init_embedding_provider_legacy(session_id=session_id)
|
||||
|
||||
return self._init_embedding_provider_explicit(
|
||||
memory_config, explicit_provider, session_id=session_id,
|
||||
)
|
||||
|
||||
def _init_embedding_provider_legacy(self, session_id: Optional[str] = None):
|
||||
"""Legacy auto-init path: OpenAI -> LinkAI. Preserved verbatim for compat."""
|
||||
from agent.memory import create_embedding_provider
|
||||
from config import conf
|
||||
|
||||
embedding_provider = None
|
||||
embedding_model = None
|
||||
|
||||
openai_api_key = conf().get("open_ai_api_key", "")
|
||||
openai_api_base = conf().get("open_ai_api_base", "")
|
||||
if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||
try:
|
||||
model = "text-embedding-3-small"
|
||||
embedding_provider = create_embedding_provider(
|
||||
provider="openai",
|
||||
model=model,
|
||||
api_key=openai_api_key,
|
||||
api_base=openai_api_base or "https://api.openai.com/v1"
|
||||
)
|
||||
embedding_model = f"openai/{model}"
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentInitializer] OpenAI embedding failed: {e}")
|
||||
|
||||
if embedding_provider is None:
|
||||
linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "")
|
||||
linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech")
|
||||
if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||
try:
|
||||
model = "text-embedding-3-small"
|
||||
embedding_provider = create_embedding_provider(
|
||||
provider="linkai",
|
||||
model=model,
|
||||
api_key=linkai_api_key,
|
||||
api_base=f"{linkai_api_base}/v1"
|
||||
)
|
||||
embedding_model = f"linkai/{model}"
|
||||
except Exception as e:
|
||||
logger.warning(f"[AgentInitializer] LinkAI embedding failed: {e}")
|
||||
|
||||
if embedding_provider is not None and embedding_model:
|
||||
global _embedding_logged
|
||||
if not _embedding_logged:
|
||||
logger.info(
|
||||
f"[AgentInitializer] Embedding model in use: {embedding_model} "
|
||||
f"(dim={embedding_provider.dimensions})"
|
||||
)
|
||||
_embedding_logged = True
|
||||
|
||||
return embedding_provider
|
||||
|
||||
def _init_embedding_provider_explicit(
|
||||
self,
|
||||
memory_config,
|
||||
provider_key: str,
|
||||
session_id: Optional[str] = None,
|
||||
):
|
||||
"""Explicit-provider path: build the configured vendor.
|
||||
|
||||
If the index was built with a different dim, vector search will
|
||||
silently return no results (cosine returns 0 for mismatched dims)
|
||||
and keyword search takes over. Users switch vendors by running
|
||||
/memory rebuild-index — see docs.
|
||||
"""
|
||||
from agent.memory import create_embedding_provider
|
||||
from agent.memory.embedding import EMBEDDING_VENDORS
|
||||
from config import conf
|
||||
|
||||
meta = EMBEDDING_VENDORS.get(provider_key)
|
||||
if meta is None:
|
||||
logger.error(
|
||||
f"[AgentInitializer] Unknown embedding_provider '{provider_key}'. "
|
||||
f"Supported: {sorted(EMBEDDING_VENDORS.keys())}. "
|
||||
f"Memory will run in keyword-only mode."
|
||||
)
|
||||
return None
|
||||
|
||||
api_key = self._resolve_embedding_api_key(provider_key)
|
||||
api_base = self._resolve_embedding_api_base(provider_key, meta["default_base_url"])
|
||||
|
||||
if not api_key:
|
||||
logger.error(
|
||||
f"[AgentInitializer] embedding_provider='{provider_key}' is set but its "
|
||||
f"API key is missing. Memory will run in keyword-only mode."
|
||||
)
|
||||
return None
|
||||
|
||||
model = (conf().get("embedding_model") or "").strip() or meta["default_model"]
|
||||
try:
|
||||
cfg_dim = int(conf().get("embedding_dimensions") or 0)
|
||||
except (TypeError, ValueError):
|
||||
cfg_dim = 0
|
||||
dim = cfg_dim if cfg_dim > 0 else meta["default_dimensions"]
|
||||
|
||||
try:
|
||||
provider = create_embedding_provider(
|
||||
provider=provider_key,
|
||||
model=model,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
dimensions=dim,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[AgentInitializer] Failed to init embedding provider "
|
||||
f"'{provider_key}/{model}': {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
global _embedding_logged
|
||||
if not _embedding_logged:
|
||||
logger.info(
|
||||
f"[AgentInitializer] Embedding model in use: "
|
||||
f"{provider_key}/{model} (dim={provider.dimensions})"
|
||||
)
|
||||
_embedding_logged = True
|
||||
return provider
|
||||
|
||||
@staticmethod
|
||||
def _resolve_embedding_api_key(provider_key: str) -> str:
|
||||
"""Pick the API key for an explicit embedding provider from config."""
|
||||
from config import conf
|
||||
|
||||
key_map = {
|
||||
"openai": "open_ai_api_key",
|
||||
"linkai": "linkai_api_key",
|
||||
"dashscope": "dashscope_api_key",
|
||||
"doubao": "ark_api_key",
|
||||
"zhipu": "zhipu_ai_api_key",
|
||||
}
|
||||
field = key_map.get(provider_key)
|
||||
if not field:
|
||||
return ""
|
||||
value = conf().get(field, "") or ""
|
||||
if value in ["", "YOUR API KEY", "YOUR_API_KEY"]:
|
||||
return ""
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _resolve_embedding_api_base(provider_key: str, default_base: str) -> str:
|
||||
"""Pick the API base for an explicit embedding provider from config."""
|
||||
from config import conf
|
||||
|
||||
base_map = {
|
||||
"openai": "open_ai_api_base",
|
||||
"linkai": "linkai_api_base",
|
||||
"doubao": "ark_base_url",
|
||||
"zhipu": "zhipu_ai_api_base",
|
||||
}
|
||||
field = base_map.get(provider_key)
|
||||
if not field:
|
||||
return default_base
|
||||
value = (conf().get(field) or "").strip()
|
||||
if not value:
|
||||
return default_base
|
||||
if provider_key == "linkai" and not value.rstrip("/").endswith("/v1"):
|
||||
return f"{value.rstrip('/')}/v1"
|
||||
return value
|
||||
|
||||
def _sync_memory(self, memory_manager, session_id: Optional[str] = None):
|
||||
"""Sync memory database"""
|
||||
try:
|
||||
|
||||
@@ -47,12 +47,15 @@
|
||||
This runs synchronously in <head> so the correct class is on <html>
|
||||
before any CSS or body rendering occurs. -->
|
||||
<script>
|
||||
// Map an arbitrary locale string (zh-CN, en-US, fr ...) to 'zh' / 'en',
|
||||
// Map an arbitrary locale string (zh-CN, en-US, fr ...) to 'zh' / 'zh-Hant' / 'en',
|
||||
// or '' when unrecognized so callers can fall through to the next source.
|
||||
window.__cowNormalizeLang__ = function(raw) {
|
||||
if (!raw) return '';
|
||||
var v = String(raw).trim().toLowerCase();
|
||||
var v = String(raw).trim().toLowerCase().replace('_', '-');
|
||||
if (v === 'auto') return '';
|
||||
// Handle Traditional Chinese variants first (more specific)
|
||||
if (v === 'zh-hant' || v.indexOf('zh-hant-') === 0 || v === 'zh-tw' || v === 'zh-hk') return 'zh-Hant';
|
||||
// Then Simplified Chinese
|
||||
if (v.indexOf('zh') === 0) return 'zh';
|
||||
if (v.indexOf('en') === 0) return 'en';
|
||||
return '';
|
||||
@@ -267,14 +270,29 @@
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<button id="lang-toggle" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
|
||||
cursor-pointer transition-colors duration-150"
|
||||
onclick="toggleLanguage()">
|
||||
<i class="fas fa-globe text-xs"></i>
|
||||
<span id="lang-label">EN</span>
|
||||
</button>
|
||||
<!-- Language Selector (dropdown) -->
|
||||
<div id="lang-selector" class="relative">
|
||||
<button id="lang-toggle" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||
text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
|
||||
cursor-pointer transition-colors duration-150"
|
||||
onclick="toggleLangMenu(event)">
|
||||
<i class="fas fa-globe text-xs"></i>
|
||||
<span id="lang-label">EN</span>
|
||||
<i class="fas fa-chevron-down text-[10px] opacity-60"></i>
|
||||
</button>
|
||||
<div id="lang-menu" class="hidden absolute right-0 mt-1 min-w-[120px] py-1 rounded-lg z-50
|
||||
bg-white dark:bg-slate-800 shadow-lg ring-1 ring-black/5 dark:ring-white/10">
|
||||
<button class="lang-menu-item w-full text-left px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300
|
||||
hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer" data-lang="zh"
|
||||
onclick="selectLanguage('zh')">简体中文</button>
|
||||
<button class="lang-menu-item w-full text-left px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300
|
||||
hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer" data-lang="zh-Hant"
|
||||
onclick="selectLanguage('zh-Hant')">繁體中文</button>
|
||||
<button class="lang-menu-item w-full text-left px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300
|
||||
hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer" data-lang="en"
|
||||
onclick="selectLanguage('en')">English</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button id="theme-toggle" class="p-2 rounded-lg text-slate-500 dark:text-slate-400
|
||||
@@ -304,6 +322,14 @@
|
||||
cursor-pointer transition-colors duration-150" title="GitHub">
|
||||
<i class="fab fa-github text-lg"></i>
|
||||
</a>
|
||||
|
||||
<!-- Logout Button (hidden by default) -->
|
||||
<button id="logout-btn-header" class="p-2 rounded-lg text-slate-500 dark:text-slate-400
|
||||
hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-500/10 dark:hover:text-red-400
|
||||
cursor-pointer transition-colors duration-150 hidden"
|
||||
onclick="handleLogout()" title="Logout" data-i18n-title="logout">
|
||||
<i class="fas fa-sign-out-alt text-base"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Content Area -->
|
||||
@@ -829,11 +855,11 @@
|
||||
<!-- VIEW: Knowledge -->
|
||||
<!-- ====================================================== -->
|
||||
<div id="view-knowledge" class="view">
|
||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10">
|
||||
<div class="w-full max-w-[1600px] mx-auto">
|
||||
<div class="flex-1 min-h-0 overflow-y-auto md:overflow-hidden p-4 md:p-8 lg:p-10 md:flex md:flex-col">
|
||||
<div class="w-full max-w-[1600px] mx-auto md:flex-1 md:min-h-0 md:flex md:flex-col">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 md:mb-6 md:flex-shrink-0">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="knowledge_title">知识库</h2>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1" data-i18n="knowledge_desc">浏览和探索你的知识库</p>
|
||||
@@ -841,10 +867,6 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
|
||||
<span id="knowledge-action-status" class="text-xs opacity-0 transition-opacity duration-200"></span>
|
||||
<button onclick="createKnowledgeCategory()"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium cursor-pointer transition-colors">
|
||||
<i class="fas fa-folder-plus"></i><span data-i18n="knowledge_new_category">新建分类</span>
|
||||
</button>
|
||||
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
|
||||
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
|
||||
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
|
||||
@@ -855,6 +877,28 @@
|
||||
<i class="fas fa-diagram-project mr-1.5"></i><span data-i18n="knowledge_tab_graph">图谱</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="knowledge-new-menu" class="relative">
|
||||
<button onclick="toggleKnowledgeNewMenu(event)"
|
||||
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-plus"></i><span data-i18n="knowledge_new">新建</span><i class="fas fa-chevron-down text-[9px] ml-0.5"></i>
|
||||
</button>
|
||||
<div id="knowledge-new-menu-list"
|
||||
class="hidden absolute right-0 mt-1.5 w-44 z-50 bg-white dark:bg-[#1A1A1A] border border-slate-200 dark:border-white/10 rounded-lg shadow-lg py-1">
|
||||
<button onclick="createKnowledgeCategory(); closeKnowledgeNewMenu()"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
|
||||
<i class="fas fa-folder-plus w-3.5 text-slate-400"></i><span data-i18n="knowledge_new_category">新建分类</span>
|
||||
</button>
|
||||
<button onclick="createKnowledgeDocument(); closeKnowledgeNewMenu()"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
|
||||
<i class="fas fa-file-circle-plus w-3.5 text-slate-400"></i><span data-i18n="knowledge_new_document">新建文档</span>
|
||||
</button>
|
||||
<button onclick="selectKnowledgeImportFiles(); closeKnowledgeNewMenu()"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 text-xs text-slate-600 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-white/5 cursor-pointer transition-colors">
|
||||
<i class="fas fa-file-arrow-up w-3.5 text-slate-400"></i><span data-i18n="knowledge_import_documents">导入文档</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input id="knowledge-import-input" type="file" class="hidden" multiple accept=".md,.txt,text/markdown,text/plain">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -877,12 +921,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Documents panel -->
|
||||
<div id="knowledge-panel-docs" class="hidden">
|
||||
<div class="flex flex-col md:flex-row gap-4 md:gap-6" style="min-height: calc(100vh - 220px)">
|
||||
<div id="knowledge-panel-docs" class="hidden md:flex-1 md:min-h-0">
|
||||
<div class="flex flex-col md:flex-row gap-4 md:gap-6 md:h-full">
|
||||
<!-- File tree -->
|
||||
<div id="knowledge-sidebar" class="w-full md:w-72 lg:w-80 flex-shrink-0">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-slate-200 dark:border-white/10">
|
||||
<div id="knowledge-sidebar" class="w-full md:w-72 lg:w-80 flex-shrink-0 md:h-full">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden flex flex-col md:h-full">
|
||||
<div class="px-4 py-3 border-b border-slate-200 dark:border-white/10 flex-shrink-0">
|
||||
<div class="relative">
|
||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-xs"></i>
|
||||
<input id="knowledge-search" type="text" placeholder="Search..."
|
||||
@@ -890,19 +934,19 @@
|
||||
oninput="filterKnowledgeTree(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
<div id="knowledge-tree" class="p-2 overflow-y-auto max-h-[50vh] md:max-h-[calc(100vh-300px)]"></div>
|
||||
<div id="knowledge-tree" class="p-2 overflow-y-auto flex-1 max-h-[50vh] md:max-h-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Content viewer -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex-1 min-w-0 md:h-full">
|
||||
<div id="knowledge-content-placeholder"
|
||||
class="flex flex-col items-center justify-center py-20 text-slate-400 dark:text-slate-500">
|
||||
class="flex flex-col items-center justify-center py-20 md:h-full text-slate-400 dark:text-slate-500 bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10">
|
||||
<i class="fas fa-file-lines text-3xl mb-3 opacity-40"></i>
|
||||
<p class="text-sm" data-i18n="knowledge_select_hint">选择一个文档查看</p>
|
||||
</div>
|
||||
<div id="knowledge-content-viewer" class="hidden">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="flex items-center gap-3 px-4 md:px-5 py-3 border-b border-slate-200 dark:border-white/10">
|
||||
<div id="knowledge-content-viewer" class="hidden md:h-full">
|
||||
<div class="bg-white dark:bg-[#1A1A1A] rounded-xl border border-slate-200 dark:border-white/10 overflow-hidden flex flex-col md:h-full">
|
||||
<div class="flex items-center gap-3 px-4 md:px-5 py-3 border-b border-slate-200 dark:border-white/10 flex-shrink-0">
|
||||
<button onclick="knowledgeMobileBack()" class="md:hidden p-1 -ml-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer">
|
||||
<i class="fas fa-arrow-left text-xs"></i>
|
||||
</button>
|
||||
@@ -911,8 +955,7 @@
|
||||
<span id="knowledge-viewer-path" class="text-xs text-slate-400 dark:text-slate-500 ml-auto font-mono truncate hidden md:inline"></span>
|
||||
</div>
|
||||
<div id="knowledge-viewer-body"
|
||||
class="p-4 md:p-5 overflow-y-auto text-sm msg-content text-slate-700 dark:text-slate-200"
|
||||
style="max-height: calc(100vh - 280px)"></div>
|
||||
class="p-4 md:p-5 overflow-y-auto flex-1 max-h-[60vh] md:max-h-none text-sm msg-content text-slate-700 dark:text-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1083,7 +1126,7 @@
|
||||
|
||||
<!-- 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 id="knowledge-dialog-card" 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-emerald-50 dark:bg-emerald-900/20 flex items-center justify-center">
|
||||
@@ -1097,8 +1140,36 @@
|
||||
<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>
|
||||
<div id="knowledge-dialog-select" class="cfg-dropdown hidden w-full" tabindex="0">
|
||||
<div class="cfg-dropdown-selected">
|
||||
<span class="cfg-dropdown-text">--</span>
|
||||
<i class="fas fa-chevron-down cfg-dropdown-arrow"></i>
|
||||
</div>
|
||||
<div class="cfg-dropdown-menu"></div>
|
||||
</div>
|
||||
<textarea id="knowledge-dialog-textarea" rows="8"
|
||||
class="hidden 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 resize-y"></textarea>
|
||||
<div id="knowledge-document-form" class="hidden space-y-3">
|
||||
<div class="rounded-lg bg-emerald-50 dark:bg-emerald-900/15 border border-emerald-100 dark:border-emerald-800/40 px-3 py-2">
|
||||
<div id="knowledge-document-category-label" class="text-[11px] text-emerald-600 dark:text-emerald-400 mb-0.5"></div>
|
||||
<div id="knowledge-document-path-preview" class="text-xs font-mono text-emerald-700 dark:text-emerald-300 break-all"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label id="knowledge-document-filename-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300 mb-1.5"></label>
|
||||
<input id="knowledge-document-filename" 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"
|
||||
placeholder="note.md">
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<label id="knowledge-document-content-label" class="block text-sm font-medium text-slate-600 dark:text-slate-300"></label>
|
||||
<button id="knowledge-document-template" type="button" class="text-xs text-primary-500 hover:text-primary-600"></button>
|
||||
</div>
|
||||
<textarea id="knowledge-document-content" rows="14"
|
||||
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 resize-y"
|
||||
placeholder="# Title Write your notes here..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -1293,6 +1293,18 @@
|
||||
background: rgba(74, 190, 110, 0.1);
|
||||
color: #4ABE6E;
|
||||
}
|
||||
.knowledge-import-drag-over {
|
||||
outline: 2px dashed rgba(74, 190, 110, 0.55);
|
||||
outline-offset: 4px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
#knowledge-dialog-card.knowledge-document-dialog {
|
||||
max-width: 760px;
|
||||
}
|
||||
#knowledge-document-content {
|
||||
min-height: 320px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Graph legend */
|
||||
.knowledge-graph-legend {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ from common import const
|
||||
from common import i18n
|
||||
from common.log import logger
|
||||
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"}
|
||||
VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
|
||||
@@ -181,6 +181,29 @@ def _read_uploaded_file_bytes(file_obj) -> bytes:
|
||||
raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}")
|
||||
|
||||
|
||||
def _read_uploaded_file_bytes_limited(file_obj, max_bytes: int) -> bytes:
|
||||
"""Read uploaded content and fail once it exceeds max_bytes."""
|
||||
if isinstance(file_obj, bytes):
|
||||
content = file_obj
|
||||
elif isinstance(file_obj, str):
|
||||
content = file_obj.encode("utf-8")
|
||||
elif hasattr(file_obj, "file") and hasattr(file_obj.file, "read"):
|
||||
content = file_obj.file.read(max_bytes + 1)
|
||||
elif hasattr(file_obj, "read"):
|
||||
content = file_obj.read(max_bytes + 1)
|
||||
elif hasattr(file_obj, "value"):
|
||||
content = file_obj.value
|
||||
else:
|
||||
raise ValueError("Unable to read uploaded file content")
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8")
|
||||
if not isinstance(content, bytes):
|
||||
raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}")
|
||||
if len(content) > max_bytes:
|
||||
raise ValueError("file too large")
|
||||
return content
|
||||
|
||||
|
||||
def _raw_web_input():
|
||||
"""Return unprocessed multipart form data when web.py exposes rawinput."""
|
||||
rawinput = getattr(getattr(web, "webapi", None), "rawinput", None)
|
||||
@@ -240,7 +263,13 @@ class WebChannel(ChatChannel):
|
||||
self.session_queues = {} # session_id -> Queue (fallback polling)
|
||||
self.request_to_session = {} # request_id -> session_id
|
||||
self.sse_queues = {} # request_id -> Queue (SSE streaming)
|
||||
# request_id -> last-active timestamp. Refreshed while the SSE
|
||||
# generator is being consumed (client still connected). The janitor
|
||||
# only reclaims queues whose generator stopped refreshing this, so a
|
||||
# long-running but still-streaming reply is never wrongly killed.
|
||||
self.sse_last_active = {}
|
||||
self._http_server = None
|
||||
self._sse_janitor_started = False
|
||||
|
||||
def _generate_msg_id(self):
|
||||
"""生成唯一的消息ID"""
|
||||
@@ -527,14 +556,29 @@ class WebChannel(ChatChannel):
|
||||
file_path = data.get("path", "")
|
||||
file_name = data.get("file_name", os.path.basename(file_path))
|
||||
file_type = data.get("file_type", "file")
|
||||
from urllib.parse import quote
|
||||
web_url = f"/api/file?path={quote(file_path)}"
|
||||
# Remote URLs are passed through as-is; local files are served
|
||||
# via the backend /api/file endpoint.
|
||||
remote_url = data.get("url", "")
|
||||
is_remote = bool(remote_url) and remote_url.lower().startswith(("http://", "https://"))
|
||||
if is_remote:
|
||||
web_url = remote_url
|
||||
else:
|
||||
from urllib.parse import quote
|
||||
web_url = f"/api/file?path={quote(file_path)}"
|
||||
is_image = file_type == "image"
|
||||
q.put({
|
||||
payload = {
|
||||
"type": "image" if is_image else "file",
|
||||
"content": web_url,
|
||||
"file_name": file_name,
|
||||
})
|
||||
# Preserve the concrete media kind (image/video/audio/...)
|
||||
# so richer clients can render an inline player.
|
||||
"file_type": file_type,
|
||||
}
|
||||
# Expose the local absolute path so the desktop client can open
|
||||
# the file directly (Finder / default app) instead of the browser.
|
||||
if not is_remote and file_path:
|
||||
payload["abs_path"] = file_path
|
||||
q.put(payload)
|
||||
|
||||
return on_event
|
||||
|
||||
@@ -865,6 +909,7 @@ class WebChannel(ChatChannel):
|
||||
|
||||
if use_sse:
|
||||
self.sse_queues[request_id] = Queue()
|
||||
self.sse_last_active[request_id] = time.time()
|
||||
|
||||
trigger_prefixs = conf().get("single_chat_prefix", [""])
|
||||
if check_prefix(prompt, trigger_prefixs) is None:
|
||||
@@ -879,8 +924,7 @@ class WebChannel(ChatChannel):
|
||||
|
||||
if context is None:
|
||||
logger.warning(f"[WebChannel] Context is None for session {session_id}, message may be filtered")
|
||||
if request_id in self.sse_queues:
|
||||
del self.sse_queues[request_id]
|
||||
self._drop_sse_request(request_id)
|
||||
return json.dumps({"status": "error", "message": "Message was filtered"})
|
||||
|
||||
context["session_id"] = session_id
|
||||
@@ -903,6 +947,60 @@ class WebChannel(ChatChannel):
|
||||
logger.error(f"Error processing message: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
|
||||
def _drop_sse_request(self, request_id: str):
|
||||
"""Reclaim all state tied to an SSE request to prevent fd/memory leaks.
|
||||
|
||||
Removing the queue lets the WSGI generator and its socket be released,
|
||||
and dropping request_to_session avoids unbounded map growth.
|
||||
"""
|
||||
self.sse_queues.pop(request_id, None)
|
||||
self.sse_last_active.pop(request_id, None)
|
||||
self.request_to_session.pop(request_id, None)
|
||||
|
||||
def _start_sse_janitor(self):
|
||||
"""Start a background thread that reclaims orphaned SSE queues.
|
||||
|
||||
When a client disconnects before the "done" event arrives (browser
|
||||
closed, session switched, network drop), the generator may keep the
|
||||
queue around to allow reconnection. Without a sweep these orphans
|
||||
accumulate, leaking file descriptors until cheroot raises
|
||||
"[Errno 24] Too many open files".
|
||||
|
||||
Reclamation is based on idle time, not total age: an active stream
|
||||
refreshes ``sse_last_active`` every second while its generator is being
|
||||
consumed, so a long-running reply (even hours long) is never killed
|
||||
while the client stays connected. Only queues that stopped refreshing
|
||||
(client gone) past SSE_IDLE_TIMEOUT are reclaimed.
|
||||
"""
|
||||
if self._sse_janitor_started:
|
||||
return
|
||||
self._sse_janitor_started = True
|
||||
|
||||
SSE_IDLE_TIMEOUT = 1800 # 30 minutes with no client consumption
|
||||
SWEEP_INTERVAL = 60
|
||||
|
||||
def _sweep():
|
||||
while True:
|
||||
time.sleep(SWEEP_INTERVAL)
|
||||
try:
|
||||
now = time.time()
|
||||
stale = [
|
||||
rid for rid, ts in list(self.sse_last_active.items())
|
||||
if now - ts > SSE_IDLE_TIMEOUT
|
||||
]
|
||||
for rid in stale:
|
||||
self._drop_sse_request(rid)
|
||||
if stale:
|
||||
logger.info(
|
||||
f"[WebChannel] SSE janitor reclaimed {len(stale)} "
|
||||
f"idle stream(s)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[WebChannel] SSE janitor error: {e}")
|
||||
|
||||
t = threading.Thread(target=_sweep, name="sse-janitor", daemon=True)
|
||||
t.start()
|
||||
|
||||
def stream_response(self, request_id: str):
|
||||
"""
|
||||
SSE generator for a given request_id.
|
||||
@@ -927,6 +1025,10 @@ class WebChannel(ChatChannel):
|
||||
|
||||
try:
|
||||
while time.time() < deadline:
|
||||
# Mark the stream alive on every loop. While the client keeps
|
||||
# consuming, the generator runs and refreshes this, so the
|
||||
# janitor won't reclaim a long-running but active stream.
|
||||
self.sse_last_active[request_id] = time.time()
|
||||
try:
|
||||
item = q.get(timeout=1)
|
||||
except Empty:
|
||||
@@ -955,13 +1057,21 @@ class WebChannel(ChatChannel):
|
||||
# voice_attach payload through to the browser.
|
||||
post_done = True
|
||||
post_deadline = time.time() + 2 # 2s post-attach tail
|
||||
except GeneratorExit:
|
||||
# Client disconnected (WSGI closed the generator). If the reply is
|
||||
# already complete there is nothing to resume, so reclaim now to
|
||||
# release the socket fd. Otherwise keep the queue briefly so a
|
||||
# reconnect with the same request_id can resume; the janitor will
|
||||
# reclaim it if no reconnect happens.
|
||||
if post_done:
|
||||
self._drop_sse_request(request_id)
|
||||
raise
|
||||
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.
|
||||
# Drop the queue once the reply is actually complete or the idle
|
||||
# deadline has passed. Early client disconnects are handled by the
|
||||
# GeneratorExit branch above and the background janitor.
|
||||
if post_done or time.time() >= deadline:
|
||||
self.sse_queues.pop(request_id, None)
|
||||
self._drop_sse_request(request_id)
|
||||
|
||||
def cancel_request(self):
|
||||
"""
|
||||
@@ -1062,7 +1172,10 @@ class WebChannel(ChatChannel):
|
||||
def startup(self):
|
||||
configured_host = conf().get("web_host", "")
|
||||
host = configured_host or ("0.0.0.0" if _is_password_enabled() else "127.0.0.1")
|
||||
port = conf().get("web_port", 9899)
|
||||
# The desktop app passes its chosen port via COW_WEB_PORT so its backend
|
||||
# never collides with a source-run web console (default 9899). This makes
|
||||
# the port a single source of truth owned by the Electron shell.
|
||||
port = int(os.environ.get("COW_WEB_PORT") or conf().get("web_port", 9899))
|
||||
is_public_bind = host in ("0.0.0.0", "::")
|
||||
|
||||
self._cleanup_stale_voice_recordings()
|
||||
@@ -1114,18 +1227,25 @@ class WebChannel(ChatChannel):
|
||||
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")
|
||||
|
||||
try:
|
||||
import webbrowser
|
||||
webbrowser.open(f"http://localhost:{port}")
|
||||
logger.debug(f"[WebChannel] Opened browser at http://localhost:{port}")
|
||||
except Exception as e:
|
||||
logger.debug(f"[WebChannel] Could not open browser: {e}")
|
||||
# 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:
|
||||
import webbrowser
|
||||
webbrowser.open(f"http://localhost:{port}")
|
||||
logger.debug(f"[WebChannel] Opened browser at http://localhost:{port}")
|
||||
except Exception as 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')
|
||||
if not os.path.exists(static_dir):
|
||||
os.makedirs(static_dir)
|
||||
logger.debug(f"[WebChannel] Created static directory: {static_dir}")
|
||||
try:
|
||||
os.makedirs(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 = (
|
||||
'/', 'RootHandler',
|
||||
@@ -1155,6 +1275,7 @@ class WebChannel(ChatChannel):
|
||||
'/api/knowledge/read', 'KnowledgeReadHandler',
|
||||
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
||||
'/api/knowledge/action', 'KnowledgeActionHandler',
|
||||
'/api/knowledge/import', 'KnowledgeImportHandler',
|
||||
'/api/scheduler', 'SchedulerHandler',
|
||||
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
||||
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
||||
@@ -1191,6 +1312,8 @@ class WebChannel(ChatChannel):
|
||||
server.requests.min = 20
|
||||
server.requests.max = 80
|
||||
self._http_server = server
|
||||
# Reclaim orphaned SSE queues so disconnected clients don't leak fds.
|
||||
self._start_sse_janitor()
|
||||
try:
|
||||
server.start()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
@@ -1474,14 +1597,14 @@ class ConfigHandler:
|
||||
_RECOMMENDED_MODELS = [
|
||||
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
||||
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
||||
# 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,
|
||||
# claude-sonnet-5 is the Claude default; claude-fable-5 is dropped
|
||||
# from this web console list for now.
|
||||
const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, 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.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_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.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
|
||||
const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_CODE,
|
||||
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.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
||||
@@ -1521,7 +1644,7 @@ class ConfigHandler:
|
||||
"api_base_key": "claude_api_base",
|
||||
"api_base_default": "https://api.anthropic.com/v1",
|
||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||
"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],
|
||||
"models": [const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||
}),
|
||||
("gemini", {
|
||||
"label": "Gemini",
|
||||
@@ -1561,7 +1684,7 @@ class ConfigHandler:
|
||||
"api_base_key": "ark_base_url",
|
||||
"api_base_default": "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"api_base_placeholder": _PLACEHOLDER_DOUBAO,
|
||||
"models": [const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE],
|
||||
"models": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE],
|
||||
}),
|
||||
("moonshot", {
|
||||
"label": "Kimi",
|
||||
@@ -1730,11 +1853,14 @@ class ConfigHandler:
|
||||
if not applied:
|
||||
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(
|
||||
os.path.abspath(__file__)))), "config.json")
|
||||
config_path = os.path.join(get_data_root(), "config.json")
|
||||
old_password = "" # Store old password before update
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
file_cfg = json.load(f)
|
||||
# Capture old password before updating
|
||||
if "web_password" in applied:
|
||||
old_password = file_cfg.get("web_password", "")
|
||||
else:
|
||||
file_cfg = {}
|
||||
file_cfg.update(applied)
|
||||
@@ -1752,6 +1878,26 @@ class ConfigHandler:
|
||||
except Exception as lang_err:
|
||||
logger.warning(f"[WebChannel] Failed to apply language: {lang_err}")
|
||||
|
||||
# Check if password was cleared: if there was a password before clearing,
|
||||
# the service is likely bound to 0.0.0.0 (public), so warn the user.
|
||||
password_warning = None
|
||||
if "web_password" in applied:
|
||||
new_password = applied["web_password"]
|
||||
configured_host = file_cfg.get("web_host", "")
|
||||
|
||||
# If password was cleared and there was a password before
|
||||
if not new_password and old_password:
|
||||
# If web_host is not explicitly set, the service auto-binds based on password
|
||||
# With password → 0.0.0.0 (public), without password → 127.0.0.1 (local)
|
||||
# So clearing password when it was previously set means going from public to local
|
||||
if not configured_host or configured_host == "0.0.0.0":
|
||||
password_warning = "password_cleared_with_public_host"
|
||||
logger.warning(
|
||||
"[WebChannel] Password cleared while service is likely bound to 0.0.0.0. "
|
||||
"Consider restarting the service to rebind to 127.0.0.1 "
|
||||
"or explicitly set web_host in config to prevent unauthorized access."
|
||||
)
|
||||
|
||||
# Reset Bridge so that bot routing reflects the new config.
|
||||
# Without this, Bridge keeps its cached bot instance (e.g. LinkAIBot)
|
||||
# even after the user switches bot_type / use_linkai / model in UI.
|
||||
@@ -1764,7 +1910,7 @@ class ConfigHandler:
|
||||
except Exception as reset_err:
|
||||
logger.warning(f"[WebChannel] Failed to reset bridge: {reset_err}")
|
||||
|
||||
return json.dumps({"status": "success", "applied": applied}, ensure_ascii=False)
|
||||
return json.dumps({"status": "success", "applied": applied, "warning": password_warning}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating config: {e}")
|
||||
return json.dumps({"status": "error", "message": str(e)})
|
||||
@@ -2062,7 +2208,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
|
||||
# provider's generic model list, but vision and image generation are
|
||||
@@ -2083,10 +2242,10 @@ class ModelsHandler:
|
||||
const.GPT_41_MINI,
|
||||
const.GPT_4o,
|
||||
],
|
||||
"doubao": [const.DOUBAO_SEED_2_PRO],
|
||||
"doubao": [const.DOUBAO_SEED_2_1_PRO, const.DOUBAO_SEED_2_1_TURBO, const.DOUBAO_SEED_2_PRO],
|
||||
"moonshot": [const.KIMI_K2_6],
|
||||
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
|
||||
"claudeAPI": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||
"claudeAPI": [const.CLAUDE_SONNET_5, const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS],
|
||||
"gemini": [const.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE],
|
||||
"qianfan": [const.ERNIE_45_TURBO_VL],
|
||||
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
||||
@@ -2107,11 +2266,14 @@ class ModelsHandler:
|
||||
const.GPT_41_MINI,
|
||||
const.GPT_54_MINI,
|
||||
const.QWEN37_PLUS,
|
||||
const.DOUBAO_SEED_2_PRO,
|
||||
const.DOUBAO_SEED_2_1_PRO,
|
||||
const.KIMI_K2_6,
|
||||
const.CLAUDE_4_6_SONNET,
|
||||
const.CLAUDE_SONNET_5,
|
||||
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.
|
||||
@@ -2148,10 +2310,7 @@ class ModelsHandler:
|
||||
|
||||
@staticmethod
|
||||
def _config_path() -> str:
|
||||
return os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
|
||||
"config.json",
|
||||
)
|
||||
return os.path.join(get_data_root(), "config.json")
|
||||
|
||||
@classmethod
|
||||
def _read_file_config(cls) -> dict:
|
||||
@@ -2338,7 +2497,7 @@ class ModelsHandler:
|
||||
("moonshot", "moonshot_api_key", const.KIMI_K2_6),
|
||||
("doubao", "ark_api_key", const.DOUBAO_SEED_2_PRO),
|
||||
("dashscope", "dashscope_api_key", const.QWEN37_PLUS),
|
||||
("claudeAPI", "claude_api_key", const.CLAUDE_4_6_SONNET),
|
||||
("claudeAPI", "claude_api_key", const.CLAUDE_SONNET_5),
|
||||
("gemini", "gemini_api_key", const.GEMINI_35_FLASH),
|
||||
("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL),
|
||||
("zhipu", "zhipu_ai_api_key", const.GLM_5V_TURBO),
|
||||
@@ -2425,18 +2584,31 @@ class ModelsHandler:
|
||||
user_specified = (vision_conf.get("model") 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:
|
||||
# 1. Explicit `tools.vision.provider` (persisted via UI; supports
|
||||
# custom model names that prefix-inference can't recognize).
|
||||
# 2. Scan per-provider model lists by model name.
|
||||
# Empty provider keeps the dropdown on "auto" when we can't tell.
|
||||
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
|
||||
elif user_specified:
|
||||
for pid, models in cls._VISION_PROVIDER_MODELS.items():
|
||||
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
|
||||
|
||||
# In auto mode the hint should reflect what vision.py will actually
|
||||
@@ -2452,7 +2624,7 @@ class ModelsHandler:
|
||||
"current_model": user_specified,
|
||||
"fallback_provider": predicted["provider"],
|
||||
"fallback_model": predicted["model"],
|
||||
"providers": list(cls._VISION_PROVIDER_MODELS.keys()),
|
||||
"providers": providers,
|
||||
"provider_models": cls._VISION_PROVIDER_MODELS,
|
||||
}
|
||||
|
||||
@@ -2525,18 +2697,40 @@ class ModelsHandler:
|
||||
suggested = ""
|
||||
if not explicit:
|
||||
for pid in cls._EMBEDDING_PROVIDERS:
|
||||
if pid == "custom":
|
||||
continue
|
||||
meta = ConfigHandler.PROVIDER_MODELS.get(pid) or {}
|
||||
key_field = meta.get("api_key_field")
|
||||
if key_field and cls._is_real_key(local_config.get(key_field, "")):
|
||||
suggested = pid
|
||||
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 {
|
||||
"editable": True,
|
||||
"current_provider": explicit,
|
||||
"suggested_provider": suggested,
|
||||
"current_model": local_config.get("embedding_model", "") or "",
|
||||
"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
|
||||
@@ -2898,10 +3092,10 @@ class ModelsHandler:
|
||||
{
|
||||
"action": "set_custom_provider",
|
||||
"id": "3f2a9c1b", # required for edit; omit for create
|
||||
"name": "siliconflow", # required, display label
|
||||
"name": "my-provider", # required, display label
|
||||
"api_base": "https://...", # required when creating
|
||||
"api_key": "sk-...", # optional on edit (keep existing)
|
||||
"model": "deepseek-ai/...", # optional default model
|
||||
"model": "model-name", # optional default model
|
||||
"make_active": true # optional, also activate it
|
||||
}
|
||||
"""
|
||||
@@ -3122,6 +3316,25 @@ class ModelsHandler:
|
||||
# is persisted so users picking a custom model under a specific vendor
|
||||
# still get routed there — runtime falls back to model-name prefix
|
||||
# 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()
|
||||
file_cfg = self._read_file_config()
|
||||
self._set_nested_namespace_value(file_cfg, "tools", "vision", "model", model)
|
||||
@@ -3247,7 +3460,20 @@ class ModelsHandler:
|
||||
logger.warning(f"[ModelsHandler] Bridge voice refresh failed: {e}")
|
||||
|
||||
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,
|
||||
# so reject that explicitly instead of silently writing it through.
|
||||
if provider_id and not model:
|
||||
@@ -3465,10 +3691,16 @@ class ChannelsHandler:
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
from common import i18n
|
||||
local_config = conf()
|
||||
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 = []
|
||||
is_hant = i18n.get_language() == i18n.ZH_HANT
|
||||
for ch_name, ch_def in self.CHANNEL_DEFS.items():
|
||||
if desktop_mode and ch_name == "feishu":
|
||||
continue
|
||||
fields_out = []
|
||||
for f in ch_def["fields"]:
|
||||
raw_val = local_config.get(f["key"], f.get("default", ""))
|
||||
@@ -3476,16 +3708,32 @@ class ChannelsHandler:
|
||||
display_val = self._mask_secret(str(raw_val))
|
||||
else:
|
||||
display_val = raw_val
|
||||
|
||||
label_val = f["label"]
|
||||
if is_hant and isinstance(label_val, str):
|
||||
label_val = i18n.to_traditional(label_val)
|
||||
elif is_hant and isinstance(label_val, dict):
|
||||
label_val = label_val.copy()
|
||||
label_val["zh-Hant"] = i18n.to_traditional(label_val.get("zh", ""))
|
||||
|
||||
fields_out.append({
|
||||
"key": f["key"],
|
||||
"label": f["label"],
|
||||
"label": label_val,
|
||||
"type": f["type"],
|
||||
"value": display_val,
|
||||
"default": f.get("default", ""),
|
||||
})
|
||||
|
||||
label_val = ch_def["label"]
|
||||
if is_hant and isinstance(label_val, str):
|
||||
label_val = i18n.to_traditional(label_val)
|
||||
elif is_hant and isinstance(label_val, dict):
|
||||
label_val = label_val.copy()
|
||||
label_val["zh-Hant"] = i18n.to_traditional(label_val.get("zh", ""))
|
||||
|
||||
ch_info = {
|
||||
"name": ch_name,
|
||||
"label": ch_def["label"],
|
||||
"label": label_val,
|
||||
"icon": ch_def["icon"],
|
||||
"color": ch_def["color"],
|
||||
"active": ch_name in active_channels,
|
||||
@@ -3550,8 +3798,7 @@ class ChannelsHandler:
|
||||
if not applied:
|
||||
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(
|
||||
os.path.abspath(__file__)))), "config.json")
|
||||
config_path = os.path.join(get_data_root(), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
file_cfg = json.load(f)
|
||||
@@ -3621,8 +3868,7 @@ class ChannelsHandler:
|
||||
new_channel_type = ",".join(existing)
|
||||
local_config["channel_type"] = new_channel_type
|
||||
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__)))), "config.json")
|
||||
config_path = os.path.join(get_data_root(), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
file_cfg = json.load(f)
|
||||
@@ -3677,8 +3923,7 @@ class ChannelsHandler:
|
||||
local_config = conf()
|
||||
local_config["channel_type"] = new_channel_type
|
||||
|
||||
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
||||
os.path.abspath(__file__)))), "config.json")
|
||||
config_path = os.path.join(get_data_root(), "config.json")
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
file_cfg = json.load(f)
|
||||
@@ -3828,9 +4073,7 @@ class WeixinQrHandler:
|
||||
if not bot_token or not bot_id:
|
||||
return json.dumps({"status": "error", "message": "Login confirmed but missing token"})
|
||||
|
||||
cred_path = os.path.expanduser(
|
||||
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
|
||||
)
|
||||
cred_path = get_weixin_credentials_path()
|
||||
from channel.weixin.weixin_channel import _save_credentials
|
||||
_save_credentials(cred_path, {
|
||||
"token": bot_token,
|
||||
@@ -4047,16 +4290,26 @@ class ToolsHandler:
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
from agent.tools.tool_manager import ToolManager
|
||||
from common import i18n
|
||||
tm = ToolManager()
|
||||
if not tm.tool_classes:
|
||||
tm.load_tools()
|
||||
tools = []
|
||||
lang = i18n.get_language()
|
||||
for name, cls in tm.tool_classes.items():
|
||||
try:
|
||||
instance = cls()
|
||||
desc = instance.description
|
||||
if lang == i18n.ZH_HANT and desc:
|
||||
desc = i18n.to_traditional(desc)
|
||||
elif lang == "en" and name == "scheduler":
|
||||
desc = (
|
||||
"Create, query and manage scheduled tasks (reminders, periodic tasks, etc.).\n\n"
|
||||
"⚠️ IMPORTANT: Only use this tool when delayed or periodic execution is needed."
|
||||
)
|
||||
tools.append({
|
||||
"name": name,
|
||||
"description": instance.description,
|
||||
"description": desc,
|
||||
})
|
||||
except Exception:
|
||||
tools.append({"name": name, "description": ""})
|
||||
@@ -4073,10 +4326,17 @@ class SkillsHandler:
|
||||
try:
|
||||
from agent.skills.service import SkillService
|
||||
from agent.skills.manager import SkillManager
|
||||
from common import i18n
|
||||
workspace_root = _get_workspace_root()
|
||||
manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills"))
|
||||
service = SkillService(manager)
|
||||
skills = service.query()
|
||||
if i18n.get_language() == i18n.ZH_HANT:
|
||||
for skill in skills:
|
||||
if isinstance(skill, dict):
|
||||
for k, v in list(skill.items()):
|
||||
if k in ("name", "description", "display_name") and isinstance(v, str):
|
||||
skill[k] = i18n.to_traditional(v)
|
||||
return json.dumps({"status": "success", "skills": skills}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Skills API error: {e}")
|
||||
@@ -4494,8 +4754,7 @@ class LogsHandler:
|
||||
web.header('Cache-Control', 'no-cache')
|
||||
web.header('X-Accel-Buffering', 'no')
|
||||
|
||||
from config import get_root
|
||||
log_path = os.path.join(get_root(), "run.log")
|
||||
log_path = os.path.join(get_data_root(), "run.log")
|
||||
|
||||
def generate():
|
||||
if not os.path.isfile(log_path):
|
||||
@@ -4647,6 +4906,71 @@ class KnowledgeActionHandler:
|
||||
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
|
||||
|
||||
|
||||
class KnowledgeImportHandler:
|
||||
def POST(self):
|
||||
_require_auth()
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
try:
|
||||
from agent.knowledge.service import KnowledgeService
|
||||
content_length = int(getattr(web.ctx, "env", {}).get("CONTENT_LENGTH") or 0)
|
||||
if content_length > KnowledgeService.MAX_IMPORT_TOTAL_SIZE:
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"code": 413,
|
||||
"message": "import batch too large",
|
||||
"payload": None,
|
||||
})
|
||||
params = _raw_web_input()
|
||||
target_category = params.get("target_category", "")
|
||||
conflict_strategy = params.get("conflict_strategy", "skip")
|
||||
uploaded = _ensure_list(params.get("files"))
|
||||
single = params.get("file")
|
||||
if single is not None:
|
||||
uploaded.append(single)
|
||||
if not uploaded:
|
||||
return json.dumps({"status": "error", "code": 400, "message": "No files uploaded", "payload": None})
|
||||
if len(uploaded) > KnowledgeService.MAX_IMPORT_FILES:
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"code": 400,
|
||||
"message": f"too many files: max {KnowledgeService.MAX_IMPORT_FILES}",
|
||||
"payload": None,
|
||||
})
|
||||
|
||||
files = []
|
||||
total_size = 0
|
||||
for file_obj in uploaded:
|
||||
if file_obj is None:
|
||||
continue
|
||||
filename = getattr(file_obj, "filename", "") or getattr(file_obj, "name", "")
|
||||
content = _read_uploaded_file_bytes_limited(file_obj, KnowledgeService.MAX_IMPORT_FILE_SIZE)
|
||||
total_size += len(content)
|
||||
if total_size > KnowledgeService.MAX_IMPORT_TOTAL_SIZE:
|
||||
return json.dumps({
|
||||
"status": "error",
|
||||
"code": 413,
|
||||
"message": "import batch too large",
|
||||
"payload": None,
|
||||
})
|
||||
files.append({
|
||||
"filename": filename,
|
||||
"content": content,
|
||||
})
|
||||
|
||||
result = KnowledgeService(_get_workspace_root()).dispatch("import_documents", {
|
||||
"target_category": target_category,
|
||||
"conflict_strategy": conflict_strategy,
|
||||
"files": files,
|
||||
})
|
||||
return json.dumps({
|
||||
"status": "success" if result["code"] < 300 else "error",
|
||||
**result,
|
||||
}, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"[WebChannel] Knowledge import error: {e}", exc_info=True)
|
||||
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
|
||||
|
||||
|
||||
class VersionHandler:
|
||||
def GET(self):
|
||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||
|
||||
@@ -24,7 +24,7 @@ from channel.weixin.weixin_message import WeixinMessage
|
||||
from common.expired_dict import ExpiredDict
|
||||
from common.log import logger
|
||||
from common.singleton import singleton
|
||||
from config import conf
|
||||
from config import conf, get_weixin_credentials_path
|
||||
|
||||
MAX_CONSECUTIVE_FAILURES = 3
|
||||
BACKOFF_DELAY = 30
|
||||
@@ -96,9 +96,7 @@ class WeixinChannel(ChatChannel):
|
||||
cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
|
||||
token = conf().get("weixin_token", "")
|
||||
|
||||
self._credentials_path = os.path.expanduser(
|
||||
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
|
||||
)
|
||||
self._credentials_path = get_weixin_credentials_path()
|
||||
|
||||
# Always load credentials so we can restore context_tokens even when
|
||||
# the bot token itself comes from config.
|
||||
|
||||
@@ -1 +1 @@
|
||||
2.1.1
|
||||
2.1.3
|
||||
|
||||
@@ -21,7 +21,7 @@ from bridge.context import Context, ContextType
|
||||
from bridge.reply import Reply, ReplyType
|
||||
from common.log import logger
|
||||
from linkai import LinkAIClient, PushMsg
|
||||
from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root
|
||||
from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root, get_weixin_credentials_path
|
||||
from plugins import PluginManager
|
||||
import threading
|
||||
import time
|
||||
@@ -336,9 +336,7 @@ class CloudClient(LinkAIClient):
|
||||
@staticmethod
|
||||
def _remove_weixin_credentials():
|
||||
"""Remove the weixin token credentials file so next connect triggers QR login."""
|
||||
cred_path = os.path.expanduser(
|
||||
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
|
||||
)
|
||||
cred_path = get_weixin_credentials_path()
|
||||
try:
|
||||
if os.path.exists(cred_path):
|
||||
os.remove(cred_path)
|
||||
|
||||
@@ -37,6 +37,7 @@ CLAUDE_4_6_OPUS = "claude-opus-4-6" # Claude Opus 4.6
|
||||
CLAUDE_4_SONNET = "claude-sonnet-4-0" # Claude Sonnet 4.0
|
||||
CLAUDE_4_5_SONNET = "claude-sonnet-4-5" # Claude Sonnet 4.5 - Agent recommended model
|
||||
CLAUDE_4_6_SONNET = "claude-sonnet-4-6" # Claude Sonnet 4.6 - Agent recommended model
|
||||
CLAUDE_SONNET_5 = "claude-sonnet-5" # Claude Sonnet 5 - default flagship model for Claude
|
||||
|
||||
# Gemini (Google)
|
||||
GEMINI_PRO = "gemini-1.0-pro"
|
||||
@@ -153,6 +154,8 @@ MIMO_V2_FLASH = "mimo-v2-flash" # MiMo V2 Flash - high-speed
|
||||
|
||||
# Doubao (Volcengine Ark)
|
||||
DOUBAO = "doubao"
|
||||
DOUBAO_SEED_2_1_PRO = "doubao-seed-2-1-pro-260628"
|
||||
DOUBAO_SEED_2_1_TURBO = "doubao-seed-2-1-turbo-260628"
|
||||
DOUBAO_SEED_2_CODE = "doubao-seed-2-0-code-preview-260215"
|
||||
DOUBAO_SEED_2_PRO = "doubao-seed-2-0-pro-260215"
|
||||
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
|
||||
@@ -197,7 +200,7 @@ MODEL_LIST = [
|
||||
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||
|
||||
# Claude
|
||||
CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_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_SONNET_5, CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_FABLE_5, CLAUDE_4_6_SONNET, CLAUDE_4_6_OPUS, CLAUDE_4_OPUS, CLAUDE_4_5_SONNET, CLAUDE_4_SONNET, CLAUDE_3_OPUS, CLAUDE_3_OPUS_0229,
|
||||
CLAUDE_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
||||
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||
|
||||
@@ -223,7 +226,8 @@ MODEL_LIST = [
|
||||
QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
|
||||
|
||||
# Doubao
|
||||
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
||||
DOUBAO, DOUBAO_SEED_2_1_PRO, DOUBAO_SEED_2_1_TURBO,
|
||||
DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
||||
|
||||
# Kimi (Moonshot)
|
||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
||||
|
||||
@@ -7,6 +7,11 @@ across the CLI, startup logs, error messages, agent prompts and channel
|
||||
replies. It must NOT import project config (to avoid circular imports) and
|
||||
must stay dependency-free so it can run at the earliest startup phase.
|
||||
|
||||
Supported language codes (BCP 47 compliant):
|
||||
- "zh" (Simplified Chinese)
|
||||
- "zh-Hant" (Traditional Chinese, script-based tag per Unicode CLDR)
|
||||
- "en" (English)
|
||||
|
||||
Resolution priority (highest first):
|
||||
1. Explicit `cow_lang` from config.json — also covers Docker/CI, since any
|
||||
config key is overridable via its uppercase env var (e.g. COW_LANG=zh),
|
||||
@@ -19,7 +24,10 @@ Resolution priority (highest first):
|
||||
5. Default -> English
|
||||
|
||||
A value of "auto" (the default) triggers detection (steps 2-5). Explicitly
|
||||
setting "zh" or "en" locks the language and skips detection.
|
||||
setting "zh", "zh-Hant", or "en" locks the language and skips detection.
|
||||
|
||||
Note: For backwards compatibility, zh-tw, zh-hk, and other regional variants
|
||||
are automatically normalized to zh-Hant during detection.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -28,10 +36,85 @@ import sys
|
||||
|
||||
# Supported language codes
|
||||
ZH = "zh"
|
||||
ZH_HANT = "zh-Hant"
|
||||
EN = "en"
|
||||
SUPPORTED = (ZH, EN)
|
||||
SUPPORTED = (ZH, ZH_HANT, EN)
|
||||
DEFAULT_LANG = EN
|
||||
|
||||
# Mapped Simplified to Traditional characters in this codebase
|
||||
_SIMPLIFIED = "与专业东丢两严个丰临为举么义乐乔习书乱于云产亲仅从仓们价众优伙会伟传体余侧倾储儿关兴兽内册写冲决况准减几凭凯击划则刚创删别剥办务动区华协单占厂历压参双发变叙台号后吗听启员响嚣团国图场坏块声处备复够头夹妩姗娇娱婶学宝实宠审宽对导将尝尽层属币师带帧帮幂干并广庆库应开异弃张弹强归当录彦彻征径忆态总悦惯戏战户执扩扫抛抢护报拟拥拦择换据数断无旧时昵显晓暂术机杂权条来杰极构枪标树样档桦梦检欢残毕气汇汉汤没泄泼洁测浏润涩淀渊温游湾湿滞满滤灵灿炀点炼烦热爱爷状独猪环现瑶电画监盖盘着睁码础确离种积称稳竞笔签简类粤紧纠红约级纪纯纳线组细织终绍经结绘给络绝统继绪续维综缀缓编缩网罗羁职联聪脑脚脱腾舰艺节苍苏范荐获萝蔼虑补装见观规视览觉触计订认讨让训议讯记讲许论设访证识诉诊词译试诚话询该详语误说请读谁调谢谨谱贝负责贤败账货质费资赋赖赘轩转轮软轻载较辑输边达过运还这进远违连迟适选递逻遥邮邻采释里鉴针钉钟钥钮钱铁链销锁错锤键镜长闭问闲间闺闻闽阅队阳际陆陕险随隐难静韩页项顺须顾预领频题额风飞饭饰馆馈馏马驻驿验骤鱼鸡麦齐"
|
||||
_TRADITIONAL = "與專業東丟兩嚴個豐臨為舉麼義樂喬習書亂於雲產親僅從倉們價眾優夥會偉傳體餘側傾儲兒關興獸內冊寫沖決況準減幾憑凱擊劃則剛創刪別剝辦務動區華協單佔廠歷壓參雙發變敘臺號後嗎聽啟員響囂團國圖場壞塊聲處備復夠頭夾嫵姍嬌娛嬸學寶實寵審寬對導將嘗盡層屬幣師帶幀幫冪幹並廣慶庫應開異棄張彈強歸當錄彥徹徵徑憶態總悅慣戲戰戶執擴掃拋搶護報擬擁攔擇換據數斷無舊時暱顯曉暫術機雜權條來傑極構槍標樹樣檔樺夢檢歡殘畢氣匯漢湯沒洩潑潔測瀏潤澀澱淵溫遊灣溼滯滿濾靈燦煬點煉煩熱愛爺狀獨豬環現瑤電畫監蓋盤著睜碼礎確離種積稱穩競筆籤簡類粵緊糾紅約級紀純納線組細織終紹經結繪給絡絕統繼緒續維綜綴緩編縮網羅羈職聯聰腦腳脫騰艦藝節蒼蘇範薦獲蘿藹慮補裝見觀規視覽覺觸計訂認討讓訓議訊記講許論設訪證識訴診詞譯試誠話詢該詳語誤說請讀誰調謝謹譜貝負責賢敗帳貨質費資檔案影片圖片連結資料資訊支援排程執行帳號密碼憑證埠服務啟用管道終端機控制台"
|
||||
_CHAR_MAP = None
|
||||
|
||||
_PHRASE_MAP = {
|
||||
"默认": "預設",
|
||||
"内存": "記憶體",
|
||||
"配置": "設定",
|
||||
"进程": "處理程序",
|
||||
"目录": "目錄",
|
||||
"文件夹": "資料夾",
|
||||
"文件": "檔案",
|
||||
"视频": "影片",
|
||||
"图片": "圖片",
|
||||
"影象": "影像",
|
||||
"图像": "影像",
|
||||
"链接": "連結",
|
||||
"数据": "資料",
|
||||
"信息": "資訊",
|
||||
"支持": "支援",
|
||||
"定时": "排程",
|
||||
"运行": "執行",
|
||||
"账号": "帳號",
|
||||
"密码": "密碼",
|
||||
"凭据": "憑證",
|
||||
"端口": "埠",
|
||||
"服务": "服務",
|
||||
"激活": "啟用",
|
||||
"通道": "管道",
|
||||
"终端": "終端機",
|
||||
"主控台": "控制台",
|
||||
"创建": "建立",
|
||||
"计算机": "電腦",
|
||||
}
|
||||
|
||||
|
||||
def to_traditional(text):
|
||||
"""Convert Simplified Chinese text to Traditional Chinese.
|
||||
|
||||
Uses a two-tier approach:
|
||||
1. Phrase-level mapping for project-specific terms (e.g., "配置" → "設定")
|
||||
2. OpenCC library (opencc-python-reimplemented) if available for high-quality
|
||||
context-aware conversion, with fallback to built-in character mapping
|
||||
|
||||
This function is designed to work without external dependencies. If OpenCC
|
||||
is not installed, it falls back to a curated 450-character mapping table
|
||||
plus 30+ technical term mappings that cover common project vocabulary.
|
||||
|
||||
For production use with zh-Hant language, installing OpenCC is recommended:
|
||||
pip install opencc-python-reimplemented
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# Replace phrases first
|
||||
for s, t_phrase in _PHRASE_MAP.items():
|
||||
text = text.replace(s, t_phrase)
|
||||
|
||||
try:
|
||||
from opencc import OpenCC
|
||||
cc = OpenCC('s2twp')
|
||||
return cc.convert(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
global _CHAR_MAP
|
||||
if _CHAR_MAP is None:
|
||||
_CHAR_MAP = dict(zip(_SIMPLIFIED, _TRADITIONAL))
|
||||
|
||||
# Replace characters
|
||||
return "".join(_CHAR_MAP.get(c, c) for c in text)
|
||||
|
||||
|
||||
# Resolved language cache; None until first resolution.
|
||||
_resolved_lang = None
|
||||
|
||||
@@ -48,7 +131,10 @@ def _normalize(raw):
|
||||
value = str(raw).strip().lower().replace("_", "-")
|
||||
if value in ("auto", ""):
|
||||
return None
|
||||
# Chinese variants: zh, zh-cn, zh-hans, zh-hans-cn, zh-tw, zh-hk ...
|
||||
# Traditional Chinese variants: zh-tw, zh-hk, zh-hant, zh-hant-tw, zh-hant-hk...
|
||||
if value.startswith("zh-tw") or value.startswith("zh-hk") or "hant" in value:
|
||||
return ZH_HANT
|
||||
# General or Simplified Chinese variants: zh, zh-cn, zh-hans...
|
||||
if value.startswith("zh") or value.startswith("chinese"):
|
||||
return ZH
|
||||
if value.startswith("en") or value.startswith("english"):
|
||||
@@ -167,7 +253,7 @@ def get_language():
|
||||
|
||||
|
||||
def is_zh():
|
||||
return get_language() == ZH
|
||||
return get_language() in (ZH, ZH_HANT)
|
||||
|
||||
|
||||
def t(zh_text, en_text):
|
||||
@@ -176,4 +262,7 @@ def t(zh_text, en_text):
|
||||
Intended for one-off strings where a full message catalog is overkill:
|
||||
t("已中止", "Cancelled")
|
||||
"""
|
||||
return zh_text if get_language() == ZH else en_text
|
||||
lang = get_language()
|
||||
if lang == ZH_HANT:
|
||||
return to_traditional(zh_text)
|
||||
return zh_text if lang == ZH else en_text
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import io
|
||||
|
||||
|
||||
def _log_path():
|
||||
# Mirror config.get_data_root() without importing config (avoids a circular
|
||||
# import, since config imports this module). The desktop build sets
|
||||
# COW_DATA_DIR (e.g. ~/.cow); source deployments fall back to CWD.
|
||||
data_dir = os.environ.get("COW_DATA_DIR")
|
||||
if data_dir:
|
||||
data_dir = os.path.expanduser(data_dir)
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
return os.path.join(data_dir, "run.log")
|
||||
return "run.log"
|
||||
|
||||
|
||||
def _reset_logger(log):
|
||||
for handler in log.handlers:
|
||||
handler.close()
|
||||
@@ -20,15 +33,28 @@ def _reset_logger(log):
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
file_handle = logging.FileHandler("run.log", encoding="utf-8")
|
||||
file_handle.setFormatter(
|
||||
logging.Formatter(
|
||||
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
log.addHandler(file_handle)
|
||||
log.addHandler(console_handle)
|
||||
# File logging is best-effort: if the log path isn't writable (e.g. a
|
||||
# packaged app installed under Program Files run by a non-admin user, with
|
||||
# an unwritable CWD), fall back to console-only instead of crashing the
|
||||
# whole process at import time.
|
||||
try:
|
||||
file_handle = logging.FileHandler(_log_path(), encoding="utf-8")
|
||||
file_handle.setFormatter(
|
||||
logging.Formatter(
|
||||
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
log.addHandler(file_handle)
|
||||
except OSError:
|
||||
console_handle.handle(
|
||||
logging.LogRecord(
|
||||
"log", logging.WARNING, __file__, 0,
|
||||
"[log] file logging disabled (log path not writable): %s",
|
||||
(_log_path(),), None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _get_logger():
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import os
|
||||
import pathlib
|
||||
|
||||
from common.utils import expand_path
|
||||
from config import conf
|
||||
|
||||
|
||||
class TmpDir(object):
|
||||
"""A temporary directory that is deleted when the object is destroyed."""
|
||||
"""Temporary directory for transient artifacts (e.g. synthesized voice).
|
||||
|
||||
tmpFilePath = pathlib.Path("./tmp/")
|
||||
Resolves to ``<agent_workspace>/tmp`` (default ``~/cow/tmp``) so temp files
|
||||
land inside the agent workspace instead of a CWD-relative ``./tmp``, which
|
||||
is unreliable for the packaged desktop app where CWD is undefined.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
pathExists = os.path.exists(self.tmpFilePath)
|
||||
if not pathExists:
|
||||
os.makedirs(self.tmpFilePath)
|
||||
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
|
||||
self.tmpFilePath = os.path.join(ws_root, "tmp")
|
||||
os.makedirs(self.tmpFilePath, exist_ok=True)
|
||||
|
||||
def path(self):
|
||||
return str(self.tmpFilePath) + "/"
|
||||
|
||||
@@ -41,5 +41,8 @@
|
||||
"enable_thinking": false,
|
||||
"reasoning_effort": "high",
|
||||
"knowledge": true,
|
||||
"self_evolution_enabled": true
|
||||
"self_evolution_enabled": true,
|
||||
"mcp_tool_retrieval_enabled": false,
|
||||
"mcp_tool_retrieval_threshold": 20,
|
||||
"mcp_tool_retrieval_top_k": 10
|
||||
}
|
||||
|
||||
91
config.py
91
config.py
@@ -1,10 +1,12 @@
|
||||
# encoding:utf-8
|
||||
|
||||
import ast
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import sys
|
||||
|
||||
from common.log import logger
|
||||
from common import i18n
|
||||
@@ -27,7 +29,7 @@ available_setting = {
|
||||
"custom_api_key": "", # custom OpenAI-compatible provider api key (used when bot_type is "custom"); legacy single-provider field
|
||||
"custom_api_base": "", # custom OpenAI-compatible provider api base (used when bot_type is "custom"); legacy single-provider field
|
||||
# Multiple custom (OpenAI-compatible) providers. Activated via bot_type: "custom:<id>".
|
||||
# Each item: {"id": "3f2a9c1b", "name": "siliconflow", "api_key": "sk-...", "api_base": "https://api.siliconflow.cn/v1", "model": "deepseek-ai/DeepSeek-V3"}
|
||||
# Each item: {"id": "3f2a9c1b", "name": "my-provider", "api_key": "sk-...", "api_base": "https://api.example.com/v1", "model": "model-name"}
|
||||
"custom_providers": [],
|
||||
"proxy": "", # proxy used by openai
|
||||
# chatgpt model; when use_azure_chatgpt is true, this is the Azure model deployment name
|
||||
@@ -263,8 +265,17 @@ available_setting = {
|
||||
"self_evolution_enabled": False, # switch to enable/disable self-evolution
|
||||
"self_evolution_idle_minutes": 10, # idle time before a session is reviewed
|
||||
"self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger
|
||||
# Deep Dream: nightly memory distillation into MEMORY.md + dream diary.
|
||||
"deep_dream_enabled": True, # scheduled deep dream switch; manual /memory dream is unaffected
|
||||
"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)
|
||||
# On-demand MCP tool retrieval: when many MCP tools are connected, inject
|
||||
# only the most query-relevant ones instead of all of them. Built-in tools
|
||||
# are always injected in full; degrades to full injection when disabled,
|
||||
# below threshold, or when no embedding provider is available.
|
||||
"mcp_tool_retrieval_enabled": False, # switch for on-demand MCP tool retrieval
|
||||
"mcp_tool_retrieval_threshold": 20, # only retrieve when MCP tool count exceeds this
|
||||
"mcp_tool_retrieval_top_k": 10, # max relevant MCP tools injected per turn
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +317,12 @@ class Config(dict):
|
||||
self.user_datas[user] = {}
|
||||
return self.user_datas[user]
|
||||
|
||||
# SECURITY NOTE: pickle.load() can execute arbitrary code during
|
||||
# deserialization. This is safe as long as user_datas.pkl is trusted
|
||||
# (local app data directory, written only by this process). For a future
|
||||
# hardening pass, consider migrating to JSON (json.load/json.dump) if the
|
||||
# data structures are JSON-serializable, or adding an HMAC signature to
|
||||
# detect tampering of the pickle file.
|
||||
def load_user_datas(self):
|
||||
try:
|
||||
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "rb") as f:
|
||||
@@ -319,6 +336,8 @@ class Config(dict):
|
||||
|
||||
def save_user_datas(self):
|
||||
try:
|
||||
# SECURITY: pickle.dump output should only be loaded by this same
|
||||
# process. See note on load_user_datas() above.
|
||||
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "wb") as f:
|
||||
pickle.dump(self.user_datas, f)
|
||||
logger.info("[Config] User datas saved.")
|
||||
@@ -377,10 +396,16 @@ def load_config():
|
||||
logger.info(" \\____\\___/ \\_/\\_//_/ \\_\\__, |\\___|_| |_|\\__|")
|
||||
logger.info(" |___/ ")
|
||||
logger.info("")
|
||||
config_path = "./config.json"
|
||||
# User config lives in the data root: source deployments use CWD (./), while
|
||||
# the desktop build points COW_DATA_DIR at ~/.cow so config survives updates.
|
||||
config_path = os.path.join(get_data_root(), "config.json")
|
||||
if not os.path.exists(config_path):
|
||||
logger.info("config file not found, falling back to config-template.json")
|
||||
config_path = "./config-template.json"
|
||||
# Resolve the template via get_resource_root() so it works both from
|
||||
# source and from a frozen (PyInstaller) bundle, where the template
|
||||
# ships inside the bundle (sys._MEIPASS) and CWD may differ.
|
||||
template_path = os.path.join(get_resource_root(), "config-template.json")
|
||||
config_path = template_path if os.path.exists(template_path) else "./config-template.json"
|
||||
|
||||
config_str = read_file(config_path)
|
||||
logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str)))
|
||||
@@ -408,11 +433,19 @@ def load_config():
|
||||
if name in available_setting:
|
||||
logger.info("[INIT] override config by environ args: {}={}".format(name, value))
|
||||
try:
|
||||
config[name] = eval(value)
|
||||
# SECURITY: Use ast.literal_eval instead of eval().
|
||||
# ast.literal_eval only parses Python literals (strings, numbers,
|
||||
# tuples, lists, dicts, booleans, None) and CANNOT execute
|
||||
# arbitrary code, preventing environment-variable injection.
|
||||
config[name] = ast.literal_eval(value)
|
||||
except Exception:
|
||||
if value == "false":
|
||||
# literal_eval can raise ValueError/SyntaxError for non-literal
|
||||
# strings, but also TypeError/RecursionError on malformed input
|
||||
# (e.g. unhashable dict keys); catch broadly to avoid crashing
|
||||
# startup, and fall back to treating the value as a plain string.
|
||||
if value.lower() == "false":
|
||||
config[name] = False
|
||||
elif value == "true":
|
||||
elif value.lower() == "true":
|
||||
config[name] = True
|
||||
else:
|
||||
config[name] = value
|
||||
@@ -620,6 +653,34 @@ def get_root():
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_resource_root():
|
||||
"""Directory holding bundled read-only resources (e.g. config-template.json).
|
||||
|
||||
Under PyInstaller, data files live in sys._MEIPASS (the onedir _internal
|
||||
folder), which differs from get_root() — the latter is used for writable
|
||||
user data and should stay next to the executable, not inside the bundle.
|
||||
"""
|
||||
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
||||
return sys._MEIPASS
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_data_root():
|
||||
"""Directory for writable user data (config.json, user_datas.pkl, run.log).
|
||||
|
||||
The desktop build sets COW_DATA_DIR (e.g. ~/.cow) so data lives in the
|
||||
user's home rather than inside the read-only app bundle and survives app
|
||||
updates. When unset (source deployment), it falls back to get_root(), so
|
||||
existing behavior is unchanged.
|
||||
"""
|
||||
data_dir = os.environ.get("COW_DATA_DIR")
|
||||
if data_dir:
|
||||
data_dir = os.path.expanduser(data_dir)
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
return data_dir
|
||||
return get_root()
|
||||
|
||||
|
||||
def read_file(path):
|
||||
with open(path, mode="r", encoding="utf-8-sig") as f:
|
||||
return f.read()
|
||||
@@ -630,13 +691,29 @@ def conf():
|
||||
|
||||
|
||||
def get_appdata_dir():
|
||||
data_path = os.path.join(get_root(), conf().get("appdata_dir", ""))
|
||||
data_path = os.path.join(get_data_root(), conf().get("appdata_dir", ""))
|
||||
if not os.path.exists(data_path):
|
||||
logger.info("[INIT] data path not exists, create it: {}".format(data_path))
|
||||
os.makedirs(data_path)
|
||||
return data_path
|
||||
|
||||
|
||||
def get_weixin_credentials_path():
|
||||
"""Resolve the Weixin credentials (token) file path.
|
||||
|
||||
Honors an explicit ``weixin_credentials_path`` from config. Otherwise the
|
||||
packaged desktop build (COW_DATA_DIR set) keeps it under the data dir
|
||||
(~/.cow) so all user data stays together, while source deployments retain
|
||||
the legacy ~/.weixin_cow_credentials.json default unchanged.
|
||||
"""
|
||||
configured = conf().get("weixin_credentials_path")
|
||||
if configured:
|
||||
return os.path.expanduser(configured)
|
||||
if os.environ.get("COW_DATA_DIR"):
|
||||
return os.path.join(get_data_root(), "weixin_credentials.json")
|
||||
return os.path.expanduser("~/.weixin_cow_credentials.json")
|
||||
|
||||
|
||||
def subscribe_msg():
|
||||
trigger_prefix = conf().get("single_chat_prefix", [""])[0]
|
||||
msg = conf().get("subscribe_msg", "")
|
||||
|
||||
6
desktop/.gitignore
vendored
Normal file
6
desktop/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
81
desktop/README.md
Normal file
81
desktop/README.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# CowAgent Desktop
|
||||
|
||||
Cross-platform desktop client for CowAgent, built with Electron + React + TypeScript.
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- npm or yarn
|
||||
- Python 3.7+ (for the backend)
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
npm install
|
||||
```
|
||||
|
||||
### Run in Development
|
||||
|
||||
Start the renderer dev server and Electron together:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Or run them separately:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start Vite dev server
|
||||
npm run dev:renderer
|
||||
|
||||
# Terminal 2: Start Electron (after renderer is ready)
|
||||
npm run dev:main
|
||||
```
|
||||
|
||||
The app will automatically start the Python backend from the parent directory.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Build for current platform
|
||||
npm run dist
|
||||
|
||||
# Build for macOS only
|
||||
npm run dist:mac
|
||||
|
||||
# Build for Windows only
|
||||
npm run dist:win
|
||||
```
|
||||
|
||||
Build outputs are placed in the `release/` directory.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
desktop/
|
||||
├── src/
|
||||
│ ├── main/ # Electron main process
|
||||
│ │ ├── index.ts # Window management, IPC
|
||||
│ │ ├── python-manager.ts # Python backend lifecycle
|
||||
│ │ └── preload.ts # Context bridge for renderer
|
||||
│ └── renderer/ # React UI (Vite)
|
||||
│ └── src/
|
||||
│ ├── api/ # HTTP client for backend APIs
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── hooks/ # React hooks
|
||||
│ ├── pages/ # Page components
|
||||
│ └── types.ts # TypeScript types
|
||||
├── resources/ # App icons
|
||||
├── package.json # Dependencies and build config
|
||||
└── vite.config.ts # Vite config
|
||||
```
|
||||
|
||||
### How it Works
|
||||
|
||||
1. Electron main process starts and creates the app window
|
||||
2. It spawns the Python backend (`app.py`) as a child process
|
||||
3. The React UI communicates with the Python backend via HTTP APIs
|
||||
4. SSE (Server-Sent Events) is used for streaming chat responses and live logs
|
||||
79
desktop/build/build-backend.sh
Executable file
79
desktop/build/build-backend.sh
Executable file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the desktop backend into a self-contained onedir bundle via PyInstaller.
|
||||
# Run from anywhere; paths are resolved relative to the repo root.
|
||||
#
|
||||
# Usage:
|
||||
# bash desktop/build/build-backend.sh # build
|
||||
# PYTHON=python3.11 bash desktop/build/build-backend.sh # pick interpreter
|
||||
#
|
||||
# Output: desktop/build/dist/cowagent-backend/ (folder with the executable)
|
||||
set -euo pipefail
|
||||
|
||||
# --- resolve paths --------------------------------------------------------
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
BUILD_DIR="$SCRIPT_DIR"
|
||||
VENV_DIR="$BUILD_DIR/.venv-build"
|
||||
|
||||
# Prefer Python 3.11 when available: on 3.13+ web.py must be installed from a
|
||||
# GitHub git source (the PyPI build fails), which is flaky on some networks.
|
||||
# 3.11 installs web.py straight from PyPI and has the best PyInstaller support.
|
||||
if [ -z "${PYTHON:-}" ]; then
|
||||
for cand in \
|
||||
"/Library/Frameworks/Python.framework/Versions/3.11/bin/python3.11" \
|
||||
"python3.11" \
|
||||
"python3.12" \
|
||||
"python3"; do
|
||||
if command -v "$cand" >/dev/null 2>&1; then
|
||||
PYTHON="$cand"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
# Prefer Python 3.11: it installs web.py from PyPI (no GitHub clone) and avoids
|
||||
# 3.13's removed-cgi compatibility shims. Override with PYTHON=... if needed.
|
||||
pick_python() {
|
||||
if [ -n "${PYTHON:-}" ]; then echo "$PYTHON"; return; fi
|
||||
for c in python3.11 python3.12 python3.10 python3; do
|
||||
if command -v "$c" >/dev/null 2>&1; then echo "$c"; return; fi
|
||||
done
|
||||
echo python3
|
||||
}
|
||||
PYTHON="$(pick_python)"
|
||||
|
||||
echo "==> Repo root: $ROOT"
|
||||
echo "==> Using Python: $($PYTHON --version 2>&1) ($PYTHON)"
|
||||
|
||||
# --- isolated build venv --------------------------------------------------
|
||||
if [ ! -d "$VENV_DIR" ]; then
|
||||
echo "==> Creating build venv at $VENV_DIR"
|
||||
"$PYTHON" -m venv "$VENV_DIR"
|
||||
fi
|
||||
# shellcheck disable=SC1091
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
echo "==> Installing build dependencies"
|
||||
pip install -q --upgrade pip
|
||||
# Don't leave a half-populated venv behind if deps fail (e.g. flaky network):
|
||||
# the next run would otherwise reuse a broken venv.
|
||||
if ! pip install -q -r "$BUILD_DIR/requirements-desktop.txt"; then
|
||||
echo "!! Dependency install failed. Removing the build venv so a retry starts clean." >&2
|
||||
deactivate || true
|
||||
rm -rf "$VENV_DIR"
|
||||
exit 1
|
||||
fi
|
||||
pip install -q pyinstaller
|
||||
|
||||
# --- run pyinstaller from repo root so relative datas resolve -------------
|
||||
cd "$ROOT"
|
||||
echo "==> Running PyInstaller (onedir)"
|
||||
pyinstaller "$BUILD_DIR/cowagent-backend.spec" \
|
||||
--noconfirm \
|
||||
--distpath "$BUILD_DIR/dist" \
|
||||
--workpath "$BUILD_DIR/build-work"
|
||||
|
||||
echo ""
|
||||
echo "==> Done. Bundle at: $BUILD_DIR/dist/cowagent-backend/"
|
||||
du -sh "$BUILD_DIR/dist/cowagent-backend/" 2>/dev/null || true
|
||||
echo "==> Smoke test: COW_DESKTOP=1 \"$BUILD_DIR/dist/cowagent-backend/cowagent-backend\""
|
||||
194
desktop/build/cowagent-backend.spec
Normal file
194
desktop/build/cowagent-backend.spec
Normal file
@@ -0,0 +1,194 @@
|
||||
# -*- 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 + plugins. WebChannel -> ChatChannel imports
|
||||
# `from plugins import *`, and desktop mode loads plugins (in a background
|
||||
# thread) so command plugins like cow_cli/godcmd (/status, #help) work. Plugin
|
||||
# modules are imported dynamically by name in scan_plugins(), so list them
|
||||
# explicitly. The `cli` package is a cow_cli dependency (`from cli import ...`).
|
||||
hiddenimports += [
|
||||
'plugins',
|
||||
'plugins.event',
|
||||
'plugins.plugin',
|
||||
'plugins.plugin_manager',
|
||||
]
|
||||
hiddenimports += collect_submodules('plugins')
|
||||
|
||||
# `cli` powers cow_cli's slash commands (`cow skill install`, `cow status`, …).
|
||||
# Its command modules are imported lazily inside functions, so static analysis
|
||||
# misses them. collect_submodules('cli') alone proved unreliable (a build can
|
||||
# end up with `cli` but not `cli.commands`), so list the command modules
|
||||
# explicitly AND ship the package as data (see datas) as a belt-and-suspenders.
|
||||
hiddenimports += collect_submodules('cli')
|
||||
hiddenimports += [
|
||||
'cli',
|
||||
'cli.cli',
|
||||
'cli.utils',
|
||||
'cli.commands',
|
||||
'cli.commands.skill',
|
||||
'cli.commands.process',
|
||||
'cli.commands.context',
|
||||
'cli.commands.install',
|
||||
'cli.commands.knowledge',
|
||||
]
|
||||
|
||||
# Third-party SDKs that use lazy/conditional imports internally.
|
||||
hiddenimports += collect_submodules('dashscope')
|
||||
hiddenimports += [
|
||||
'tiktoken_ext',
|
||||
'tiktoken_ext.openai_public',
|
||||
]
|
||||
|
||||
# Document parsing libs. The read / web_fetch tools import these lazily inside
|
||||
# functions (e.g. `from pypdf import PdfReader`), so PyInstaller's static
|
||||
# analysis misses them and they'd be dropped from the bundle — leaving the
|
||||
# desktop client unable to read PDF/Word/Excel/PPT. List them explicitly.
|
||||
hiddenimports += [
|
||||
'pypdf',
|
||||
'docx', # python-docx
|
||||
'pptx', # python-pptx
|
||||
'openpyxl',
|
||||
]
|
||||
hiddenimports += collect_submodules('pypdf')
|
||||
hiddenimports += collect_submodules('docx')
|
||||
hiddenimports += collect_submodules('pptx')
|
||||
hiddenimports += collect_submodules('openpyxl')
|
||||
|
||||
# --- 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'),
|
||||
# PluginManager.scan_plugins() walks the on-disk ./plugins dir at runtime
|
||||
# (it doesn't rely solely on imports), so ship the package directory too.
|
||||
(rp('plugins'), 'plugins'),
|
||||
# Ship the `cli` package as loose files too: onedir adds _internal to
|
||||
# sys.path, so `import cli.commands.*` resolves even if PyInstaller's
|
||||
# submodule collection misses the lazily-imported command modules.
|
||||
(rp('cli'), 'cli'),
|
||||
# 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)
|
||||
|
||||
# python-docx / python-pptx bundle template files (default.docx / default.pptx,
|
||||
# content-type XML) inside their packages; they're loaded at import/parse time,
|
||||
# so ship them or document parsing fails in the frozen build.
|
||||
datas += collect_data_files('docx')
|
||||
datas += collect_data_files('pptx')
|
||||
|
||||
# --- 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',
|
||||
)
|
||||
19
desktop/build/entitlements.mac.plist
Normal file
19
desktop/build/entitlements.mac.plist
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Electron needs JIT and writable/executable memory for V8. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- PyInstaller backend loads many unsigned/third-party dylibs. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<!-- Allow spawning the bundled backend and other child processes. -->
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
147
desktop/build/notarize-dmg.sh
Executable file
147
desktop/build/notarize-dmg.sh
Executable file
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# STAGE 2 of the decoupled release pipeline: notarize signed dmgs locally.
|
||||
#
|
||||
# CI (stage 1) already produced code-signed, hardened-runtime dmgs and mirrored
|
||||
# them to R2 as unpublished. Apple's notary service keeps this large PyInstaller
|
||||
# bundle "In Progress" for hours, so we notarize here — off the CI clock — and
|
||||
# staple the ticket straight onto the dmg (users download it ready-to-run).
|
||||
#
|
||||
# What it does for each dmg passed on the command line:
|
||||
# 1. submit the dmg to the notary service ONCE (--no-wait) and remember the id,
|
||||
# 2. poll that SAME id until Accepted/Invalid; network errors are ignored and
|
||||
# retried, and it NEVER resubmits (avoids piling up duplicate submissions),
|
||||
# 3. staple the ticket onto the dmg,
|
||||
# 4. (optional) re-upload the stapled dmg to R2, overwriting the unpublished
|
||||
# copy, so the CDN serves the notarized bytes.
|
||||
#
|
||||
# Auth: uses a stored keychain profile (default: cow-notary). Create it once via
|
||||
# xcrun notarytool store-credentials cow-notary \
|
||||
# --apple-id <id> --team-id <team> --password <app-specific-password>
|
||||
#
|
||||
# Usage:
|
||||
# # notarize + staple only (no upload):
|
||||
# desktop/build/notarize-dmg.sh path/to/CowAgent-1.2.3-arm64.dmg [more.dmg ...]
|
||||
#
|
||||
# # notarize + staple + re-upload to R2 (needs wrangler + Cloudflare creds):
|
||||
# VER=1.2.3 UPLOAD=1 desktop/build/notarize-dmg.sh *.dmg
|
||||
#
|
||||
# Env:
|
||||
# PROFILE keychain profile name (default: cow-notary)
|
||||
# UPLOAD set to 1 to re-upload stapled dmgs to R2
|
||||
# VER version string for the R2 key desktop/v${VER}/<file> (required if UPLOAD=1)
|
||||
# R2_BUCKET R2 bucket (default: cow-skills)
|
||||
# POLL_SECONDS status poll interval (default: 60)
|
||||
# MAX_WAIT_MINUTES give up polling after this long (default: 720 = 12h)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
PROFILE="${PROFILE:-cow-notary}"
|
||||
R2_BUCKET="${R2_BUCKET:-cow-skills}"
|
||||
POLL_SECONDS="${POLL_SECONDS:-60}"
|
||||
MAX_WAIT_MINUTES="${MAX_WAIT_MINUTES:-720}"
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
echo "usage: $0 <dmg> [dmg ...]" >&2
|
||||
echo " set UPLOAD=1 and VER=<version> to also re-upload stapled dmgs to R2" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "${UPLOAD:-0}" = "1" ] && [ -z "${VER:-}" ]; then
|
||||
echo "error: UPLOAD=1 requires VER=<version> (used for the R2 key)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log() { echo "[notarize-dmg] $*"; }
|
||||
|
||||
notarize_one() {
|
||||
local dmg="$1"
|
||||
if [ ! -f "$dmg" ]; then
|
||||
log "SKIP: not a file: $dmg"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# If it's already stapled (e.g. re-run), skip straight to (optional) upload.
|
||||
if xcrun stapler validate "$dmg" >/dev/null 2>&1; then
|
||||
log "$dmg already stapled — skipping notarization."
|
||||
else
|
||||
log "submitting $dmg (no-wait)..."
|
||||
local submit_out submission_id
|
||||
submit_out="$(xcrun notarytool submit "$dmg" \
|
||||
--keychain-profile "$PROFILE" --no-wait --output-format json)"
|
||||
submission_id="$(echo "$submit_out" | /usr/bin/plutil -extract id raw - 2>/dev/null || true)"
|
||||
if [ -z "$submission_id" ] || [ "$submission_id" = "null" ]; then
|
||||
# Fallback parse without plutil (json is single-line).
|
||||
submission_id="$(echo "$submit_out" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')"
|
||||
fi
|
||||
if [ -z "$submission_id" ]; then
|
||||
log "ERROR: could not parse submission id from:"
|
||||
echo "$submit_out" >&2
|
||||
return 1
|
||||
fi
|
||||
log "submission id: $submission_id (polling same id, never resubmitting)"
|
||||
|
||||
local deadline status ts
|
||||
deadline=$(( $(date +%s) + MAX_WAIT_MINUTES * 60 ))
|
||||
while :; do
|
||||
status=""
|
||||
status="$(xcrun notarytool info "$submission_id" \
|
||||
--keychain-profile "$PROFILE" --output-format json 2>/dev/null \
|
||||
| sed -n 's/.*"status":"\([^"]*\)".*/\1/p' || true)"
|
||||
ts="$(date +%H:%M:%S)"
|
||||
log "[$ts] status: ${status:-<query failed, retrying>}"
|
||||
|
||||
case "$status" in
|
||||
Accepted) break ;;
|
||||
Invalid|Rejected)
|
||||
log "notarization $status — fetching log:"
|
||||
xcrun notarytool log "$submission_id" --keychain-profile "$PROFILE" || true
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
log "ERROR: not finished after ${MAX_WAIT_MINUTES} min (id: $submission_id)."
|
||||
log "NOT resubmitting. Check later: xcrun notarytool info $submission_id --keychain-profile $PROFILE"
|
||||
return 1
|
||||
fi
|
||||
sleep "$POLL_SECONDS"
|
||||
done
|
||||
|
||||
log "Accepted; stapling ticket to $dmg"
|
||||
local staple_try=1
|
||||
until xcrun stapler staple "$dmg"; do
|
||||
if [ "$staple_try" -ge 3 ]; then
|
||||
log "ERROR: stapling failed after 3 attempts"
|
||||
return 1
|
||||
fi
|
||||
log "staple failed, retrying in 15s..."
|
||||
sleep 15
|
||||
staple_try=$((staple_try + 1))
|
||||
done
|
||||
xcrun stapler validate "$dmg"
|
||||
log "$dmg notarized + stapled."
|
||||
fi
|
||||
|
||||
if [ "${UPLOAD:-0}" = "1" ]; then
|
||||
local base key
|
||||
base="$(basename "$dmg")"
|
||||
key="desktop/v${VER}/${base}"
|
||||
log "re-uploading stapled dmg -> r2://${R2_BUCKET}/${key}"
|
||||
npx --yes wrangler@latest r2 object put "${R2_BUCKET}/${key}" \
|
||||
--file "$dmg" --remote
|
||||
log "uploaded $base"
|
||||
fi
|
||||
}
|
||||
|
||||
rc=0
|
||||
for dmg in "$@"; do
|
||||
echo "======================================================================"
|
||||
notarize_one "$dmg" || rc=1
|
||||
done
|
||||
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
log "one or more dmgs failed — see output above."
|
||||
exit 1
|
||||
fi
|
||||
log "all done."
|
||||
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
|
||||
83
desktop/electron-builder.js
Normal file
83
desktop/electron-builder.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Dynamic electron-builder config.
|
||||
*
|
||||
* We keep the base config in package.json's "build" field and extend it here
|
||||
* only to populate `mac.binaries` — the list of extra Mach-O files that must
|
||||
* be signed with hardened runtime + entitlements.
|
||||
*
|
||||
* Why this is needed:
|
||||
* The Python backend is a PyInstaller onedir bundle shipped via extraResources
|
||||
* into Contents/Resources/backend/. electron-builder only hands the top-level
|
||||
* `.app` to codesign, which does NOT deep-sign the ~180 nested .so/.dylib
|
||||
* files under Resources/. Left unsigned (or without hardened runtime), Apple
|
||||
* notarization rejects the whole app.
|
||||
*
|
||||
* `mac.binaries` is the officially supported way to sign extra binaries: they
|
||||
* are signed inside electron-builder's own signing pass, AFTER it has created
|
||||
* the temporary keychain and imported the Developer ID cert (from CSC_LINK).
|
||||
* A previous afterPack approach failed because afterPack runs BEFORE that
|
||||
* keychain exists, so `codesign` couldn't find the identity.
|
||||
*
|
||||
* Paths are resolved relative to the `.app` at signing time. We enumerate the
|
||||
* pre-build backend source dir (build/dist/cowagent-backend, produced by
|
||||
* PyInstaller before packaging) — its layout mirrors the in-app copy — and map
|
||||
* each Mach-O to its in-app relative path.
|
||||
*/
|
||||
const { execFileSync } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const config = require('./package.json').build
|
||||
|
||||
// PyInstaller output that gets copied into the app at
|
||||
// Contents/Resources/backend/cowagent-backend (see extraResources).
|
||||
const backendSrc = path.join(__dirname, 'build', 'dist', 'cowagent-backend')
|
||||
const inAppPrefix = path.join('Contents', 'Resources', 'backend', 'cowagent-backend')
|
||||
|
||||
function isMachO(file) {
|
||||
try {
|
||||
return execFileSync('file', ['-b', file], { encoding: 'utf8' }).includes('Mach-O')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function collectBackendBinaries() {
|
||||
if (!fs.existsSync(backendSrc)) {
|
||||
console.warn(`[electron-builder.js] backend not found at ${backendSrc}; mac.binaries left empty`)
|
||||
return []
|
||||
}
|
||||
const rels = []
|
||||
const walk = (dir) => {
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const full = path.join(dir, name)
|
||||
const st = fs.lstatSync(full)
|
||||
if (st.isSymbolicLink()) continue
|
||||
if (st.isDirectory()) {
|
||||
walk(full)
|
||||
continue
|
||||
}
|
||||
if (isMachO(full)) {
|
||||
// Map source path -> in-app relative path (resolved against the .app).
|
||||
const rel = path.relative(backendSrc, full)
|
||||
rels.push(path.join(inAppPrefix, rel))
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(backendSrc)
|
||||
return rels
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const binaries = collectBackendBinaries()
|
||||
console.log(`[electron-builder.js] injecting ${binaries.length} backend binaries into mac.binaries`)
|
||||
// Sign the backend binaries here, but do NOT notarize in CI: Apple's notary
|
||||
// service routinely keeps this large PyInstaller bundle "In Progress" for
|
||||
// hours, which no CI job can afford to block on. Notarization is decoupled
|
||||
// into a manual local step (build/notarize-dmg.sh) run after the CI produces
|
||||
// the signed dmg. The dmg is code-signed and hardened-runtime enabled here,
|
||||
// so it only needs the notarization ticket stapled afterwards.
|
||||
config.mac = { ...config.mac, binaries, notarize: false }
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
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
117
desktop/package.json
Normal file
117
desktop/package.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"name": "cowagent-desktop",
|
||||
"version": "2.1.3",
|
||||
"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",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false,
|
||||
"entitlements": "build/entitlements.mac.plist",
|
||||
"entitlementsInherit": "build/entitlements.mac.plist",
|
||||
"notarize": false,
|
||||
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||
"target": [
|
||||
{
|
||||
"target": "dmg",
|
||||
"arch": [
|
||||
"arm64",
|
||||
"x64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "zip",
|
||||
"arch": [
|
||||
"arm64",
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"icon": "resources/icon.ico",
|
||||
"artifactName": "${productName}-Setup-${version}-${arch}.${ext}",
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
},
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://cowagent.ai/update/"
|
||||
}
|
||||
}
|
||||
}
|
||||
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 |
354
desktop/src/main/index.ts
Normal file
354
desktop/src/main/index.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
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, setUpdateLanguage } 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)
|
||||
}
|
||||
|
||||
// Surface renderer-side console output and load failures to the main-process
|
||||
// stdout. Without this, "stuck on initializing" hangs are invisible from the
|
||||
// terminal because all renderer logs stay in the (closed) devtools.
|
||||
mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => {
|
||||
console.log(`[renderer:${level}] ${message} (${sourceId}:${line})`)
|
||||
})
|
||||
mainWindow.webContents.on('did-fail-load', (_e, code, desc, url) => {
|
||||
console.error(`[renderer] did-fail-load ${code} ${desc} ${url}`)
|
||||
})
|
||||
|
||||
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) => {
|
||||
console.log(`[backend] ready on port ${port}`)
|
||||
mainWindow?.webContents.send('backend-status', { status: 'ready', port })
|
||||
})
|
||||
|
||||
pythonBackend.on('error', (error: string) => {
|
||||
// Mirror to the main-process stdout too: otherwise backend startup errors
|
||||
// are only visible in the renderer devtools, making `npm run dev` hangs
|
||||
// impossible to diagnose from the terminal.
|
||||
console.error(`[backend] error: ${error}`)
|
||||
mainWindow?.webContents.send('backend-status', { status: 'error', error })
|
||||
})
|
||||
|
||||
pythonBackend.on('log', (line: string) => {
|
||||
console.log(`[backend] ${line}`)
|
||||
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]
|
||||
})
|
||||
|
||||
// Open a local file with the OS default app; falls back to revealing it in
|
||||
// the file manager when no handler exists. Returns '' on success.
|
||||
ipcMain.handle('open-path', async (_event, targetPath: string) => {
|
||||
if (!targetPath) return 'empty path'
|
||||
const err = await shell.openPath(targetPath)
|
||||
if (err) shell.showItemInFolder(targetPath)
|
||||
return err
|
||||
})
|
||||
|
||||
// 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)
|
||||
|
||||
// Current app version, shown in the NavRail footer.
|
||||
ipcMain.handle('get-app-version', () => app.getVersion())
|
||||
|
||||
// Auto-update controls (renderer-driven: check, then opt-in download/install).
|
||||
// The renderer passes its current UI language so downloads can be routed to
|
||||
// the China CDN mirror (zh) or R2 (others).
|
||||
ipcMain.handle('update-check', (_event, lang?: string) => {
|
||||
setUpdateLanguage(lang)
|
||||
checkForUpdates()
|
||||
})
|
||||
ipcMain.handle('update-download', (_event, lang?: string) => {
|
||||
setUpdateLanguage(lang)
|
||||
startDownload()
|
||||
})
|
||||
ipcMain.handle('update-install', () => {
|
||||
// Let the window actually close so the app can fully quit — otherwise the
|
||||
// close-to-tray handler preventDefault()s it, the process stays alive, and
|
||||
// Squirrel.Mac can't swap the app bundle (the update silently no-ops and
|
||||
// relaunching still shows the old version).
|
||||
isQuitting = true
|
||||
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: a first silent check a few seconds after launch (so it
|
||||
// doesn't compete with backend startup), then poll every 4 hours so a
|
||||
// long-running window still surfaces new releases. autoDownload is off, so a
|
||||
// found update only lights the badge + opens the panel for the user to opt in.
|
||||
initUpdater(() => mainWindow)
|
||||
setTimeout(() => checkForUpdates(), 5000)
|
||||
const UPDATE_POLL_MS = 4 * 60 * 60 * 1000
|
||||
setInterval(() => checkForUpdates(), UPDATE_POLL_MS)
|
||||
|
||||
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))
|
||||
}
|
||||
67
desktop/src/main/preload.ts
Normal file
67
desktop/src/main/preload.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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),
|
||||
openPath: (targetPath: string) => ipcRenderer.invoke('open-path', targetPath) as Promise<string>,
|
||||
|
||||
// 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)
|
||||
},
|
||||
|
||||
// Current app version (e.g. "0.0.5"), shown in the NavRail footer.
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
|
||||
// Auto-update: trigger checks/download/install and subscribe to status. The
|
||||
// optional lang routes installer downloads to the China CDN mirror (zh) or R2.
|
||||
checkForUpdate: (lang?: string) => ipcRenderer.invoke('update-check', lang),
|
||||
downloadUpdate: (lang?: string) => ipcRenderer.invoke('update-download', lang),
|
||||
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 ''
|
||||
}
|
||||
})(),
|
||||
})
|
||||
378
desktop/src/main/python-manager.ts
Normal file
378
desktop/src/main/python-manager.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
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'
|
||||
import net from 'net'
|
||||
|
||||
// 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')
|
||||
|
||||
// Fixed port for the desktop backend. Deliberately not 9899 (the web console's
|
||||
// default) so a source-run `python app.py` never collides with the packaged
|
||||
// app. This is a SINGLE SOURCE OF TRUTH shared with the renderer (see
|
||||
// useBackend.ts BACKEND_PORT): the backend is always told to bind exactly here
|
||||
// via COW_WEB_PORT, and the renderer always talks to exactly here. We do NOT
|
||||
// fall back to an OS-random port, because the renderer could never guess it —
|
||||
// instead we proactively free this port before launch (see freePort()).
|
||||
export const DESKTOP_BACKEND_PORT = 9876
|
||||
|
||||
export class PythonBackend extends EventEmitter {
|
||||
private process: ChildProcess | null = null
|
||||
private backendPath: string
|
||||
private port: number = DESKTOP_BACKEND_PORT
|
||||
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'
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an explicit `web_port` from config.json, if the user pinned one. The
|
||||
* packaged build keeps config in COW_DATA_DIR (~/.cow); dev reads it from the
|
||||
* repo path. Returns null when unset, so the caller can auto-pick a free port
|
||||
* instead of fighting over a fixed one.
|
||||
*/
|
||||
private readConfiguredPort(dataDir: string): number | null {
|
||||
try {
|
||||
const configPath = path.join(dataDir, 'config.json')
|
||||
if (fs.existsSync(configPath)) {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
const p = Number(config.web_port)
|
||||
if (Number.isInteger(p) && p > 0 && p < 65536) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore — fall through to auto-selection
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the port to bind. The whole point is determinism: the renderer must
|
||||
* be able to reach the backend WITHOUT guessing, so we use exactly one fixed
|
||||
* port (DESKTOP_BACKEND_PORT) unless the user explicitly pinned a web_port.
|
||||
* We never auto-roll to a random port — instead start() proactively frees the
|
||||
* fixed port. The returned value is the single source of truth handed to both
|
||||
* the backend (COW_WEB_PORT) and the renderer (getBackendPort IPC).
|
||||
*/
|
||||
private resolvePort(dataDir: string): number {
|
||||
const pinned = this.readConfiguredPort(dataDir)
|
||||
return pinned !== null ? pinned : DESKTOP_BACKEND_PORT
|
||||
}
|
||||
|
||||
/** True if we can bind 127.0.0.1:port right now (i.e. it's free). */
|
||||
private isPortFree(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const tester = net
|
||||
.createServer()
|
||||
.once('error', () => resolve(false))
|
||||
.once('listening', () => {
|
||||
tester.close(() => resolve(true))
|
||||
})
|
||||
.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Make sure our fixed port is usable before launch by killing whatever is
|
||||
* holding it (almost always a stale backend from a previous run that didn't
|
||||
* shut down cleanly). We only ever target a process actually listening on
|
||||
* 127.0.0.1:<port>, so we won't touch unrelated apps. Best-effort: if we
|
||||
* can't free it we still try to bind and let the backend surface EADDRINUSE.
|
||||
*/
|
||||
private async freePort(port: number): Promise<void> {
|
||||
if (await this.isPortFree(port)) {
|
||||
return
|
||||
}
|
||||
this.emit('log', `Port ${port} is busy — clearing stale process before launch`)
|
||||
const pids = await this.findListenerPids(port)
|
||||
for (const pid of pids) {
|
||||
// Never signal ourselves (Electron could, in theory, be the listener).
|
||||
if (pid === process.pid) continue
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM')
|
||||
} catch {
|
||||
// already gone / no permission — ignore
|
||||
}
|
||||
}
|
||||
// Give the OS a beat to release the socket, then force-kill leftovers.
|
||||
await new Promise((r) => setTimeout(r, 600))
|
||||
if (!(await this.isPortFree(port))) {
|
||||
for (const pid of await this.findListenerPids(port)) {
|
||||
if (pid === process.pid) continue
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
}
|
||||
}
|
||||
|
||||
/** PIDs listening on 127.0.0.1:<port>, via lsof (POSIX) / netstat (Windows). */
|
||||
private findListenerPids(port: number): Promise<number[]> {
|
||||
return new Promise((resolve) => {
|
||||
const isWin = process.platform === 'win32'
|
||||
const cmd = isWin ? 'netstat' : 'lsof'
|
||||
const args = isWin
|
||||
? ['-ano', '-p', 'tcp']
|
||||
: ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t']
|
||||
let out = ''
|
||||
try {
|
||||
const child = spawn(cmd, args)
|
||||
child.stdout?.on('data', (d: Buffer) => (out += d.toString()))
|
||||
child.on('error', () => resolve([]))
|
||||
child.on('close', () => {
|
||||
const pids = new Set<number>()
|
||||
if (isWin) {
|
||||
// Match lines like: TCP 127.0.0.1:9876 ... LISTENING 12345
|
||||
for (const line of out.split('\n')) {
|
||||
if (!/LISTENING/i.test(line)) continue
|
||||
if (!new RegExp(`[:.]${port}\\b`).test(line)) continue
|
||||
const pid = Number(line.trim().split(/\s+/).pop())
|
||||
if (Number.isInteger(pid) && pid > 0) pids.add(pid)
|
||||
}
|
||||
} else {
|
||||
for (const tok of out.split(/\s+/)) {
|
||||
const pid = Number(tok)
|
||||
if (Number.isInteger(pid) && pid > 0) pids.add(pid)
|
||||
}
|
||||
}
|
||||
resolve([...pids])
|
||||
})
|
||||
} catch {
|
||||
resolve([])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Always launch our OWN backend (re-entrancy is guarded above by the status
|
||||
// check, so we never double-spawn for this instance). We don't reuse
|
||||
// whatever happens to be on the port: that's how the app previously attached
|
||||
// to a source-run web console and read the wrong config. The port is fixed
|
||||
// (or the user's pinned web_port) — never random — so the renderer always
|
||||
// knows it. We then proactively free that port (kill stale listeners)
|
||||
// before spawning, so a leftover process from a previous run can't block us.
|
||||
this.port = this.resolvePort(dataDir)
|
||||
await this.freePort(this.port)
|
||||
|
||||
let command: string
|
||||
let args: string[]
|
||||
let cwd: string
|
||||
|
||||
if (bundled) {
|
||||
command = bundled
|
||||
args = []
|
||||
// Run from the writable data dir (~/.cow), NOT the install dir. When the
|
||||
// app is installed under Program Files, a non-admin user has no write
|
||||
// permission to the executable's folder, so any relative-path write
|
||||
// during startup would crash the backend (works only as admin). The
|
||||
// bundle reads its read-only resources via sys._MEIPASS, so cwd is free
|
||||
// to point elsewhere.
|
||||
try {
|
||||
fs.mkdirSync(COW_DATA_DIR, { recursive: true })
|
||||
} catch {
|
||||
// ignore — get_data_root() also ensures the dir on the Python side
|
||||
}
|
||||
cwd = COW_DATA_DIR
|
||||
this.emit('log', `Starting bundled backend: ${bundled} (cwd=${cwd})`)
|
||||
} 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',
|
||||
// The shell owns the port: tell the backend to bind exactly here so the
|
||||
// two sides can never disagree (and we avoid the 9899 web-console clash).
|
||||
COW_WEB_PORT: String(this.port),
|
||||
...(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) => {
|
||||
// If the backend dies before it ever became ready, surface an error now
|
||||
// instead of letting waitForReady spin for the full timeout. A clean exit
|
||||
// (code 0/null, e.g. our own stop()) just marks stopped.
|
||||
const wasReady = this.status === 'ready'
|
||||
this.status = 'stopped'
|
||||
this.emit('log', `Python process exited with code ${code}`)
|
||||
if (!wasReady && code !== 0 && code !== null) {
|
||||
this.status = 'error'
|
||||
this.emit('error', `Backend exited during startup (code ${code})`)
|
||||
}
|
||||
})
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
this.status = 'error'
|
||||
this.emit('error', `Failed to start Python: ${err.message}`)
|
||||
})
|
||||
|
||||
await this.waitForReady()
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
// Backend already settled: ready (done), or stopped/errored by the exit
|
||||
// handler (don't keep polling a dead process — the error was emitted).
|
||||
if (this.status === 'ready' || this.status === 'stopped' || this.status === 'error') {
|
||||
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
|
||||
}
|
||||
245
desktop/src/main/updater.ts
Normal file
245
desktop/src/main/updater.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
// electron-updater is CommonJS: its members live on module.exports, with no
|
||||
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
||||
// 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
|
||||
|
||||
// The update feed. Both entries hit the same Pages Function
|
||||
// (https://cowagent.ai/update/); the ?lang=zh query tells it to 302 installer
|
||||
// downloads to the China CDN mirror instead of R2. The feed metadata is
|
||||
// identical either way, so we can freely switch the feed URL between attempts
|
||||
// to fall back from one download origin to the other.
|
||||
const FEED_BASE = 'https://cowagent.ai/update/'
|
||||
const feedUrlFor = (china: boolean) => (china ? `${FEED_BASE}?lang=zh` : FEED_BASE)
|
||||
|
||||
// Which origin the current session prefers, derived from the app UI language
|
||||
// (zh -> China mirror). Downloads that fail on the preferred origin retry once
|
||||
// on the other one before surfacing an error.
|
||||
let preferChina = false
|
||||
// Guard so a single download only ever falls back once (avoids ping-pong).
|
||||
let downloadFellBack = false
|
||||
|
||||
function applyFeedUrl(): void {
|
||||
const url = feedUrlFor(preferChina)
|
||||
try {
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url })
|
||||
log(`feed url set: ${url} (preferChina=${preferChina})`)
|
||||
} catch (err) {
|
||||
log(`feed url set failed: ${(err as Error)?.message || String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Called from the check/download IPC with the renderer's current UI language.
|
||||
export function setUpdateLanguage(lang: string | undefined): void {
|
||||
const china = (lang || '').toLowerCase().startsWith('zh')
|
||||
if (china !== preferChina) {
|
||||
preferChina = china
|
||||
if (app.isPackaged) applyFeedUrl()
|
||||
}
|
||||
}
|
||||
|
||||
// Persist update logs to a file so a user hitting a silent "spinner never
|
||||
// resolves" can just send us userData/logs/updater.log. We can't rely on a
|
||||
// logging dep, so this is a tiny append-only writer, plus console for the
|
||||
// in-app log view / terminal.
|
||||
let logFile: string | null = null
|
||||
|
||||
function initLogFile() {
|
||||
try {
|
||||
const dir = path.join(app.getPath('userData'), 'logs')
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
logFile = path.join(dir, 'updater.log')
|
||||
} catch {
|
||||
logFile = null
|
||||
}
|
||||
}
|
||||
|
||||
function log(...parts: unknown[]) {
|
||||
const line = `[${new Date().toISOString()}] [updater] ${parts
|
||||
.map((p) => (typeof p === 'string' ? p : safeStringify(p)))
|
||||
.join(' ')}`
|
||||
// Console: shows up in the terminal (dev) and the packaged app's stdout.
|
||||
console.log(line)
|
||||
if (logFile) {
|
||||
try {
|
||||
fs.appendFileSync(logFile, line + '\n')
|
||||
} catch {
|
||||
// ignore disk errors — logging must never break the updater
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function safeStringify(v: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(v)
|
||||
} catch {
|
||||
return String(v)
|
||||
}
|
||||
}
|
||||
|
||||
function send(status: UpdateStatus) {
|
||||
getWindow()?.webContents.send('update-status', status)
|
||||
}
|
||||
|
||||
export function initUpdater(windowGetter: () => BrowserWindow | null): void {
|
||||
getWindow = windowGetter
|
||||
initLogFile()
|
||||
|
||||
log(`init: appVersion=${app.getVersion()} packaged=${app.isPackaged} logFile=${logFile ?? '<none>'}`)
|
||||
|
||||
// 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) {
|
||||
log('not packaged — updater wiring skipped')
|
||||
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
|
||||
// The desktop channel ships pre-release-tagged builds (e.g. 0.0.8-test), so a
|
||||
// current version like 0.0.7-test must be allowed to compare against, and be
|
||||
// offered, other pre-release versions. Without this electron-updater's semver
|
||||
// compare can silently skip pre-releases and neither "available" nor
|
||||
// "not-available" fires — the UI just spins forever.
|
||||
autoUpdater.allowPrerelease = true
|
||||
autoUpdater.allowDowngrade = false
|
||||
// Point at the preferred origin up front (defaults to R2; switched to the CN
|
||||
// mirror once the renderer reports a zh UI language via setUpdateLanguage).
|
||||
applyFeedUrl()
|
||||
// Route electron-updater's own internal logging to our file too, so we
|
||||
// capture the feed URL, parsed versions and any stack traces it logs.
|
||||
autoUpdater.logger = {
|
||||
info: (m: unknown) => log('eu-info:', m),
|
||||
warn: (m: unknown) => log('eu-warn:', m),
|
||||
error: (m: unknown) => log('eu-error:', m),
|
||||
debug: (m: unknown) => log('eu-debug:', m),
|
||||
} as unknown as typeof autoUpdater.logger
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
log(`checking-for-update: current=${app.getVersion()}`)
|
||||
send({ state: 'checking' })
|
||||
})
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
log(`update-available: current=${app.getVersion()} remote=${info.version} -> update needed`)
|
||||
send({
|
||||
state: 'available',
|
||||
version: info.version,
|
||||
notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined,
|
||||
})
|
||||
})
|
||||
autoUpdater.on('update-not-available', (info) => {
|
||||
log(`update-not-available: current=${app.getVersion()} remote=${info?.version ?? '<unknown>'} -> up to date`)
|
||||
send({ state: 'not-available' })
|
||||
})
|
||||
autoUpdater.on('download-progress', (p) => {
|
||||
log(`download-progress: ${Math.round(p.percent)}% (${p.transferred}/${p.total} bytes, ${Math.round(p.bytesPerSecond / 1024)} KB/s)`)
|
||||
send({ state: 'downloading', percent: Math.round(p.percent) })
|
||||
})
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
log(`update-downloaded: version=${info.version} -> ready to install`)
|
||||
send({ state: 'downloaded', version: info.version })
|
||||
})
|
||||
autoUpdater.on('error', (err) => {
|
||||
const message = err == null ? 'unknown' : err.message || String(err)
|
||||
log(`error: ${message}`, err instanceof Error && err.stack ? err.stack : '')
|
||||
send({ state: 'error', message })
|
||||
})
|
||||
}
|
||||
|
||||
// Silent check shortly after launch. When not packaged there's no update feed,
|
||||
// but a manual click should still get visible feedback instead of looking dead:
|
||||
// reply "not-available" so the menu can show "up to date".
|
||||
export function checkForUpdates(): void {
|
||||
if (!app.isPackaged) {
|
||||
// Dev-only UI harness: set COW_MOCK_UPDATE=1 to simulate an available
|
||||
// update so the update panel/menu interactions can be exercised in
|
||||
// `npm run dev` (where there's no real feed). Never runs in a packaged app.
|
||||
if (process.env.COW_MOCK_UPDATE) {
|
||||
const version = process.env.COW_MOCK_UPDATE_VERSION || '9.9.9'
|
||||
log(`checkForUpdates: not packaged, MOCK available version=${version}`)
|
||||
send({ state: 'available', version })
|
||||
return
|
||||
}
|
||||
log('checkForUpdates: not packaged, replying not-available')
|
||||
send({ state: 'not-available' })
|
||||
return
|
||||
}
|
||||
log(`checkForUpdates: requesting feed, current=${app.getVersion()}`)
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
const message = err?.message || String(err)
|
||||
log(`checkForUpdates: request failed: ${message}`, err instanceof Error && err.stack ? err.stack : '')
|
||||
send({ state: 'error', message })
|
||||
})
|
||||
}
|
||||
|
||||
export function startDownload(): void {
|
||||
if (!app.isPackaged) return
|
||||
downloadFellBack = false
|
||||
log(`startDownload: user requested download (preferChina=${preferChina})`)
|
||||
attemptDownload()
|
||||
}
|
||||
|
||||
// Download from the current origin; on failure, switch to the OTHER origin once
|
||||
// and retry. This is the client-side "mirrors back each other" fallback: R2 and
|
||||
// the China CDN hold identical bytes, so a slow/blocked origin can be swapped
|
||||
// transparently without the user noticing.
|
||||
function attemptDownload(): void {
|
||||
autoUpdater.downloadUpdate().catch((err) => {
|
||||
const message = err?.message || String(err)
|
||||
log(`startDownload: failed on ${preferChina ? 'CN' : 'R2'}: ${message}`, err instanceof Error && err.stack ? err.stack : '')
|
||||
if (!downloadFellBack) {
|
||||
downloadFellBack = true
|
||||
preferChina = !preferChina
|
||||
applyFeedUrl()
|
||||
log(`startDownload: retrying on ${preferChina ? 'CN' : 'R2'} mirror`)
|
||||
// Re-check first so electron-updater re-reads the feed from the new origin
|
||||
// before downloading (its cached updateInfo is origin-agnostic here, but a
|
||||
// fresh check keeps the internal state consistent).
|
||||
autoUpdater
|
||||
.checkForUpdates()
|
||||
.then(() => autoUpdater.downloadUpdate())
|
||||
.catch((err2) => {
|
||||
const m2 = err2?.message || String(err2)
|
||||
log(`startDownload: fallback also failed: ${m2}`, err2 instanceof Error && err2.stack ? err2.stack : '')
|
||||
send({ state: 'error', message: m2 })
|
||||
})
|
||||
return
|
||||
}
|
||||
send({ state: 'error', message })
|
||||
})
|
||||
}
|
||||
|
||||
export function quitAndInstall(): void {
|
||||
if (!app.isPackaged) return
|
||||
log('quitAndInstall: relaunching to install update')
|
||||
// Drop window-all-closed handlers first: a lingering handler can keep the
|
||||
// process alive and stop the installer from replacing files / relaunching
|
||||
// (a documented electron-updater gotcha, esp. on Windows NSIS).
|
||||
app.removeAllListeners('window-all-closed')
|
||||
// isSilent=TRUE on Windows. Our installer is now ASSISTED (nsis.oneClick=false
|
||||
// + allowToChangeInstallationDirectory) so the FIRST install shows the
|
||||
// directory/mode wizard. But an UPDATE must NOT re-show that wizard — isSilent
|
||||
// skips it and updates in place. isForceRunAfter=true relaunches after the
|
||||
// silent update. (The old assisted+silent force-run bug, #2179, was fixed
|
||||
// upstream in PR #2278; we're on electron-updater 6.8.9, well past it.)
|
||||
// setImmediate + removeAllListeners are the documented prerequisites for the
|
||||
// relaunch to fire reliably. macOS ignores isSilent entirely.
|
||||
setImmediate(() => autoUpdater.quitAndInstall(true, true))
|
||||
}
|
||||
21
desktop/src/renderer/assets.d.ts
vendored
Normal file
21
desktop/src/renderer/assets.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
// Type declarations for static asset imports handled by Vite.
|
||||
declare module '*.png' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.jpg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.jpeg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.svg' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
declare module '*.webp' {
|
||||
const src: string
|
||||
export default src
|
||||
}
|
||||
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: https: http://127.0.0.1:* http://localhost:*; media-src 'self' data: blob: https: http://127.0.0.1:* http://localhost:*; connect-src 'self' https: 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>
|
||||
143
desktop/src/renderer/src/App.tsx
Normal file
143
desktop/src/renderer/src/App.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { History } 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, isMac } = usePlatform()
|
||||
const { sessionsCollapsed, toggleSessions, navCollapsed } = 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('session_history')}
|
||||
// Keep aligned with the SessionList history button: only nudge
|
||||
// right of the macOS traffic lights when the nav rail is collapsed
|
||||
// (otherwise the lights stay within the rail and don't overlap).
|
||||
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 ${isMac ? 'mt-1' : ''} ${isMac && navCollapsed ? 'ml-2' : ''}`}
|
||||
>
|
||||
<History 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
|
||||
422
desktop/src/renderer/src/api/client.ts
Normal file
422
desktop/src/renderer/src/api/client.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
import type {
|
||||
ConfigData,
|
||||
ChannelInfo,
|
||||
ChannelAction,
|
||||
SkillInfo,
|
||||
ToolInfo,
|
||||
MemoryItem,
|
||||
MemoryCategory,
|
||||
MemoryPage,
|
||||
SchedulerTask,
|
||||
Attachment,
|
||||
SessionsPage,
|
||||
HistoryPage,
|
||||
ModelsData,
|
||||
ModelsAction,
|
||||
KnowledgeList,
|
||||
KnowledgeGraph,
|
||||
KnowledgeAction,
|
||||
KnowledgeImportPayload,
|
||||
} from '../types'
|
||||
|
||||
interface ApiResult {
|
||||
status: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl = 'http://127.0.0.1:9876'
|
||||
|
||||
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),
|
||||
})
|
||||
}
|
||||
|
||||
// Bulk import: upload .md/.txt files into a target category (multipart).
|
||||
async importKnowledge(
|
||||
files: File[],
|
||||
targetCategory: string
|
||||
): Promise<{ status: string; message?: string; payload?: KnowledgeImportPayload }> {
|
||||
const formData = new FormData()
|
||||
formData.append('target_category', targetCategory)
|
||||
formData.append('conflict_strategy', 'rename')
|
||||
files.forEach((file) => formData.append('files', file, file.name))
|
||||
const res = await fetch(`${this.baseUrl}/api/knowledge/import`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// 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
|
||||
BIN
desktop/src/renderer/src/assets/logo.png
Normal file
BIN
desktop/src/renderer/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
398
desktop/src/renderer/src/components/ChatInput.tsx
Normal file
398
desktop/src/renderer/src/components/ChatInput.tsx
Normal file
@@ -0,0 +1,398 @@
|
||||
import React, { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||
import { Plus, Paperclip, Square, X, File as FileIcon, Loader2, Trash2 } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import type { Attachment } from '../types'
|
||||
import apiClient from '../api/client'
|
||||
import { PaperPlaneIcon } from './icons'
|
||||
|
||||
export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
|
||||
|
||||
interface SlashCommand {
|
||||
cmd: string
|
||||
desc: string
|
||||
// 'new'/'clear' run a local action; 'send' (default) is a completion that
|
||||
// gets sent to the backend as a normal message (handled by command plugins).
|
||||
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)
|
||||
|
||||
// Local actions ('new'/'clear') plus completion commands handled by backend
|
||||
// command plugins (cow_cli/godcmd). Commands ending with a space expect an
|
||||
// argument, so selecting them keeps focus in the input instead of sending.
|
||||
const slashCommands: SlashCommand[] = [
|
||||
{ cmd: '/new', desc: t('slash_new'), action: 'new' },
|
||||
{ cmd: '/clear', desc: t('slash_clear'), action: 'clear' },
|
||||
{ cmd: '/help', desc: t('slash_help') },
|
||||
{ cmd: '/status', desc: t('slash_status') },
|
||||
{ cmd: '/context', desc: t('slash_context') },
|
||||
{ cmd: '/skill list', desc: t('slash_skill_list') },
|
||||
{ cmd: '/skill search ', desc: t('slash_skill_search') },
|
||||
{ cmd: '/skill install ', desc: t('slash_skill_install') },
|
||||
{ cmd: '/memory dream ', desc: t('slash_memory_dream') },
|
||||
{ cmd: '/knowledge', desc: t('slash_knowledge') },
|
||||
{ cmd: '/knowledge list', desc: t('slash_knowledge_list') },
|
||||
{ cmd: '/config', desc: t('slash_config') },
|
||||
{ cmd: '/cancel', desc: t('slash_cancel') },
|
||||
{ cmd: '/logs', desc: t('slash_logs') },
|
||||
{ cmd: '/version', desc: t('slash_version') },
|
||||
]
|
||||
const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase()))
|
||||
|
||||
// Resize the textarea to fit its content (single line = 42px, capped at
|
||||
// 180px). Keep overflow hidden until we hit the cap, so an empty/short input
|
||||
// never shows a scrollbar (matches the web console behavior).
|
||||
const autoSize = (el: HTMLTextAreaElement | null) => {
|
||||
if (!el) return
|
||||
el.style.height = '42px'
|
||||
const h = Math.min(el.scrollHeight, 180)
|
||||
el.style.height = h + 'px'
|
||||
el.style.overflowY = el.scrollHeight > 180 ? 'auto' : 'hidden'
|
||||
}
|
||||
|
||||
const resetHeight = () => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
el.style.height = '42px'
|
||||
el.style.overflowY = 'hidden'
|
||||
}
|
||||
|
||||
// Sync the height once on mount so the very first render matches the 42px
|
||||
// single-line height instead of the browser's default textarea size.
|
||||
useEffect(() => {
|
||||
autoSize(textareaRef.current)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// 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()
|
||||
autoSize(el)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const runSlash = (c: SlashCommand) => {
|
||||
setSlashOpen(false)
|
||||
if (c.action === 'new') {
|
||||
setText('')
|
||||
resetHeight()
|
||||
onNewChat()
|
||||
return
|
||||
}
|
||||
if (c.action === 'clear') {
|
||||
setText('')
|
||||
resetHeight()
|
||||
onClearContext()
|
||||
return
|
||||
}
|
||||
// Completion command. If it expects an argument (trailing space), keep it
|
||||
// in the input so the user can type the argument; otherwise send it now.
|
||||
const needsArg = c.cmd.endsWith(' ')
|
||||
if (needsArg) {
|
||||
setText(c.cmd)
|
||||
requestAnimationFrame(() => textareaRef.current?.focus())
|
||||
} else {
|
||||
onSend(c.cmd.trim(), [])
|
||||
setText('')
|
||||
resetHeight()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
autoSize(e.target)
|
||||
// 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 right-0 mb-1.5 max-h-80 overflow-y-auto rounded-xl border border-default bg-elevated shadow-xl z-30 p-1.5">
|
||||
<div className="px-2.5 pt-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wider text-content-tertiary">
|
||||
{t('slash_menu_title')}
|
||||
</div>
|
||||
{filtered.map((c, i) => (
|
||||
<button
|
||||
key={c.cmd}
|
||||
onMouseEnter={() => setSlashIndex(i)}
|
||||
onClick={() => runSlash(c)}
|
||||
className={`w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-lg text-left cursor-pointer transition-colors ${
|
||||
i === slashIndex ? 'bg-accent-soft' : 'hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-[13px] font-medium font-mono whitespace-nowrap ${
|
||||
i === slashIndex ? 'text-accent' : 'text-content-secondary'
|
||||
}`}
|
||||
>
|
||||
{c.cmd}
|
||||
</span>
|
||||
<span className="text-xs text-content-tertiary whitespace-nowrap truncate">{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>
|
||||
<button
|
||||
onClick={onClearContext}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-btn text-content-secondary hover:text-danger hover:bg-danger-soft cursor-pointer transition-colors"
|
||||
title={t('chat_clear_context')}
|
||||
>
|
||||
<Trash2 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 overflow-y-hidden"
|
||||
/>
|
||||
|
||||
{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-[42px] h-[42px] flex items-center justify-center rounded-btn bg-accent text-white hover:bg-accent-hover disabled:bg-surface-2 disabled:text-content-disabled disabled:cursor-not-allowed cursor-pointer transition-none [&_*]:transition-none"
|
||||
title={t('chat_send')}
|
||||
>
|
||||
<PaperPlaneIcon size={15} />
|
||||
</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
|
||||
203
desktop/src/renderer/src/components/MessageBubble.tsx
Normal file
203
desktop/src/renderer/src/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Copy, Check, RefreshCw, 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
|
||||
/** Fired when an inline image/video finishes loading, so the parent can
|
||||
* re-scroll to the bottom (async media changes bubble height after mount). */
|
||||
onMediaLoad?: () => 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, onMediaLoad }) => {
|
||||
const isUser = message.role === 'user'
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(message.content)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
}
|
||||
|
||||
// Open a sent file: prefer the local path via Electron (Finder / default
|
||||
// app); fall back to the served URL in a browser when unavailable.
|
||||
const openAttachment = (att: { abs_path?: string; preview_url?: string; file_path: string }) => {
|
||||
if (att.abs_path && window.electronAPI?.openPath) {
|
||||
window.electronAPI.openPath(att.abs_path)
|
||||
return
|
||||
}
|
||||
window.open(apiClient.getFileUrl(att.preview_url || att.file_path), '_blank')
|
||||
}
|
||||
|
||||
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-accent text-white">
|
||||
<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>
|
||||
{/* Edit entry hidden: editing a past question cascade-deletes all
|
||||
subsequent turns, which surprises users. Kept off until we support
|
||||
non-destructive editing. */}
|
||||
{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} />}
|
||||
|
||||
{/* Media attachments sent via the `send` tool (images / files). */}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{message.attachments.map((att, i) =>
|
||||
att.file_type === 'image' ? (
|
||||
<img
|
||||
key={i}
|
||||
src={apiClient.getFileUrl(att.preview_url || att.file_path)}
|
||||
alt={att.file_name}
|
||||
onLoad={() => onMediaLoad?.()}
|
||||
onClick={() => window.open(apiClient.getFileUrl(att.preview_url || att.file_path), '_blank')}
|
||||
className="max-w-[320px] w-full rounded-xl border border-default cursor-zoom-in"
|
||||
/>
|
||||
) : att.file_type === 'video' ? (
|
||||
<video
|
||||
key={i}
|
||||
src={apiClient.getFileUrl(att.preview_url || att.file_path)}
|
||||
controls
|
||||
onLoadedData={() => onMediaLoad?.()}
|
||||
className="max-w-[360px] w-full rounded-xl border border-default"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => openAttachment(att)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-surface-2 rounded-xl text-xs text-content-secondary hover:text-content cursor-pointer"
|
||||
>
|
||||
<FileIcon size={13} />
|
||||
{att.file_name}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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
|
||||
109
desktop/src/renderer/src/components/MessageSteps.tsx
Normal file
109
desktop/src/renderer/src/components/MessageSteps.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronRight, Loader2, Check, X, Brain } 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>
|
||||
<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
|
||||
133
desktop/src/renderer/src/components/UpdateBanner.tsx
Normal file
133
desktop/src/renderer/src/components/UpdateBanner.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React from 'react'
|
||||
import { Download, RefreshCw, X, Loader2, AlertTriangle } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import { useUpdateStore, hasAvailableUpdate } from '../store/updateStore'
|
||||
|
||||
// Compact update panel anchored to the NavRail footer. Shown whenever an update
|
||||
// is available AND the panel is open (auto-opened on detection, re-openable via
|
||||
// "check for update"). Dismissing (×) just closes it; the menu can re-open it.
|
||||
const UpdateBanner: React.FC = () => {
|
||||
const state = useUpdateStore()
|
||||
const open = state.panelOpen
|
||||
|
||||
const available = hasAvailableUpdate(state)
|
||||
const status = state.status
|
||||
const errored = status?.state === 'error'
|
||||
|
||||
// Full-screen "installing…" overlay: bridges the otherwise blank window
|
||||
// between clicking "restart to install" and the app actually quitting to
|
||||
// swap the bundle. (The gap AFTER quit, before relaunch, is OS-level and
|
||||
// can't be covered.)
|
||||
if (state.installing) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-3 bg-base/90 backdrop-blur-sm">
|
||||
<Loader2 size={28} className="animate-spin text-accent" />
|
||||
<p className="text-sm text-content-secondary">{t('update_installing')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show the panel when it's open AND we either know of an update or hit an
|
||||
// error. Keeping it up on error is important: a failed download must surface
|
||||
// a visible message instead of silently doing nothing.
|
||||
if (!open || (!available && !errored)) return null
|
||||
|
||||
const version = state.version
|
||||
const preparing = state.preparing
|
||||
const downloading = status?.state === 'downloading'
|
||||
const downloaded = status?.state === 'downloaded'
|
||||
// macOS emits a second progress pass (verify) after hitting 100%; show it as
|
||||
// an indeterminate "verifying" state rather than a bar restarting from 0.
|
||||
const verifying = downloading && state.progressPeaked
|
||||
const busy = preparing || downloading
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-2 left-2 right-2 z-40">
|
||||
<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">
|
||||
{errored ? t('update_failed') : t('update_available')}
|
||||
</p>
|
||||
{!errored && version && (
|
||||
<p className="text-xs text-content-tertiary mt-0.5">v{version}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => state.dismiss()}
|
||||
className="text-content-tertiary hover:text-content cursor-pointer flex-shrink-0"
|
||||
title={t('update_later')}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{errored && (
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-start gap-2 text-xs text-content-secondary">
|
||||
<AlertTriangle size={13} className="text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<span className="break-words">
|
||||
{status?.state === 'error' ? status.message : ''}
|
||||
</span>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
<RefreshCw size={15} />
|
||||
{t('update_retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!errored && preparing && (
|
||||
<div className="flex items-center gap-2 text-xs text-content-secondary py-1">
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
<span>{t('update_preparing')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!errored && downloading && verifying && (
|
||||
<div className="flex items-center gap-2 text-xs text-content-secondary py-1">
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
<span>{t('update_verifying')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!errored && downloading && !verifying && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!errored && !busy && !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>
|
||||
)}
|
||||
|
||||
{!errored && 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
|
||||
16
desktop/src/renderer/src/components/icons.tsx
Normal file
16
desktop/src/renderer/src/components/icons.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
|
||||
// Solid paper-plane icon (Font Awesome's fa-paper-plane path) so the send
|
||||
// button and the feishu/telegram channels match the web console exactly.
|
||||
export const PaperPlaneIcon: React.FC<{ size?: number; className?: string }> = ({ size = 16, className }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 512 512"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
<path d="M498.1 5.6c10.1 7 15.4 19.1 13.5 31.2l-64 416c-1.5 9.7-7.4 18.2-16 23s-18.9 5.4-28 1.6L284 427.7l-68.5 74.1c-8.9 9.7-22.9 12.9-35.2 8.1S160 493.2 160 480l0-83.6c0-4 1.5-7.8 4.2-10.8L331.8 202.8c5.8-6.3 5.6-16-.4-22s-15.7-6.4-22-.7L106 360.8 17.7 316.6C7.1 311.3 .3 300.7 0 288.9s5.9-22.8 16.1-28.7l448-256c10.7-6.1 23.9-5.5 34 1.4z" />
|
||||
</svg>
|
||||
)
|
||||
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 */
|
||||
|
||||
}
|
||||
153
desktop/src/renderer/src/hooks/useBackend.ts
Normal file
153
desktop/src/renderer/src/hooks/useBackend.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
// Fixed default port — MUST match DESKTOP_BACKEND_PORT in main/python-manager.ts.
|
||||
// The backend is launched on exactly this port (the main process frees it first
|
||||
// and passes it via COW_WEB_PORT), so probing it works even before the
|
||||
// getBackendPort IPC resolves. Keeping both sides on one constant means the
|
||||
// renderer can never end up talking to the wrong port.
|
||||
const BACKEND_PORT = 9876
|
||||
|
||||
interface BackendState {
|
||||
status: 'connecting' | 'ready' | 'error'
|
||||
port: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function useBackend() {
|
||||
const [state, setState] = useState<BackendState>({
|
||||
status: 'connecting',
|
||||
port: BACKEND_PORT,
|
||||
})
|
||||
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(BACKEND_PORT)
|
||||
|
||||
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) {
|
||||
// Always start polling, even if getBackendPort rejects or the ready event
|
||||
// was already emitted before we subscribed: polling /config is the
|
||||
// self-sufficient path to "ready" and must never depend on the IPC round
|
||||
// trip succeeding (otherwise the app can hang forever on "connecting").
|
||||
api
|
||||
.getBackendPort()
|
||||
.then((port) => {
|
||||
const p = port || BACKEND_PORT
|
||||
portRef.current = p
|
||||
setState((prev) => ({ ...prev, port: p }))
|
||||
startPolling(p)
|
||||
})
|
||||
.catch(() => {
|
||||
startPolling(BACKEND_PORT)
|
||||
})
|
||||
|
||||
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(BACKEND_PORT)
|
||||
}
|
||||
|
||||
// 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
|
||||
// First run: follow the OS appearance rather than forcing a fixed theme.
|
||||
return 'system'
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
785
desktop/src/renderer/src/i18n.ts
Normal file
785
desktop/src/renderer/src/i18n.ts
Normal file
@@ -0,0 +1,785 @@
|
||||
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: '文档加载失败',
|
||||
// knowledge management
|
||||
knowledge_new: '新建',
|
||||
knowledge_new_category: '新建分类',
|
||||
knowledge_new_document: '新建文档',
|
||||
knowledge_import_documents: '导入文档',
|
||||
knowledge_working: '处理中...',
|
||||
knowledge_importing: '正在导入...',
|
||||
knowledge_request_failed: '请求失败,请稍后重试',
|
||||
knowledge_import_failed: '导入失败',
|
||||
knowledge_category_created: '分类已创建',
|
||||
knowledge_document_created: '文档已创建',
|
||||
knowledge_dialog_confirm: '确定',
|
||||
knowledge_dialog_cancel: '取消',
|
||||
knowledge_field_required: '此项不能为空',
|
||||
knowledge_category_label: '分类路径',
|
||||
knowledge_category_hint: '支持嵌套路径,例如 research/ai',
|
||||
knowledge_category_subtitle: '分类会创建为 knowledge/ 下的目录',
|
||||
knowledge_need_category: '请先创建分类',
|
||||
knowledge_destination: '目标分类',
|
||||
knowledge_doc_choose_category: '先选择分类,然后输入文件名',
|
||||
knowledge_doc_save_to: '保存到 {category}',
|
||||
knowledge_doc_filename: '文件名',
|
||||
knowledge_doc_filename_required: '文件名不能为空',
|
||||
knowledge_doc_must_md: '新建文档仅支持 .md 文件名',
|
||||
knowledge_doc_content: 'Markdown 内容',
|
||||
knowledge_doc_content_required: '内容不能为空',
|
||||
knowledge_doc_content_too_large: '内容不能超过 10MB',
|
||||
knowledge_doc_insert_template: '插入模板',
|
||||
knowledge_import_selected: '已选择 {count} 个文件',
|
||||
knowledge_import_hint: '支持 Markdown 和 TXT,TXT 会转成 Markdown 文档',
|
||||
knowledge_import_need_category: '请先创建一个分类',
|
||||
knowledge_import_choose_files: '请选择 .md 或 .txt 文件',
|
||||
knowledge_import_too_many: '一次最多导入 {max} 个文件',
|
||||
knowledge_import_file_too_large: '{name} 超过 10MB',
|
||||
knowledge_import_total_too_large: '单次导入总大小不能超过 200MB',
|
||||
knowledge_import_result: '导入 {imported} 个,跳过 {skipped} 个,失败 {failed} 个',
|
||||
knowledge_drop_hint: '拖放 .md / .txt 文件到此导入',
|
||||
nav_expand: '展开侧栏',
|
||||
nav_collapse: '收起侧栏',
|
||||
session_history: '历史会话',
|
||||
update_available: '发现新版本',
|
||||
update_download: '下载更新',
|
||||
update_downloading: '正在下载',
|
||||
update_preparing: '正在准备…',
|
||||
update_verifying: '正在校验,即将完成…',
|
||||
update_installing: '正在安装并重启,请稍候…',
|
||||
update_restart: '重启以更新',
|
||||
update_later: '稍后',
|
||||
update_latest: '已是最新版本',
|
||||
update_check: '检查更新',
|
||||
update_checking: '正在检查…',
|
||||
update_failed: '更新失败',
|
||||
update_retry: '重试',
|
||||
menu_more: '更多',
|
||||
menu_theme_light: '浅色模式',
|
||||
menu_theme_dark: '深色模式',
|
||||
menu_language: '语言',
|
||||
menu_website: '官网',
|
||||
menu_docs: '文档中心',
|
||||
menu_skill_hub: '技能广场',
|
||||
menu_feedback: '反馈',
|
||||
// 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: '1分钟后提醒我检查服务器',
|
||||
example_code_title: '编程助手',
|
||||
example_code_text: '搜索AI资讯生成可视化网页报告',
|
||||
example_knowledge_title: '知识库',
|
||||
example_knowledge_text: '查看知识库当前文档情况',
|
||||
example_skill_title: '技能系统',
|
||||
example_skill_text: '查看所有支持的工具和技能',
|
||||
example_web_title: '指令中心',
|
||||
example_web_text: '查看全部命令',
|
||||
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_select_label: '选择要接入的通道',
|
||||
channels_select_placeholder: '请选择通道...',
|
||||
channels_add_close: '关闭',
|
||||
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: '重试',
|
||||
// slash command descriptions
|
||||
slash_menu_title: '命令',
|
||||
slash_new: '新建对话',
|
||||
slash_clear: '清除对话上下文',
|
||||
slash_help: '显示命令帮助',
|
||||
slash_status: '查看运行状态',
|
||||
slash_context: '查看对话上下文',
|
||||
slash_skill_list: '查看已安装技能',
|
||||
slash_skill_search: '搜索技能',
|
||||
slash_skill_install: '安装技能 (名称或 GitHub URL)',
|
||||
slash_memory_dream: '手动触发记忆蒸馏 (可指定天数, 默认3)',
|
||||
slash_knowledge: '查看知识库统计',
|
||||
slash_knowledge_list: '查看知识库文件树',
|
||||
slash_config: '查看当前配置',
|
||||
slash_cancel: '中止当前正在运行的 Agent 任务',
|
||||
slash_logs: '查看最近日志',
|
||||
slash_version: '查看版本',
|
||||
},
|
||||
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',
|
||||
// knowledge management
|
||||
knowledge_new: 'New',
|
||||
knowledge_new_category: 'New category',
|
||||
knowledge_new_document: 'New document',
|
||||
knowledge_import_documents: 'Import documents',
|
||||
knowledge_working: 'Working...',
|
||||
knowledge_importing: 'Importing...',
|
||||
knowledge_request_failed: 'Request failed, please try again',
|
||||
knowledge_import_failed: 'Import failed',
|
||||
knowledge_category_created: 'Category created',
|
||||
knowledge_document_created: 'Document created',
|
||||
knowledge_dialog_confirm: 'Confirm',
|
||||
knowledge_dialog_cancel: 'Cancel',
|
||||
knowledge_field_required: 'This field is required',
|
||||
knowledge_category_label: 'Category path',
|
||||
knowledge_category_hint: 'Nested paths are supported, e.g. research/ai',
|
||||
knowledge_category_subtitle: 'Creates a directory under knowledge/',
|
||||
knowledge_need_category: 'Create a category first',
|
||||
knowledge_destination: 'Destination category',
|
||||
knowledge_doc_choose_category: 'Choose a category, then enter a filename',
|
||||
knowledge_doc_save_to: 'Save to {category}',
|
||||
knowledge_doc_filename: 'Filename',
|
||||
knowledge_doc_filename_required: 'Filename is required',
|
||||
knowledge_doc_must_md: 'New documents must be .md files',
|
||||
knowledge_doc_content: 'Markdown content',
|
||||
knowledge_doc_content_required: 'Content is required',
|
||||
knowledge_doc_content_too_large: 'Content cannot exceed 10MB',
|
||||
knowledge_doc_insert_template: 'Insert template',
|
||||
knowledge_import_selected: '{count} file(s) selected',
|
||||
knowledge_import_hint: 'Markdown and TXT are supported. TXT is converted to Markdown.',
|
||||
knowledge_import_need_category: 'Create a category first',
|
||||
knowledge_import_choose_files: 'Choose .md or .txt files',
|
||||
knowledge_import_too_many: 'Import at most {max} files at a time',
|
||||
knowledge_import_file_too_large: '{name} exceeds 10MB',
|
||||
knowledge_import_total_too_large: 'Total import size cannot exceed 200MB',
|
||||
knowledge_import_result: '{imported} imported · {skipped} skipped · {failed} failed',
|
||||
knowledge_drop_hint: 'Drop .md / .txt files here to import',
|
||||
menu_settings: 'Settings',
|
||||
nav_expand: 'Expand sidebar',
|
||||
nav_collapse: 'Collapse sidebar',
|
||||
session_history: 'Chat history',
|
||||
update_available: 'New version available',
|
||||
update_download: 'Download update',
|
||||
update_downloading: 'Downloading',
|
||||
update_preparing: 'Preparing…',
|
||||
update_verifying: 'Verifying, almost done…',
|
||||
update_installing: 'Installing and restarting, please wait…',
|
||||
update_restart: 'Restart to update',
|
||||
update_later: 'Later',
|
||||
update_latest: 'You are up to date',
|
||||
update_check: 'Check for updates',
|
||||
update_checking: 'Checking…',
|
||||
update_failed: 'Update failed',
|
||||
update_retry: 'Retry',
|
||||
menu_more: 'More',
|
||||
menu_theme_light: 'Light mode',
|
||||
menu_theme_dark: 'Dark mode',
|
||||
menu_language: 'Language',
|
||||
menu_website: 'Website',
|
||||
menu_docs: 'Documentation',
|
||||
menu_skill_hub: 'Skill Hub',
|
||||
menu_feedback: 'Feedback',
|
||||
// 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: 'Scheduled Task',
|
||||
example_task_text: 'Remind me to check the server in 1 minute',
|
||||
example_code_title: 'Coding',
|
||||
example_code_text: 'Search AI news and build a visual web report',
|
||||
example_knowledge_title: 'Knowledge Base',
|
||||
example_knowledge_text: 'Show the current documents in the knowledge base',
|
||||
example_skill_title: 'Skills',
|
||||
example_skill_text: 'Show all supported tools and skills',
|
||||
example_web_title: 'Commands',
|
||||
example_web_text: 'Show all commands',
|
||||
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_select_label: 'Select a channel to add',
|
||||
channels_select_placeholder: 'Select a channel...',
|
||||
channels_add_close: 'Close',
|
||||
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',
|
||||
// slash command descriptions
|
||||
slash_menu_title: 'Commands',
|
||||
slash_new: 'New chat',
|
||||
slash_clear: 'Clear conversation context',
|
||||
slash_help: 'Show command help',
|
||||
slash_status: 'Show running status',
|
||||
slash_context: 'Show conversation context',
|
||||
slash_skill_list: 'List installed skills',
|
||||
slash_skill_search: 'Search skills',
|
||||
slash_skill_install: 'Install a skill (name or GitHub URL)',
|
||||
slash_memory_dream: 'Trigger memory distillation (optional days, default 3)',
|
||||
slash_knowledge: 'Show knowledge base stats',
|
||||
slash_knowledge_list: 'Show knowledge base file tree',
|
||||
slash_config: 'Show current config',
|
||||
slash_cancel: 'Abort the running agent task',
|
||||
slash_logs: 'Show recent logs',
|
||||
slash_version: 'Show version',
|
||||
},
|
||||
}
|
||||
|
||||
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 || ''
|
||||
}
|
||||
335
desktop/src/renderer/src/index.css
Normal file
335
desktop/src/renderer/src/index.css
Normal file
@@ -0,0 +1,335 @@
|
||||
@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) */
|
||||
--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);
|
||||
|
||||
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;
|
||||
}
|
||||
386
desktop/src/renderer/src/layout/NavRail.tsx
Normal file
386
desktop/src/renderer/src/layout/NavRail.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
MessageSquare,
|
||||
BookOpen,
|
||||
Brain,
|
||||
Zap,
|
||||
Radio,
|
||||
Clock,
|
||||
Settings,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Sun,
|
||||
Moon,
|
||||
ScrollText,
|
||||
MoreHorizontal,
|
||||
Languages,
|
||||
Download,
|
||||
Loader2,
|
||||
Globe,
|
||||
FileText,
|
||||
Store,
|
||||
MessageSquareWarning,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
// The desktop app's own brand icon (transparent PNG), bundled by Vite.
|
||||
import brandLogo from '../assets/logo.png'
|
||||
import { t, getLang, setLang, Lang } from '../i18n'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
import { useTheme } from '../hooks/useTheme'
|
||||
import { usePlatform } from '../hooks/usePlatform'
|
||||
import { useUpdateStore, hasPendingUpdate, hasAvailableUpdate } from '../store/updateStore'
|
||||
import UpdateBanner from '../components/UpdateBanner'
|
||||
|
||||
// Fallback shown when app.getVersion() is unavailable (dev/web preview). Keep
|
||||
// in sync with desktop/package.json "version"; the packaged app overrides this
|
||||
// with the real value via IPC, so it only matters outside a packaged build.
|
||||
const FALLBACK_VERSION = '2.1.3'
|
||||
|
||||
// External links opened in the user's default browser. The window-open handler
|
||||
// in the main process routes window.open() through shell.openExternal.
|
||||
// English is the default (no suffix); Chinese gets a /zh suffix. Skill hub is
|
||||
// language-agnostic.
|
||||
const SKILL_HUB_URL = 'https://skills.cowagent.ai/'
|
||||
// GitHub issues — where users report bugs / request features.
|
||||
const FEEDBACK_URL = 'https://github.com/zhayujie/CowAgent/issues'
|
||||
|
||||
const websiteUrl = () => (getLang() === 'zh' ? 'https://cowagent.ai/zh' : 'https://cowagent.ai')
|
||||
const docsUrl = () => (getLang() === 'zh' ? 'https://docs.cowagent.ai/zh' : 'https://docs.cowagent.ai')
|
||||
|
||||
const openExternal = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
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()
|
||||
// On macOS the top-left is occupied by the native traffic lights, so the
|
||||
// brand mark is only shown on Windows/Linux where that corner is otherwise
|
||||
// empty (mirrors the web console's sidebar logo).
|
||||
const { isMac } = usePlatform()
|
||||
|
||||
const collapsed = navCollapsed
|
||||
const width = collapsed ? 'w-[56px]' : 'w-[208px]'
|
||||
|
||||
const updateState = useUpdateStore()
|
||||
// Footer dot: hidden once dismissed for this version (user asked for this).
|
||||
const pendingUpdate = hasPendingUpdate(updateState)
|
||||
// Menu "check for update" dot: stays as long as an update actually exists,
|
||||
// even after dismissing the footer badge.
|
||||
const availableUpdate = hasAvailableUpdate(updateState)
|
||||
const checking = updateState.status?.state === 'checking'
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// Local fallback so a version always shows even if the main-process IPC is
|
||||
// unavailable (e.g. dev/web preview). The real value comes from
|
||||
// app.getVersion() (packaged package.json), never from a remote service.
|
||||
const [version, setVersion] = useState(FALLBACK_VERSION)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI
|
||||
?.getAppVersion?.()
|
||||
.then((v) => v && setVersion(v))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Close the popover on any outside click / Escape.
|
||||
useEffect(() => {
|
||||
if (!menuOpen) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMenuOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [menuOpen])
|
||||
|
||||
const toggleLanguage = () => {
|
||||
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
|
||||
setLang(next)
|
||||
onLangChange()
|
||||
}
|
||||
|
||||
// Track a user-initiated check so we can show "up to date" feedback in the
|
||||
// menu when the result comes back as not-available (the auto poll stays
|
||||
// silent). Cleared shortly after, and whenever the menu closes.
|
||||
const [checkedManually, setCheckedManually] = useState(false)
|
||||
const updateStatusState = updateState.status?.state
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkedManually) return
|
||||
if (updateStatusState === 'not-available') {
|
||||
const id = setTimeout(() => setCheckedManually(false), 4000)
|
||||
return () => clearTimeout(id)
|
||||
}
|
||||
// A pending update opens its own panel; no need for the inline hint.
|
||||
if (updateStatusState === 'available' || updateStatusState === 'downloaded') {
|
||||
setCheckedManually(false)
|
||||
}
|
||||
return
|
||||
}, [checkedManually, updateStatusState])
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuOpen) setCheckedManually(false)
|
||||
}, [menuOpen])
|
||||
|
||||
const checkUpdate = () => {
|
||||
setCheckedManually(true)
|
||||
// If an update is already known, recheck() re-opens its panel, so close the
|
||||
// menu to reveal it. Otherwise keep the menu OPEN: the "up to date" result
|
||||
// shows inline as the menu label — closing it (which resets checkedManually)
|
||||
// is exactly what made the box flash and never show "up to date".
|
||||
if (availableUpdate) setMenuOpen(false)
|
||||
updateState.recheck()
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}>
|
||||
{/* Top: full-width drag strip; bottom border continues the header divider
|
||||
across the whole window. No right border so it doesn't cut the lights.
|
||||
On Windows/Linux the top-left corner is empty (no traffic lights), so
|
||||
we surface the brand mark here like the web console's sidebar. */}
|
||||
<div
|
||||
className={`titlebar-drag h-[44px] flex-shrink-0 border-b border-default flex items-center ${
|
||||
collapsed ? 'justify-center px-0' : 'px-3'
|
||||
}`}
|
||||
>
|
||||
{!isMac && (
|
||||
<div className="flex items-center gap-2 min-w-0 select-none">
|
||||
<BrandLogo />
|
||||
{!collapsed && (
|
||||
<span className="text-[14px] font-semibold text-content truncate">CowAgent</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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: a single "more" entry (with version + update dot) that
|
||||
opens an upward popover, plus the always-visible collapse toggle. */}
|
||||
<div className="flex-shrink-0 px-2 py-2 border-t border-subtle relative" ref={menuRef}>
|
||||
{menuOpen && (
|
||||
<FooterMenu
|
||||
theme={theme}
|
||||
checking={checking}
|
||||
pendingUpdate={availableUpdate}
|
||||
upToDate={checkedManually && updateStatusState === 'not-available' && !availableUpdate}
|
||||
onLogs={() => {
|
||||
setMenuOpen(false)
|
||||
navigate('/logs')
|
||||
}}
|
||||
onTheme={toggleTheme}
|
||||
onLanguage={toggleLanguage}
|
||||
onCheckUpdate={checkUpdate}
|
||||
onOpenLink={(url) => {
|
||||
setMenuOpen(false)
|
||||
openExternal(url)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}>
|
||||
{/* Single clickable entry: version label (left) + the three dots
|
||||
(right) form one button; the whole block opens the popover. The
|
||||
version is the packaged app version, also what auto-update
|
||||
compares against. Collapsed: dots only, version hidden. */}
|
||||
<button
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
title={t('menu_more')}
|
||||
className={`relative inline-flex items-center rounded-btn cursor-pointer transition-colors ${
|
||||
menuOpen ? 'bg-surface-2 text-content' : 'text-content-tertiary hover:text-content hover:bg-surface-2'
|
||||
} ${collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2 gap-1.5'}`}
|
||||
>
|
||||
{!collapsed && version && (
|
||||
<span className="text-[12px] truncate">{`v${version}`}</span>
|
||||
)}
|
||||
<MoreHorizontal size={17} className="flex-shrink-0" />
|
||||
{pendingUpdate && (
|
||||
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-danger" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!collapsed && <div className="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>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// Brand mark for the top-left corner (Windows/Linux). Uses the desktop app's
|
||||
// own icon (transparent PNG with its own rounded shape), so it sits cleanly on
|
||||
// both light and dark backgrounds without extra styling.
|
||||
const BrandLogo: React.FC = () => (
|
||||
<img
|
||||
src={brandLogo}
|
||||
alt="CowAgent"
|
||||
draggable={false}
|
||||
className="flex-shrink-0 w-7 h-7 object-contain"
|
||||
/>
|
||||
)
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
// Upward popover holding the secondary actions previously crammed into the
|
||||
// footer (theme, language, logs, update check). Keeps the footer to a single
|
||||
// entry so new items can be added here without cluttering the rail.
|
||||
const FooterMenu: React.FC<{
|
||||
theme: string
|
||||
checking: boolean
|
||||
pendingUpdate: boolean
|
||||
upToDate: boolean
|
||||
onLogs: () => void
|
||||
onTheme: () => void
|
||||
onLanguage: () => void
|
||||
onCheckUpdate: () => void
|
||||
onOpenLink: (url: string) => void
|
||||
}> = ({ theme, checking, pendingUpdate, upToDate, onLogs, onTheme, onLanguage, onCheckUpdate, onOpenLink }) => {
|
||||
const updateLabel = checking
|
||||
? t('update_checking')
|
||||
: upToDate
|
||||
? t('update_latest')
|
||||
: t('update_check')
|
||||
return (
|
||||
<div className="absolute bottom-full left-2 right-2 mb-2 z-50 rounded-lg border border-default bg-elevated shadow-lg py-1">
|
||||
{/* External destinations first (skill hub, docs, website) */}
|
||||
<MenuItem icon={<Store size={16} />} label={t('menu_skill_hub')} onClick={() => onOpenLink(SKILL_HUB_URL)} />
|
||||
<MenuItem icon={<FileText size={16} />} label={t('menu_docs')} onClick={() => onOpenLink(docsUrl())} />
|
||||
<MenuItem icon={<Globe size={16} />} label={t('menu_website')} onClick={() => onOpenLink(websiteUrl())} />
|
||||
<MenuItem
|
||||
icon={<MessageSquareWarning size={16} />}
|
||||
label={t('menu_feedback')}
|
||||
onClick={() => onOpenLink(FEEDBACK_URL)}
|
||||
/>
|
||||
|
||||
<div className="my-1 border-t border-subtle" />
|
||||
|
||||
{/* App actions below: update, theme, language, logs */}
|
||||
<MenuItem
|
||||
icon={checking ? <Loader2 size={16} className="animate-spin" /> : <Download size={16} />}
|
||||
label={updateLabel}
|
||||
onClick={onCheckUpdate}
|
||||
dot={pendingUpdate}
|
||||
disabled={checking || upToDate}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
label={theme === 'dark' ? t('menu_theme_light') : t('menu_theme_dark')}
|
||||
onClick={onTheme}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Languages size={16} />}
|
||||
label={t('menu_language')}
|
||||
trailing={getLang() === 'zh' ? 'EN' : '中'}
|
||||
onClick={onLanguage}
|
||||
/>
|
||||
<MenuItem icon={<ScrollText size={16} />} label={t('menu_logs')} onClick={onLogs} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MenuItem: React.FC<{
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
trailing?: string
|
||||
dot?: boolean
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
}> = ({ icon, label, trailing, dot, disabled, onClick }) => (
|
||||
<button
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-2.5 px-3 h-9 text-[13px] text-content-secondary hover:bg-surface-2 hover:text-content cursor-pointer transition-colors disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-content-secondary"
|
||||
>
|
||||
<span className="flex-shrink-0 text-content-tertiary relative">
|
||||
{icon}
|
||||
{dot && <span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-full bg-danger" />}
|
||||
</span>
|
||||
<span className="flex-1 text-left truncate">{label}</span>
|
||||
{trailing && <span className="text-[11px] font-medium text-content-tertiary">{trailing}</span>}
|
||||
</button>
|
||||
)
|
||||
|
||||
export default NavRail
|
||||
189
desktop/src/renderer/src/layout/SessionList.tsx
Normal file
189
desktop/src/renderer/src/layout/SessionList.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Plus, MessageSquare, Pencil, Trash2, Check, X, History } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import { useSessionStore } from '../store/sessionStore'
|
||||
import { useUIStore } from '../store/uiStore'
|
||||
import { usePlatform } from '../hooks/usePlatform'
|
||||
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 navCollapsed = useUIStore((s) => s.navCollapsed)
|
||||
const { isMac } = usePlatform()
|
||||
// When the nav rail is collapsed on macOS, the native traffic lights spill
|
||||
// past it, so nudge the history button right to keep it (and its sibling in
|
||||
// the main header) clear of the lights and aligned across states.
|
||||
const trafficOffset = isMac && navCollapsed ? 'ml-2' : ''
|
||||
// Nudge header buttons down a touch to sit level with the macOS traffic lights.
|
||||
const trafficDrop = isMac ? 'mt-1' : ''
|
||||
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 border-b border-default">
|
||||
<button
|
||||
onClick={toggleSessions}
|
||||
title={t('session_history')}
|
||||
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 ${trafficDrop} ${trafficOffset}`}
|
||||
>
|
||||
<History 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 ${trafficDrop}`}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
452
desktop/src/renderer/src/pages/ChannelsPage.tsx
Normal file
452
desktop/src/renderer/src/pages/ChannelsPage.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Loader2,
|
||||
Plug,
|
||||
Plus,
|
||||
X,
|
||||
ChevronDown,
|
||||
Check,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Bot,
|
||||
Building2,
|
||||
Headset,
|
||||
Hash,
|
||||
AtSign,
|
||||
} 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'
|
||||
import { PaperPlaneIcon } from '../components/icons'
|
||||
|
||||
// Channels that connect via QR scanning rather than credential fields.
|
||||
const QR_PROVIDERS: Record<string, 'weixin' | 'feishu'> = { weixin: 'weixin', feishu: 'feishu' }
|
||||
|
||||
// An icon component that takes a `size` prop (lucide icons and our PaperPlaneIcon).
|
||||
type IconComponent = React.FC<{ size?: number }>
|
||||
|
||||
// Per-channel icon + accent color, mirroring the web console's FontAwesome
|
||||
// icon + Tailwind color palette (we use lucide here, with hex colors so the
|
||||
// tinted icon background isn't purged by Tailwind's JIT). Feishu/Telegram use
|
||||
// the same paper-plane as the web console.
|
||||
const CHANNEL_STYLE: Record<string, { Icon: IconComponent; color: string }> = {
|
||||
weixin: { Icon: MessageCircle, color: '#10b981' },
|
||||
feishu: { Icon: PaperPlaneIcon, color: '#3b82f6' },
|
||||
dingtalk: { Icon: MessageSquare, color: '#3b82f6' },
|
||||
wecom_bot: { Icon: Bot, color: '#10b981' },
|
||||
qq: { Icon: MessageCircle, color: '#3b82f6' },
|
||||
wechatcom_app: { Icon: Building2, color: '#10b981' },
|
||||
wechat_kf: { Icon: Headset, color: '#10b981' },
|
||||
wechatmp: { Icon: MessageCircle, color: '#10b981' },
|
||||
telegram: { Icon: PaperPlaneIcon, color: '#0ea5e9' },
|
||||
slack: { Icon: Hash, color: '#a855f7' },
|
||||
discord: { Icon: AtSign, color: '#6366f1' },
|
||||
}
|
||||
|
||||
const channelStyle = (name: string) => CHANNEL_STYLE[name] ?? { Icon: Plug, color: '#94a3b8' }
|
||||
|
||||
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)
|
||||
// Whether the "add channel" panel is open, and the channel chosen in it.
|
||||
// `selected` starts empty so the user must pick a channel themselves.
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<string>('')
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
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])
|
||||
|
||||
// If the selected channel got connected (or vanished), clear the selection.
|
||||
useEffect(() => {
|
||||
if (selected && !available.some((c) => c.name === selected)) setSelected('')
|
||||
}, [available, selected])
|
||||
|
||||
const openAdd = () => {
|
||||
setSelected('')
|
||||
setAddOpen(true)
|
||||
// Scroll the new panel into view at the bottom of the list.
|
||||
requestAnimationFrame(() => {
|
||||
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
})
|
||||
}
|
||||
|
||||
const addingChannel = available.find((c) => c.name === selected)
|
||||
|
||||
const onAdded = () => {
|
||||
setAddOpen(false)
|
||||
setSelected('')
|
||||
void loadChannels()
|
||||
}
|
||||
|
||||
// Keep the config form in view as it grows after picking a channel.
|
||||
useEffect(() => {
|
||||
if (selected) {
|
||||
requestAnimationFrame(() => {
|
||||
panelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||
})
|
||||
}
|
||||
}, [selected])
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="px-6 pt-5 pb-3 flex-shrink-0 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<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>
|
||||
{!loading && available.length > 0 && !addOpen && (
|
||||
<Btn variant="primary" onClick={openAdd}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Plus size={15} />
|
||||
{t('channels_add')}
|
||||
</span>
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} 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-3">
|
||||
{connected.length === 0 && !addOpen ? (
|
||||
<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} />)
|
||||
)}
|
||||
|
||||
{/* Add-channel panel lives at the bottom of the list: pick a
|
||||
channel from the dropdown, then configure/connect it inline. */}
|
||||
{addOpen && available.length > 0 && (
|
||||
<div ref={panelRef} className="rounded-card border border-accent/40 bg-surface p-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className="text-sm font-medium text-content">{t('channels_select_label')}</label>
|
||||
<button
|
||||
onClick={() => setAddOpen(false)}
|
||||
className="text-content-tertiary hover:text-content cursor-pointer"
|
||||
title={t('channels_add_close')}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<ChannelDropdown
|
||||
channels={available}
|
||||
value={selected}
|
||||
onChange={setSelected}
|
||||
placeholder={t('channels_select_placeholder')}
|
||||
/>
|
||||
{addingChannel && (
|
||||
<ChannelCard key={addingChannel.name} channel={addingChannel} onChanged={onAdded} defaultExpanded />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Custom dropdown styled like the web console's `.cfg-dropdown` (rounded,
|
||||
// green focus ring, hover/active states) instead of a native <select>.
|
||||
const ChannelDropdown: React.FC<{
|
||||
channels: ChannelInfo[]
|
||||
value: string
|
||||
onChange: (name: string) => void
|
||||
placeholder: string
|
||||
}> = ({ channels, value, onChange, placeholder }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [open])
|
||||
|
||||
const current = channels.find((c) => c.name === value)
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`w-full flex items-center justify-between gap-2 h-10 px-3 rounded-btn border bg-inset text-sm cursor-pointer transition-colors ${
|
||||
open ? 'border-accent ring-2 ring-accent/15' : 'border-strong hover:border-content-tertiary'
|
||||
} ${current ? 'text-content' : 'text-content-tertiary'}`}
|
||||
>
|
||||
{current ? (
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<ChannelIcon name={current.name} size={26} />
|
||||
<span className="truncate">{localizedLabel(current.label)}</span>
|
||||
<span className="text-content-tertiary font-mono text-xs">({current.name})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
)}
|
||||
<ChevronDown size={14} className={`flex-shrink-0 text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute top-[calc(100%+4px)] left-0 right-0 z-50 max-h-60 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg p-1">
|
||||
{channels.map((ch) => {
|
||||
const active = ch.name === value
|
||||
return (
|
||||
<button
|
||||
key={ch.name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(ch.name)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md text-sm cursor-pointer transition-colors ${
|
||||
active ? 'bg-accent-soft text-accent font-medium' : 'text-content-secondary hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<ChannelIcon name={ch.name} size={26} />
|
||||
<span className="truncate">{localizedLabel(ch.label)}</span>
|
||||
<span className="text-content-tertiary font-mono text-xs">({ch.name})</span>
|
||||
{active && <Check size={14} className="ml-auto flex-shrink-0" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A tinted square with the channel's icon (web-console style).
|
||||
const ChannelIcon: React.FC<{ name: string; size?: number }> = ({ name, size = 36 }) => {
|
||||
const { Icon, color } = channelStyle(name)
|
||||
return (
|
||||
<span
|
||||
className="rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ width: size, height: size, backgroundColor: `${color}1a`, color }}
|
||||
>
|
||||
<Icon size={Math.round(size * 0.45)} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void; defaultExpanded?: boolean }> = ({
|
||||
channel,
|
||||
onChanged,
|
||||
defaultExpanded = false,
|
||||
}) => {
|
||||
// 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(defaultExpanded)
|
||||
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={defaultExpanded ? '' : 'rounded-card border border-default bg-surface p-4'}>
|
||||
<div className="flex items-center gap-3">
|
||||
<ChannelIcon name={channel.name} size={40} />
|
||||
<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'}`} />
|
||||
{channel.active && <span className="text-xs text-accent">{t('channels_connected')}</span>}
|
||||
</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 || defaultExpanded ? 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
|
||||
307
desktop/src/renderer/src/pages/ChatPage.tsx
Normal file
307
desktop/src/renderer/src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import {
|
||||
ChevronUp,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
Clock,
|
||||
Code2,
|
||||
BookOpen,
|
||||
Puzzle,
|
||||
Terminal,
|
||||
type LucideIcon,
|
||||
} 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
|
||||
}
|
||||
|
||||
// Welcome-screen suggestion cards (aligned with the web console: 6 cards).
|
||||
// `send` overrides the text dropped into the input (e.g. show "查看全部命令"
|
||||
// but fill "/help"); otherwise the card's *_text is used.
|
||||
// Icon + accent color per card, aligned with the web console palette.
|
||||
const SUGGESTIONS: {
|
||||
key: string
|
||||
send?: string
|
||||
icon: LucideIcon
|
||||
iconClass: string
|
||||
bgClass: string
|
||||
}[] = [
|
||||
{ key: 'example_sys', icon: FolderOpen, iconClass: 'text-blue-500', bgClass: 'bg-blue-500/10' },
|
||||
{ key: 'example_task', icon: Clock, iconClass: 'text-amber-500', bgClass: 'bg-amber-500/10' },
|
||||
{ key: 'example_code', icon: Code2, iconClass: 'text-emerald-500', bgClass: 'bg-emerald-500/10' },
|
||||
{ key: 'example_knowledge', icon: BookOpen, iconClass: 'text-violet-500', bgClass: 'bg-violet-500/10' },
|
||||
{ key: 'example_skill', icon: Puzzle, iconClass: 'text-rose-500', bgClass: 'bg-rose-500/10' },
|
||||
{ key: 'example_web', send: '/help', icon: Terminal, iconClass: 'text-content-tertiary', bgClass: 'bg-content-tertiary/10' },
|
||||
]
|
||||
|
||||
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) => {
|
||||
// Defer to the next frame so we read the height *after* the new content has
|
||||
// been laid out (markdown/streaming renders a frame later than the effect).
|
||||
requestAnimationFrame(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
// Smooth animations get interrupted by high-frequency streaming updates
|
||||
// and never catch up, so jump instantly while following the stream.
|
||||
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)
|
||||
// True while we should keep the view pinned to the bottom (e.g. during
|
||||
// streaming). Cleared when the user scrolls up to read earlier messages.
|
||||
const followBottomRef = useRef(true)
|
||||
// Tracks the previous streaming state so we can do one final snap to the
|
||||
// bottom right when streaming ends (the last chunk of a long command output
|
||||
// often lands together with isStreaming flipping to false).
|
||||
const wasStreamingRef = useRef(false)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
|
||||
if (lastSessionRef.current !== activeId) {
|
||||
lastSessionRef.current = activeId
|
||||
lastLenRef.current = messages.length
|
||||
pendingSnapRef.current = true
|
||||
followBottomRef.current = true
|
||||
}
|
||||
|
||||
if (pendingSnapRef.current) {
|
||||
// Instant snap on switch and on the first content that lands afterwards.
|
||||
lastLenRef.current = messages.length
|
||||
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
|
||||
// Follow the bottom when: a new message arrived, the user is already near
|
||||
// the bottom, or we're streaming and the user hasn't scrolled up. This
|
||||
// keeps long command/streaming output (where length is unchanged but the
|
||||
// content keeps growing) glued to the latest line.
|
||||
// One final snap right when streaming ends, so the tail of a long command
|
||||
// output isn't left scrolled off-screen.
|
||||
const justFinished = wasStreamingRef.current && !isStreaming
|
||||
wasStreamingRef.current = isStreaming
|
||||
|
||||
const following = isStreaming && followBottomRef.current
|
||||
if (grew || nearBottom || following || (justFinished && followBottomRef.current)) {
|
||||
// Instant jump while streaming/new content (smooth animations get
|
||||
// interrupted by rapid updates and never reach the bottom); smooth only
|
||||
// for a lone increment when the user is already sitting near the bottom.
|
||||
const smooth = nearBottom && !following && !grew && !justFinished
|
||||
scrollToBottom(smooth)
|
||||
}
|
||||
}, [messages, activeId, isStreaming, 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]
|
||||
)
|
||||
|
||||
// Inline images/videos load asynchronously and grow the bubble after mount,
|
||||
// so a scroll triggered on message change fires before the final height is
|
||||
// known. Re-scroll once media loads, but only while following the bottom.
|
||||
const handleMediaLoad = useCallback(() => {
|
||||
if (followBottomRef.current) scrollToBottom(false)
|
||||
}, [scrollToBottom])
|
||||
|
||||
const handleScroll = useCallback(
|
||||
async (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget
|
||||
// Track whether the user wants to stay pinned to the bottom: scrolling up
|
||||
// pauses auto-follow; returning near the bottom resumes it.
|
||||
followBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
||||
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-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
// Fill the input (don't auto-send) so the user can tweak it first.
|
||||
const draft = send ?? t(`${key}_text` as Parameters<typeof t>[0])
|
||||
inputResetRef.current?.(draft, [])
|
||||
}}
|
||||
className="group text-left bg-surface border border-default rounded-xl p-3.5 cursor-pointer hover:border-accent hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span
|
||||
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 ${bgClass}`}
|
||||
>
|
||||
<Icon size={15} className={iconClass} />
|
||||
</span>
|
||||
<span className="font-medium text-sm text-content">
|
||||
{t(`${key}_title` as Parameters<typeof t>[0])}
|
||||
</span>
|
||||
</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}
|
||||
onMediaLoad={handleMediaLoad}
|
||||
/>
|
||||
))}
|
||||
<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
|
||||
981
desktop/src/renderer/src/pages/KnowledgePage.tsx
Normal file
981
desktop/src/renderer/src/pages/KnowledgePage.tsx
Normal file
@@ -0,0 +1,981 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Loader2,
|
||||
Search,
|
||||
FileText,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
MessageSquarePlus,
|
||||
Network,
|
||||
Files,
|
||||
Plus,
|
||||
FolderPlus,
|
||||
FilePlus2,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { t, getLang } 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 KNOWLEDGE_IMPORT_MAX_FILES = 100
|
||||
const KNOWLEDGE_IMPORT_MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||
const KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE = 200 * 1024 * 1024
|
||||
|
||||
// t() with simple {placeholder} interpolation.
|
||||
const tf = (key: string, vars: Record<string, string | number>): string => {
|
||||
let out = t(key)
|
||||
for (const [k, v] of Object.entries(vars)) out = out.replace(`{${k}}`, String(v))
|
||||
return out
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
// Flatten the tree into category paths (for destination selectors).
|
||||
function categoryPaths(dirs: KnowledgeDir[], parent = ''): string[] {
|
||||
const paths: string[] = []
|
||||
for (const dir of dirs || []) {
|
||||
const path = parent ? `${parent}/${dir.dir}` : dir.dir
|
||||
paths.push(path, ...categoryPaths(dir.children || [], path))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// Validate a batch of files chosen for import. Returns an error message or ''.
|
||||
function validateImportFiles(files: File[]): string {
|
||||
if (!files.length) return t('knowledge_import_choose_files')
|
||||
if (files.length > KNOWLEDGE_IMPORT_MAX_FILES) {
|
||||
return tf('knowledge_import_too_many', { max: KNOWLEDGE_IMPORT_MAX_FILES })
|
||||
}
|
||||
let total = 0
|
||||
for (const file of files) {
|
||||
total += file.size || 0
|
||||
if ((file.size || 0) > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
|
||||
return tf('knowledge_import_file_too_large', { name: file.name })
|
||||
}
|
||||
}
|
||||
if (total > KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE) return t('knowledge_import_total_too_large')
|
||||
return ''
|
||||
}
|
||||
|
||||
// ---- Dialog model ----------------------------------------------------------
|
||||
|
||||
interface DialogState {
|
||||
kind: 'category' | 'doc-pick-category' | 'document' | 'import'
|
||||
category?: string
|
||||
files?: File[]
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Resolve a document's display title from its path, falling back to the stem.
|
||||
function findTitle(list: KnowledgeList, path: string): string {
|
||||
const fallback = path.split('/').pop()?.replace(/\.md$/i, '') || path
|
||||
for (const f of list.root_files || []) {
|
||||
if (f.name === path) return f.title || fallback
|
||||
}
|
||||
const walk = (dir: KnowledgeDir, prefix: string): string | null => {
|
||||
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
|
||||
for (const f of dir.files) {
|
||||
if (`${dirPath}/${f.name}` === path) return f.title || fallback
|
||||
}
|
||||
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 fallback
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// Management UI state.
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [dialog, setDialog] = useState<DialogState | null>(null)
|
||||
const [status, setStatus] = useState<{ text: string; error: boolean } | null>(null)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const statusTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const showStatus = useCallback((text: string, error = false, sticky = false) => {
|
||||
if (statusTimer.current) clearTimeout(statusTimer.current)
|
||||
setStatus({ text, error })
|
||||
if (!sticky) {
|
||||
statusTimer.current = setTimeout(() => setStatus(null), 4000)
|
||||
}
|
||||
}, [])
|
||||
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Reload the tree. When targetPath is given, open it; otherwise keep the
|
||||
// currently open doc (or open the first one on the initial load).
|
||||
const refresh = useCallback(
|
||||
async (targetPath?: string) => {
|
||||
try {
|
||||
const fresh = await apiClient.getKnowledgeList()
|
||||
setData(fresh)
|
||||
if (targetPath) {
|
||||
void openDoc(targetPath, findTitle(fresh, targetPath))
|
||||
} else if (!activePath) {
|
||||
const first = firstFile(fresh)
|
||||
if (first) void openDoc(first.path, first.title)
|
||||
}
|
||||
return fresh
|
||||
} catch (e) {
|
||||
console.error('Failed to load knowledge:', e)
|
||||
return null
|
||||
}
|
||||
},
|
||||
[openDoc, activePath]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.setBaseUrl(baseUrl)
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const fresh = await apiClient.getKnowledgeList()
|
||||
if (cancelled) return
|
||||
setData(fresh)
|
||||
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
|
||||
}
|
||||
// Only run on baseUrl change (initial mount). refresh() handles later reloads.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [baseUrl])
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
// ---- Management actions --------------------------------------------------
|
||||
|
||||
const categories = useMemo(() => categoryPaths(data?.tree || []), [data])
|
||||
|
||||
const createCategory = useCallback(
|
||||
async (path: string): Promise<string | null> => {
|
||||
showStatus(t('knowledge_working'), false, true)
|
||||
try {
|
||||
const res = await apiClient.knowledgeAction({ action: 'create_category', payload: { path } })
|
||||
if (res.status !== 'success') {
|
||||
showStatus((res.message as string) || t('knowledge_request_failed'), true)
|
||||
return null
|
||||
}
|
||||
showStatus(t('knowledge_category_created'))
|
||||
await refresh()
|
||||
return path
|
||||
} catch {
|
||||
showStatus(t('knowledge_request_failed'), true)
|
||||
return null
|
||||
}
|
||||
},
|
||||
[refresh, showStatus]
|
||||
)
|
||||
|
||||
const createDocument = useCallback(
|
||||
async (path: string, content: string): Promise<string | null> => {
|
||||
showStatus(t('knowledge_working'), false, true)
|
||||
try {
|
||||
const res = await apiClient.knowledgeAction({
|
||||
action: 'create_document',
|
||||
payload: { path, content, overwrite: false },
|
||||
})
|
||||
if (res.status !== 'success') {
|
||||
showStatus((res.message as string) || t('knowledge_request_failed'), true)
|
||||
return null
|
||||
}
|
||||
const created = ((res.payload as { path?: string })?.path) || path
|
||||
showStatus(t('knowledge_document_created'))
|
||||
await refresh(created)
|
||||
return created
|
||||
} catch {
|
||||
showStatus(t('knowledge_request_failed'), true)
|
||||
return null
|
||||
}
|
||||
},
|
||||
[refresh, showStatus]
|
||||
)
|
||||
|
||||
const importDocuments = useCallback(
|
||||
async (files: File[], targetCategory: string): Promise<boolean> => {
|
||||
const err = validateImportFiles(files)
|
||||
if (err) {
|
||||
showStatus(err, true)
|
||||
return false
|
||||
}
|
||||
const supported = files.filter((f) => /\.(md|txt)$/i.test(f.name || ''))
|
||||
if (!supported.length) {
|
||||
showStatus(t('knowledge_import_choose_files'), true)
|
||||
return false
|
||||
}
|
||||
showStatus(t('knowledge_importing'), false, true)
|
||||
try {
|
||||
const res = await apiClient.importKnowledge(supported, targetCategory)
|
||||
if (res.status !== 'success') {
|
||||
showStatus(res.message || t('knowledge_import_failed'), true)
|
||||
await refresh()
|
||||
return false
|
||||
}
|
||||
const p = res.payload
|
||||
showStatus(
|
||||
tf('knowledge_import_result', {
|
||||
imported: p?.imported ?? 0,
|
||||
skipped: p?.skipped ?? 0,
|
||||
failed: p?.failed ?? 0,
|
||||
})
|
||||
)
|
||||
const first = (p?.results || []).find((r) => r.status === 'imported')
|
||||
await refresh(first?.path)
|
||||
return true
|
||||
} catch {
|
||||
showStatus(t('knowledge_import_failed'), true)
|
||||
return false
|
||||
}
|
||||
},
|
||||
[refresh, showStatus]
|
||||
)
|
||||
|
||||
// Open the import dialog after validating the chosen files.
|
||||
const startImport = useCallback(
|
||||
(files: File[]) => {
|
||||
const err = validateImportFiles(files)
|
||||
if (err) {
|
||||
showStatus(err, true)
|
||||
return
|
||||
}
|
||||
setDialog({ kind: 'import', files })
|
||||
},
|
||||
[showStatus]
|
||||
)
|
||||
|
||||
const onFilesPicked = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
e.target.value = ''
|
||||
if (files.length) startImport(files)
|
||||
},
|
||||
[startImport]
|
||||
)
|
||||
|
||||
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-2">
|
||||
{status && (
|
||||
<span
|
||||
className={`text-xs max-w-[260px] truncate ${
|
||||
status.error ? 'text-danger' : 'text-content-tertiary'
|
||||
}`}
|
||||
title={status.text}
|
||||
>
|
||||
{status.text}
|
||||
</span>
|
||||
)}
|
||||
<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>
|
||||
<NewMenu
|
||||
open={menuOpen}
|
||||
setOpen={setMenuOpen}
|
||||
onCreateCategory={() => setDialog({ kind: 'category' })}
|
||||
onCreateDocument={() => {
|
||||
if (!categories.length) {
|
||||
showStatus(t('knowledge_need_category'), true)
|
||||
return
|
||||
}
|
||||
setDialog({ kind: 'doc-pick-category' })
|
||||
}}
|
||||
onImport={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".md,.txt,text/markdown,text/plain"
|
||||
className="hidden"
|
||||
onChange={onFilesPicked}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tab === 'docs' ? (
|
||||
<div
|
||||
className="flex-1 flex min-h-0 border-t border-default relative"
|
||||
onDragEnter={(e) => {
|
||||
if (e.dataTransfer?.types?.includes('Files')) {
|
||||
e.preventDefault()
|
||||
setDragOver(true)
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (e.dataTransfer?.types?.includes('Files')) e.preventDefault()
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
// Only clear when leaving the panel, not its children.
|
||||
if (e.currentTarget === e.target) setDragOver(false)
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const files = Array.from(e.dataTransfer?.files || [])
|
||||
if (files.length) startImport(files)
|
||||
}}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="absolute inset-0 z-30 flex items-center justify-center bg-accent-soft/80 border-2 border-dashed border-accent rounded-lg m-2 pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-2 text-accent">
|
||||
<Upload size={28} />
|
||||
<p className="text-sm font-medium">{t('knowledge_drop_hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{dialog && (
|
||||
<KnowledgeDialog
|
||||
state={dialog}
|
||||
categories={categories}
|
||||
onClose={() => setDialog(null)}
|
||||
onCreateCategory={createCategory}
|
||||
onPickDocCategory={(category) => setDialog({ kind: 'document', category })}
|
||||
onCreateDocument={createDocument}
|
||||
onImport={importDocuments}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- New menu --------------------------------------------------------------
|
||||
|
||||
const NewMenu: React.FC<{
|
||||
open: boolean
|
||||
setOpen: (v: boolean) => void
|
||||
onCreateCategory: () => void
|
||||
onCreateDocument: () => void
|
||||
onImport: () => void
|
||||
}> = ({ open, setOpen, onCreateCategory, onCreateDocument, onImport }) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open, setOpen])
|
||||
|
||||
const pick = (fn: () => void) => {
|
||||
setOpen(false)
|
||||
fn()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t('knowledge_new')}
|
||||
<ChevronDown size={12} className="opacity-80" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 mt-1.5 w-44 z-50 bg-surface border border-default rounded-lg shadow-lg py-1">
|
||||
<MenuItem icon={FolderPlus} label={t('knowledge_new_category')} onClick={() => pick(onCreateCategory)} />
|
||||
<MenuItem icon={FilePlus2} label={t('knowledge_new_document')} onClick={() => pick(onCreateDocument)} />
|
||||
<MenuItem icon={Upload} label={t('knowledge_import_documents')} onClick={() => pick(onImport)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MenuItem: React.FC<{ icon: LucideIcon; label: string; onClick: () => void }> = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-content-secondary hover:bg-surface-2 cursor-pointer transition-colors text-left"
|
||||
>
|
||||
<Icon size={14} className="opacity-70 flex-shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
|
||||
// ---- Dialog ----------------------------------------------------------------
|
||||
|
||||
function templateFor(filename: string): string {
|
||||
const title = (filename || 'untitled').replace(/\.md$/i, '')
|
||||
return getLang() === 'zh'
|
||||
? `# ${title}\n\n## 摘要\n\n\n## 关键点\n\n- \n\n## 参考\n\n`
|
||||
: `# ${title}\n\n## Summary\n\n\n## Key points\n\n- \n\n## References\n\n`
|
||||
}
|
||||
|
||||
const KnowledgeDialog: React.FC<{
|
||||
state: DialogState
|
||||
categories: string[]
|
||||
onClose: () => void
|
||||
onCreateCategory: (path: string) => Promise<string | null>
|
||||
onPickDocCategory: (category: string) => void
|
||||
onCreateDocument: (path: string, content: string) => Promise<string | null>
|
||||
onImport: (files: File[], target: string) => Promise<boolean>
|
||||
}> = ({ state, categories, onClose, onCreateCategory, onPickDocCategory, onCreateDocument, onImport }) => {
|
||||
const [categoryInput, setCategoryInput] = useState('')
|
||||
const [selected, setSelected] = useState(categories[0] || '')
|
||||
const [filename, setFilename] = useState('')
|
||||
const [contentInput, setContentInput] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async () => {
|
||||
setError('')
|
||||
if (state.kind === 'category') {
|
||||
const path = categoryInput.trim()
|
||||
if (!path) return setError(t('knowledge_field_required'))
|
||||
setBusy(true)
|
||||
const ok = await onCreateCategory(path)
|
||||
setBusy(false)
|
||||
if (ok !== null) onClose()
|
||||
return
|
||||
}
|
||||
if (state.kind === 'doc-pick-category') {
|
||||
if (!selected) return setError(t('knowledge_field_required'))
|
||||
onPickDocCategory(selected)
|
||||
return
|
||||
}
|
||||
if (state.kind === 'document') {
|
||||
const name = filename.trim()
|
||||
if (!name) return setError(t('knowledge_doc_filename_required'))
|
||||
if (/\.[^.]+$/i.test(name) && !/\.md$/i.test(name)) return setError(t('knowledge_doc_must_md'))
|
||||
if (!contentInput.trim()) return setError(t('knowledge_doc_content_required'))
|
||||
if (new Blob([contentInput]).size > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
|
||||
return setError(t('knowledge_doc_content_too_large'))
|
||||
}
|
||||
const safeName = name.endsWith('.md') ? name : `${name}.md`
|
||||
setBusy(true)
|
||||
const ok = await onCreateDocument(`${state.category}/${safeName}`, contentInput)
|
||||
setBusy(false)
|
||||
if (ok !== null) onClose()
|
||||
return
|
||||
}
|
||||
if (state.kind === 'import') {
|
||||
if (!selected) return setError(t('knowledge_field_required'))
|
||||
setBusy(true)
|
||||
const ok = await onImport(state.files || [], selected)
|
||||
setBusy(false)
|
||||
if (ok) onClose()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const titleMap: Record<DialogState['kind'], string> = {
|
||||
category: t('knowledge_new_category'),
|
||||
'doc-pick-category': t('knowledge_new_document'),
|
||||
document: t('knowledge_new_document'),
|
||||
import: t('knowledge_import_documents'),
|
||||
}
|
||||
|
||||
const noCategory = (state.kind === 'doc-pick-category' || state.kind === 'import') && !categories.length
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 p-4" onMouseDown={onClose}>
|
||||
<div
|
||||
className="w-full max-w-lg bg-surface border border-default rounded-xl shadow-xl p-5"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-base font-semibold text-content mb-1">{titleMap[state.kind]}</h3>
|
||||
|
||||
{state.kind === 'category' && (
|
||||
<>
|
||||
<p className="text-xs text-content-tertiary mb-4">{t('knowledge_category_subtitle')}</p>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_category_label')}</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={categoryInput}
|
||||
onChange={(e) => setCategoryInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submit()}
|
||||
placeholder="research/ai"
|
||||
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"
|
||||
/>
|
||||
<p className="text-xs text-content-tertiary mt-1.5">{t('knowledge_category_hint')}</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === 'doc-pick-category' && (
|
||||
<>
|
||||
<p className="text-xs text-content-tertiary mb-4">{t('knowledge_doc_choose_category')}</p>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_destination')}</label>
|
||||
<CategorySelect value={selected} options={categories} onChange={setSelected} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === 'document' && (
|
||||
<>
|
||||
<p className="text-xs text-content-tertiary mb-4">
|
||||
{tf('knowledge_doc_save_to', { category: state.category || '' })}
|
||||
</p>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_doc_filename')}</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
placeholder="my-note.md"
|
||||
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 mb-3"
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm text-content-secondary">{t('knowledge_doc_content')}</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!contentInput.trim()) setContentInput(templateFor(filename))
|
||||
}}
|
||||
className="text-xs text-accent hover:underline cursor-pointer"
|
||||
>
|
||||
{t('knowledge_doc_insert_template')}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={contentInput}
|
||||
onChange={(e) => setContentInput(e.target.value)}
|
||||
rows={10}
|
||||
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 font-mono resize-y"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.kind === 'import' && (
|
||||
<>
|
||||
<p className="text-xs text-content-tertiary mb-4">
|
||||
{tf('knowledge_import_selected', { count: state.files?.length ?? 0 })}
|
||||
</p>
|
||||
<label className="block text-sm text-content-secondary mb-1.5">{t('knowledge_destination')}</label>
|
||||
<CategorySelect value={selected} options={categories} onChange={setSelected} />
|
||||
<p className="text-xs text-content-tertiary mt-1.5">
|
||||
{categories.length ? t('knowledge_import_hint') : t('knowledge_import_need_category')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-danger mt-3">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
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"
|
||||
>
|
||||
{t('knowledge_dialog_cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={busy || noCategory}
|
||||
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 disabled:cursor-not-allowed inline-flex items-center gap-1.5"
|
||||
>
|
||||
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('knowledge_dialog_confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Custom dropdown: keeps the arrow / menu styling consistent with the rest of
|
||||
// the desktop UI (a native <select> renders an OS arrow we can't space out).
|
||||
const CategorySelect: React.FC<{ value: string; options: string[]; onChange: (v: string) => void }> = ({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onClickOutside = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
return () => document.removeEventListener('mousedown', onClickOutside)
|
||||
}, [open])
|
||||
|
||||
const disabled = !options.length
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`w-full flex items-center justify-between gap-2 px-3 py-2 rounded-btn border text-sm text-content transition-colors cursor-pointer ${
|
||||
open ? 'border-accent' : 'border-strong'
|
||||
} bg-inset hover:border-accent/70 disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
<span className="truncate">{value || '--'}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`flex-shrink-0 text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-60 overflow-y-auto bg-surface border border-default rounded-lg shadow-lg p-1">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`w-full text-left px-2.5 py-1.5 rounded-md text-sm cursor-pointer transition-colors truncate ${
|
||||
opt === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</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
|
||||
335
desktop/src/renderer/src/pages/settings/BasicSettings.tsx
Normal file
335
desktop/src/renderer/src/pages/settings/BasicSettings.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
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]
|
||||
}
|
||||
|
||||
// Only list configured providers (built-in or custom). Unconfigured vendors
|
||||
// have no usable credentials, so showing them — flagged "unconfigured" — is
|
||||
// just noise. Keep the current selection so a saved value never disappears.
|
||||
const providerIds = config?.providers ? Object.keys(config.providers) : []
|
||||
const providerOptions = providerIds
|
||||
.filter((id) => isConfigured(id) || id === provider)
|
||||
.map((id) => ({
|
||||
value: id,
|
||||
label: localizedLabel(providerMeta(id)?.label) || id,
|
||||
}))
|
||||
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
|
||||
161
desktop/src/renderer/src/pages/settings/CapabilityCard.tsx
Normal file
161
desktop/src/renderer/src/pages/settings/CapabilityCard.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
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 (a custom provider counts
|
||||
// only once it actually carries a name/key, not as an empty placeholder).
|
||||
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)
|
||||
}
|
||||
|
||||
// Only surface providers that are actually configured (built-in or custom).
|
||||
// An unconfigured vendor has no usable credentials, so listing it — and
|
||||
// flagging it "unconfigured" — only adds noise. The currently-selected
|
||||
// provider is always kept so a saved value never silently disappears.
|
||||
const providerOptions: DropdownOption[] = useMemo(() => {
|
||||
const opts = (state.providers || [])
|
||||
.filter((id) => isConfigured(id) || id === provider)
|
||||
.map((id) => ({ value: id, label: providerLabel(data, id) }))
|
||||
if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts]
|
||||
return opts
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [state.providers, data, allowAuto, autoLabel, provider])
|
||||
|
||||
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
|
||||
836
desktop/src/renderer/src/pages/settings/ModelsTab.tsx
Normal file
836
desktop/src/renderer/src/pages/settings/ModelsTab.tsx
Normal file
@@ -0,0 +1,836 @@
|
||||
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. Exclude ALL
|
||||
// custom providers (named or empty placeholder): custom vendors are added via
|
||||
// the single "custom vendor" option below, so an empty custom placeholder must
|
||||
// not show up here as a second, duplicate custom entry.
|
||||
const builtins = useMemo(() => data.providers.filter((p) => !p.is_custom), [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
|
||||
57
desktop/src/renderer/src/pages/settings/modelsHelpers.ts
Normal file
57
desktop/src/renderer/src/pages/settings/modelsHelpers.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { ModelEntry, ModelOption, ModelProvider, ModelsData } from '../../types'
|
||||
import { localizedLabel } from '../../i18n'
|
||||
|
||||
// Normalize a string|{value,hint} entry into a uniform option shape.
|
||||
export function normEntry(e: ModelEntry): ModelOption {
|
||||
return typeof e === 'string' ? { value: e } : e
|
||||
}
|
||||
|
||||
export function normEntries(arr?: ModelEntry[]): ModelOption[] {
|
||||
return (arr || []).map(normEntry)
|
||||
}
|
||||
|
||||
// Resolve a human label for a provider id, falling back to the id itself.
|
||||
// Handles expanded custom ids ("custom:<id>") via the providers overview.
|
||||
export function providerLabel(data: ModelsData | null, id: string): string {
|
||||
if (!id) return ''
|
||||
const p = data?.providers?.find((x) => x.id === id)
|
||||
if (p) return localizedLabel(p.label) || id
|
||||
return id
|
||||
}
|
||||
|
||||
export function findProvider(data: ModelsData | null, id: string): ModelProvider | undefined {
|
||||
return data?.providers?.find((x) => x.id === id)
|
||||
}
|
||||
|
||||
// Resolve the model list for a capability+provider, mirroring the web console:
|
||||
// 1. capability-scoped provider_models[id] (vision/image/asr/tts/embedding)
|
||||
// 2. provider_models['custom'] for expanded custom:<id> providers
|
||||
// 3. fall back to the vendor's generic models[] (chat has no provider_models)
|
||||
export function resolveModels(
|
||||
data: ModelsData | null,
|
||||
providerId: string,
|
||||
providerModels?: Record<string, ModelEntry[]>
|
||||
): ModelOption[] {
|
||||
if (!providerId) return []
|
||||
if (providerModels?.[providerId]) return normEntries(providerModels[providerId])
|
||||
if (providerId.startsWith('custom:') && providerModels?.['custom']) {
|
||||
return normEntries(providerModels['custom'])
|
||||
}
|
||||
return normEntries(findProvider(data, providerId)?.models)
|
||||
}
|
||||
|
||||
// Voices for a tts provider may be a flat list or, for linkai, keyed by model.
|
||||
export function resolveVoices(
|
||||
provider: string,
|
||||
model: string,
|
||||
voicesMap?: Record<string, ModelEntry[] | Record<string, ModelEntry[]>>
|
||||
): ModelOption[] {
|
||||
const raw = voicesMap?.[provider]
|
||||
if (!raw) return []
|
||||
if (Array.isArray(raw)) return normEntries(raw)
|
||||
// keyed by model (linkai)
|
||||
const byModel = raw as Record<string, ModelEntry[]>
|
||||
return normEntries(byModel[model] || [])
|
||||
}
|
||||
|
||||
export const CUSTOM_OPTION = '__custom__'
|
||||
233
desktop/src/renderer/src/pages/settings/primitives.tsx
Normal file
233
desktop/src/renderer/src/pages/settings/primitives.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React, { useState, useEffect, useRef, useLayoutEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { t } from '../../i18n'
|
||||
|
||||
// Shared presentational building blocks for the settings tabs.
|
||||
|
||||
export const Card: React.FC<{ icon: React.ReactNode; title: string; subtitle?: string; children: React.ReactNode }> = ({
|
||||
icon,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}) => (
|
||||
<div className="rounded-card border border-default bg-surface p-5">
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
<div className="w-8 h-8 rounded-lg bg-accent-soft text-accent flex items-center justify-center">{icon}</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-content leading-tight">{title}</h3>
|
||||
{subtitle && <p className="text-xs text-content-tertiary mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Field: React.FC<{ label: string; hint?: string; children: React.ReactNode }> = ({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}) => (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1.5">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-content-tertiary mt-1">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
export interface DropdownOption {
|
||||
value: string
|
||||
label: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export const Dropdown: React.FC<{
|
||||
value: string
|
||||
display?: string
|
||||
placeholder?: string
|
||||
options: DropdownOption[]
|
||||
disabled?: boolean
|
||||
onChange: (val: string) => void
|
||||
}> = ({ value, display, placeholder, options, disabled, onChange }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
// The menu is rendered in a portal with fixed positioning so it's never
|
||||
// clipped by an ancestor's `overflow` (e.g. a modal's scroll container).
|
||||
const [rect, setRect] = useState<{ left: number; top: number; width: number } | null>(null)
|
||||
|
||||
const place = () => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
setRect({ left: r.left, top: r.bottom + 4, width: r.width })
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (open) place()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (ref.current?.contains(target) || menuRef.current?.contains(target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
// Keep the fixed menu anchored to the trigger on scroll/resize by
|
||||
// re-measuring — NOT closing. Closing on scroll makes the dropdown vanish
|
||||
// the moment the user scrolls the settings page.
|
||||
document.addEventListener('mousedown', onDown)
|
||||
window.addEventListener('resize', place, true)
|
||||
window.addEventListener('scroll', place, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
window.removeEventListener('resize', place, true)
|
||||
window.removeEventListener('scroll', place, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const current = display ?? options.find((o) => o.value === value)?.label ?? ''
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => !disabled && setOpen((v) => !v)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-btn border bg-inset text-sm transition-colors ${
|
||||
disabled
|
||||
? 'border-default text-content-tertiary cursor-not-allowed opacity-70'
|
||||
: 'border-strong text-content cursor-pointer hover:border-accent'
|
||||
}`}
|
||||
>
|
||||
<span className={`truncate ${current ? '' : 'text-content-tertiary'}`}>{current || placeholder || '--'}</span>
|
||||
<ChevronDown size={15} className={`text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{open &&
|
||||
rect &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{ position: 'fixed', left: rect.left, top: rect.top, width: rect.width }}
|
||||
className="z-[100] max-h-64 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg py-1"
|
||||
>
|
||||
{options.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<div
|
||||
key={o.value}
|
||||
onClick={() => {
|
||||
onChange(o.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
|
||||
o.value === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
|
||||
}`}
|
||||
>
|
||||
<div className="truncate">{o.label}</div>
|
||||
{o.hint && <div className="text-xs text-content-tertiary mt-0.5 truncate">{o.hint}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Toggle: React.FC<{ checked: boolean; onChange: (v: boolean) => void }> = ({ checked, onChange }) => (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative inline-flex h-5 w-9 flex-shrink-0 items-center rounded-full transition-colors cursor-pointer ${
|
||||
checked ? 'bg-accent' : 'bg-surface-2 border border-strong'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow transition-transform ${
|
||||
checked ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
|
||||
export const TextInput: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = (props) => (
|
||||
<input
|
||||
{...props}
|
||||
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 ${
|
||||
props.className || ''
|
||||
}`}
|
||||
/>
|
||||
)
|
||||
|
||||
export const SaveRow: React.FC<{ status: string; onSave: () => void; label?: string }> = ({
|
||||
status,
|
||||
onSave,
|
||||
label,
|
||||
}) => (
|
||||
<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
|
||||
onClick={onSave}
|
||||
className="px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
|
||||
>
|
||||
{label ?? t('config_save')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const MASK_RE = /[*•]/
|
||||
|
||||
export const Modal: React.FC<{
|
||||
open: boolean
|
||||
title: string
|
||||
onClose: () => void
|
||||
children: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}> = ({ open, title, onClose, children, footer }) => {
|
||||
if (!open) return null
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-card border border-default bg-elevated shadow-xl">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-default">
|
||||
<h3 className="font-semibold text-content">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-content-tertiary hover:text-content cursor-pointer text-lg leading-none px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-4 max-h-[60vh] overflow-y-auto">{children}</div>
|
||||
{footer && <div className="flex items-center justify-end gap-2 px-5 py-3.5 border-t border-default">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Btn: React.FC<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'primary' | 'ghost' | 'danger' }
|
||||
> = ({ variant = 'ghost', className, children, ...props }) => {
|
||||
const styles =
|
||||
variant === 'primary'
|
||||
? 'bg-accent text-accent-contrast hover:bg-accent-hover'
|
||||
: variant === 'danger'
|
||||
? 'bg-danger-soft text-danger hover:bg-danger/15 border border-danger-border'
|
||||
: 'border border-strong text-content-secondary hover:bg-surface-2'
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={`px-4 py-2 rounded-btn text-sm font-medium cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${styles} ${className || ''}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
488
desktop/src/renderer/src/store/chatStore.ts
Normal file
488
desktop/src/renderer/src/store/chatStore.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
import { create } from 'zustand'
|
||||
import apiClient from '../api/client'
|
||||
import type { ChatMessage, MessageStep, Attachment, StreamEvent, HistoryMessage } from '../types'
|
||||
|
||||
/**
|
||||
* Per-session chat state. Supports parallel sessions: each session keeps its
|
||||
* own message list and active stream, so switching sessions never interrupts a
|
||||
* background run. The active EventSource lives in `streams` (outside React).
|
||||
*/
|
||||
|
||||
interface SessionRuntime {
|
||||
messages: ChatMessage[]
|
||||
isStreaming: boolean
|
||||
requestId: string | null
|
||||
// history pagination
|
||||
historyPage: number
|
||||
historyHasMore: boolean
|
||||
historyLoaded: boolean
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
sessions: Record<string, SessionRuntime>
|
||||
|
||||
getSession: (sid: string) => SessionRuntime
|
||||
ensureSession: (sid: string) => void
|
||||
|
||||
send: (sid: string, text: string, attachments: Attachment[]) => Promise<void>
|
||||
cancel: (sid: string) => Promise<void>
|
||||
regenerate: (sid: string, botMessageId: string) => Promise<void>
|
||||
editUserMessage: (sid: string, messageId: string) => { text: string; attachments: Attachment[] } | null
|
||||
deleteMessage: (sid: string, userSeq: number, cascade: boolean) => Promise<void>
|
||||
|
||||
loadHistory: (sid: string, page?: number) => Promise<void>
|
||||
clearLocal: (sid: string) => void
|
||||
}
|
||||
|
||||
// EventSource instances kept outside the store (not serializable).
|
||||
const streams: Record<string, EventSource> = {}
|
||||
|
||||
const EMPTY: SessionRuntime = {
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
requestId: null,
|
||||
historyPage: 0,
|
||||
historyHasMore: false,
|
||||
historyLoaded: false,
|
||||
}
|
||||
|
||||
function uid(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* History keeps the English cancel marker for the LLM; strip it for display so
|
||||
* the bubble shows a clean answer + a dedicated "cancelled" badge instead.
|
||||
*/
|
||||
function stripCancelMarker(text: string): string {
|
||||
if (!text) return text
|
||||
return text
|
||||
.replace(/_\(Cancelled by user\)_/g, '')
|
||||
.replace(/_\(Cancelled\)_/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild attachments from `send`-tool results persisted in the message steps.
|
||||
* SSE `file_to_send` events aren't stored, so on history reload the only record
|
||||
* of a sent image/file is the tool result JSON. Mirrors the web console's
|
||||
* `_renderSentFileFromToolResult` so media survives an app restart.
|
||||
*/
|
||||
function attachmentsFromSteps(steps: MessageStep[]): Attachment[] {
|
||||
const out: Attachment[] = []
|
||||
for (const s of steps) {
|
||||
if (s.type !== 'tool' || !s.result) continue
|
||||
let payload: Record<string, unknown>
|
||||
try {
|
||||
payload = typeof s.result === 'string' ? JSON.parse(s.result) : (s.result as unknown as Record<string, unknown>)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!payload || payload.type !== 'file_to_send') continue
|
||||
const rawPath = (payload.path as string) || ''
|
||||
const url = (payload.url as string) || ''
|
||||
if (!rawPath && !url) continue
|
||||
const isRemote = url.toLowerCase().startsWith('http://') || url.toLowerCase().startsWith('https://')
|
||||
// Local files are served via /api/file; remote URLs are used directly.
|
||||
const previewUrl = isRemote
|
||||
? url
|
||||
: rawPath.toLowerCase().startsWith('http')
|
||||
? rawPath
|
||||
: apiClient.getServeFileUrl(rawPath)
|
||||
const kind = (payload.file_type as string) || 'file'
|
||||
const fileType: Attachment['file_type'] =
|
||||
kind === 'image' ? 'image' : kind === 'video' ? 'video' : 'file'
|
||||
out.push({
|
||||
file_path: previewUrl,
|
||||
file_name: (payload.file_name as string) || 'file',
|
||||
file_type: fileType,
|
||||
preview_url: previewUrl,
|
||||
abs_path: isRemote ? undefined : rawPath,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Convert a backend history message into a UI ChatMessage. */
|
||||
function historyToMessage(m: HistoryMessage): ChatMessage {
|
||||
if (m.role === 'user') {
|
||||
return {
|
||||
id: uid('user'),
|
||||
role: 'user',
|
||||
content: m.content,
|
||||
timestamp: m.created_at,
|
||||
userSeq: m._seq,
|
||||
}
|
||||
}
|
||||
|
||||
// The backend stores the final answer both as `content` and as the LAST
|
||||
// `content` step. Strip that trailing content step so it isn't rendered
|
||||
// twice (matches the web console's renderStepsHtml logic).
|
||||
const raw = m.steps || []
|
||||
let lastContentIdx = -1
|
||||
for (let i = raw.length - 1; i >= 0; i--) {
|
||||
if (raw[i].type === 'content') {
|
||||
lastContentIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
const steps: MessageStep[] = raw
|
||||
.filter((_, i) => i !== lastContentIdx)
|
||||
.map((s) => ({ ...s }))
|
||||
const finalContent = m.content || (lastContentIdx >= 0 ? raw[lastContentIdx].content || '' : '')
|
||||
const attachments = attachmentsFromSteps(raw)
|
||||
|
||||
return {
|
||||
id: uid('assistant'),
|
||||
role: 'assistant',
|
||||
content: finalContent,
|
||||
timestamp: m.created_at,
|
||||
steps,
|
||||
reasoning: m.reasoning,
|
||||
kind: m.kind,
|
||||
extras: m.extras,
|
||||
botSeq: m._seq,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => {
|
||||
// --- helpers operating on a single session immutably ---
|
||||
const patchSession = (sid: string, patch: Partial<SessionRuntime>) =>
|
||||
set((st) => ({
|
||||
sessions: { ...st.sessions, [sid]: { ...(st.sessions[sid] || EMPTY), ...patch } },
|
||||
}))
|
||||
|
||||
const patchMessages = (sid: string, fn: (msgs: ChatMessage[]) => ChatMessage[]) =>
|
||||
set((st) => {
|
||||
const cur = st.sessions[sid] || EMPTY
|
||||
return { sessions: { ...st.sessions, [sid]: { ...cur, messages: fn(cur.messages) } } }
|
||||
})
|
||||
|
||||
const updateMsg = (sid: string, id: string, fn: (m: ChatMessage) => ChatMessage) =>
|
||||
patchMessages(sid, (msgs) => msgs.map((m) => (m.id === id ? fn(m) : m)))
|
||||
|
||||
/** Attach an EventSource for a request and wire all SSE events to a bot message. */
|
||||
const attachStream = (sid: string, requestId: string, botId: string) => {
|
||||
const es = apiClient.createSSEStream(requestId)
|
||||
streams[sid] = es
|
||||
let tailTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const closeStream = () => {
|
||||
if (tailTimer) {
|
||||
clearTimeout(tailTimer)
|
||||
tailTimer = null
|
||||
}
|
||||
es.close()
|
||||
if (streams[sid] === es) delete streams[sid]
|
||||
}
|
||||
|
||||
// Mark the turn as complete: UI becomes interactive again immediately.
|
||||
const completeTurn = () => {
|
||||
patchSession(sid, { isStreaming: false, requestId: null })
|
||||
updateMsg(sid, botId, (m) => ({ ...m, isStreaming: false }))
|
||||
}
|
||||
|
||||
const finishStream = () => {
|
||||
completeTurn()
|
||||
closeStream()
|
||||
}
|
||||
|
||||
es.onmessage = (event) => {
|
||||
let data: StreamEvent
|
||||
try {
|
||||
data = JSON.parse(event.data)
|
||||
} catch {
|
||||
return // keepalive
|
||||
}
|
||||
|
||||
switch (data.type) {
|
||||
case 'reasoning':
|
||||
updateMsg(sid, botId, (m) => ({ ...m, reasoning: (m.reasoning || '') + (data.content || '') }))
|
||||
break
|
||||
|
||||
case 'delta':
|
||||
updateMsg(sid, botId, (m) => ({ ...m, content: m.content + (data.content || '') }))
|
||||
break
|
||||
|
||||
case 'message_end':
|
||||
// Freeze accumulated text as a content step when tool calls follow,
|
||||
// mirroring the web console's interleaved step model.
|
||||
if (data.has_tool_calls) {
|
||||
updateMsg(sid, botId, (m) => {
|
||||
if (!m.content.trim()) return m
|
||||
const steps = [...(m.steps || []), { type: 'content' as const, content: m.content.trim() }]
|
||||
return { ...m, steps, content: '' }
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'tool_start':
|
||||
updateMsg(sid, botId, (m) => {
|
||||
// commit any reasoning into a thinking step
|
||||
const steps = [...(m.steps || [])]
|
||||
if (m.reasoning && m.reasoning.trim()) {
|
||||
steps.push({ type: 'thinking', content: m.reasoning.trim() })
|
||||
}
|
||||
steps.push({
|
||||
type: 'tool',
|
||||
id: data.tool_call_id,
|
||||
name: data.tool,
|
||||
arguments: data.arguments,
|
||||
status: 'running',
|
||||
})
|
||||
return { ...m, steps, reasoning: '', content: '' }
|
||||
})
|
||||
break
|
||||
|
||||
case 'tool_progress':
|
||||
updateMsg(sid, botId, (m) => ({
|
||||
...m,
|
||||
steps: (m.steps || []).map((s) =>
|
||||
s.type === 'tool' && s.id === data.tool_call_id ? { ...s, result: data.content } : s
|
||||
),
|
||||
}))
|
||||
break
|
||||
|
||||
case 'tool_end':
|
||||
updateMsg(sid, botId, (m) => ({
|
||||
...m,
|
||||
steps: (m.steps || []).map((s) =>
|
||||
s.type === 'tool' && s.id === data.tool_call_id
|
||||
? {
|
||||
...s,
|
||||
status: data.status,
|
||||
result: data.result ?? s.result,
|
||||
execution_time: data.execution_time,
|
||||
is_error: data.status !== 'success',
|
||||
}
|
||||
: s
|
||||
),
|
||||
}))
|
||||
break
|
||||
|
||||
case 'image':
|
||||
case 'file': {
|
||||
// Media pushed by the `send` tool (file_to_send). `content` is either
|
||||
// a backend /api/file?path=... URL or a passed-through http(s) URL.
|
||||
const url = data.content || ''
|
||||
if (!url) break
|
||||
// Prefer the concrete media kind from the backend (image/video/...);
|
||||
// fall back to the coarse SSE event type.
|
||||
const kind = data.file_type || (data.type === 'image' ? 'image' : 'file')
|
||||
const attType: Attachment['file_type'] =
|
||||
kind === 'image' ? 'image' : kind === 'video' ? 'video' : 'file'
|
||||
const att: Attachment = {
|
||||
file_path: url,
|
||||
file_name: data.file_name || 'file',
|
||||
file_type: attType,
|
||||
preview_url: url,
|
||||
abs_path: data.abs_path,
|
||||
}
|
||||
updateMsg(sid, botId, (m) => ({
|
||||
...m,
|
||||
attachments: [...(m.attachments || []), att],
|
||||
}))
|
||||
break
|
||||
}
|
||||
|
||||
case 'cancelled':
|
||||
updateMsg(sid, botId, (m) => ({ ...m, isCancelled: true }))
|
||||
break
|
||||
|
||||
case 'done':
|
||||
updateMsg(sid, botId, (m) => {
|
||||
const next = stripCancelMarker(data.content || m.content)
|
||||
return {
|
||||
...m,
|
||||
content: next,
|
||||
botSeq: data.bot_seq ?? m.botSeq,
|
||||
isStreaming: false,
|
||||
}
|
||||
})
|
||||
// backfill the preceding user message's seq for edit/delete
|
||||
if (data.user_seq != null) {
|
||||
patchMessages(sid, (msgs) => {
|
||||
const idx = msgs.findIndex((m) => m.id === botId)
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
if (msgs[i].role === 'user') {
|
||||
msgs[i] = { ...msgs[i], userSeq: data.user_seq }
|
||||
break
|
||||
}
|
||||
}
|
||||
return [...msgs]
|
||||
})
|
||||
}
|
||||
// The answer is final: free the UI now (don't wait for onerror).
|
||||
completeTurn()
|
||||
// Backend keeps the stream open for a short tail (e.g. TTS audio via
|
||||
// voice_attach). Close it ourselves if nothing else arrives.
|
||||
if (tailTimer) clearTimeout(tailTimer)
|
||||
tailTimer = setTimeout(closeStream, 1500)
|
||||
break
|
||||
|
||||
case 'voice_attach':
|
||||
if (data.audio_url) {
|
||||
updateMsg(sid, botId, (m) => ({
|
||||
...m,
|
||||
extras: { ...(m.extras || {}), audio: data.audio_url },
|
||||
}))
|
||||
}
|
||||
finishStream()
|
||||
break
|
||||
|
||||
case 'error':
|
||||
updateMsg(sid, botId, (m) => ({ ...m, error: data.message || 'stream error', isStreaming: false }))
|
||||
finishStream()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
// Stream closed (often the normal end after `done`/tail). Finalize.
|
||||
finishStream()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: {},
|
||||
|
||||
getSession: (sid) => get().sessions[sid] || EMPTY,
|
||||
|
||||
ensureSession: (sid) => {
|
||||
if (!get().sessions[sid]) patchSession(sid, { ...EMPTY })
|
||||
},
|
||||
|
||||
send: async (sid, text, attachments) => {
|
||||
const userMsg: ChatMessage = {
|
||||
id: uid('user'),
|
||||
role: 'user',
|
||||
content: text,
|
||||
timestamp: Date.now() / 1000,
|
||||
attachments: attachments.length ? attachments : undefined,
|
||||
}
|
||||
const botId = uid('assistant')
|
||||
const botMsg: ChatMessage = {
|
||||
id: botId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now() / 1000,
|
||||
steps: [],
|
||||
isStreaming: true,
|
||||
}
|
||||
patchMessages(sid, (msgs) => [...msgs, userMsg, botMsg])
|
||||
patchSession(sid, { isStreaming: true })
|
||||
|
||||
try {
|
||||
const res = await apiClient.sendMessage(sid, text, {
|
||||
stream: true,
|
||||
attachments: attachments.length ? attachments : undefined,
|
||||
})
|
||||
if (res.status === 'success' && res.stream && res.request_id) {
|
||||
patchSession(sid, { requestId: res.request_id })
|
||||
attachStream(sid, res.request_id, botId)
|
||||
} else if (res.inline_reply) {
|
||||
updateMsg(sid, botId, (m) => ({ ...m, content: res.inline_reply || '', isStreaming: false }))
|
||||
patchSession(sid, { isStreaming: false })
|
||||
} else {
|
||||
updateMsg(sid, botId, (m) => ({ ...m, error: 'send failed', isStreaming: false }))
|
||||
patchSession(sid, { isStreaming: false })
|
||||
}
|
||||
} catch (err) {
|
||||
updateMsg(sid, botId, (m) => ({ ...m, error: `${err}`, isStreaming: false }))
|
||||
patchSession(sid, { isStreaming: false })
|
||||
}
|
||||
},
|
||||
|
||||
cancel: async (sid) => {
|
||||
const s = get().sessions[sid]
|
||||
if (!s?.requestId) return
|
||||
try {
|
||||
await apiClient.cancel({ requestId: s.requestId, sessionId: sid })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
regenerate: async (sid, botMessageId) => {
|
||||
const s = get().sessions[sid] || EMPTY
|
||||
const idx = s.messages.findIndex((m) => m.id === botMessageId)
|
||||
if (idx < 0) return
|
||||
// find the user message that produced this bot reply
|
||||
let userMsg: ChatMessage | null = null
|
||||
for (let i = idx - 1; i >= 0; i--) {
|
||||
if (s.messages[i].role === 'user') {
|
||||
userMsg = s.messages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!userMsg) return
|
||||
// delete the turn on the backend (by the user's seq) then resend
|
||||
if (userMsg.userSeq != null) {
|
||||
try {
|
||||
await apiClient.deleteMessage({ sessionId: sid, userSeq: userMsg.userSeq, deleteUser: true, cascade: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
// drop the user+bot messages locally from idx-? : remove from the user msg onward
|
||||
const userIdx = s.messages.indexOf(userMsg)
|
||||
patchMessages(sid, (msgs) => msgs.slice(0, userIdx))
|
||||
await get().send(sid, userMsg.content, userMsg.attachments || [])
|
||||
},
|
||||
|
||||
editUserMessage: (sid, messageId) => {
|
||||
const s = get().sessions[sid] || EMPTY
|
||||
const msg = s.messages.find((m) => m.id === messageId)
|
||||
if (!msg || msg.role !== 'user') return null
|
||||
const userIdx = s.messages.indexOf(msg)
|
||||
// cascade-delete this turn on the backend
|
||||
if (msg.userSeq != null) {
|
||||
apiClient
|
||||
.deleteMessage({ sessionId: sid, userSeq: msg.userSeq, deleteUser: true, cascade: true })
|
||||
.catch(() => {})
|
||||
}
|
||||
patchMessages(sid, (msgs) => msgs.slice(0, userIdx))
|
||||
return { text: msg.content, attachments: msg.attachments || [] }
|
||||
},
|
||||
|
||||
deleteMessage: async (sid, userSeq, cascade) => {
|
||||
try {
|
||||
await apiClient.deleteMessage({ sessionId: sid, userSeq, deleteUser: true, cascade })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// reload history to reflect server state
|
||||
await get().loadHistory(sid, 1)
|
||||
},
|
||||
|
||||
loadHistory: async (sid, page = 1) => {
|
||||
try {
|
||||
const res = await apiClient.getHistory(sid, page, 20)
|
||||
const uiMsgs = res.messages.map(historyToMessage)
|
||||
patchSession(sid, {
|
||||
historyPage: res.page,
|
||||
historyHasMore: res.has_more,
|
||||
historyLoaded: true,
|
||||
})
|
||||
if (page === 1) {
|
||||
patchMessages(sid, () => uiMsgs)
|
||||
} else {
|
||||
// older page: prepend
|
||||
patchMessages(sid, (msgs) => [...uiMsgs, ...msgs])
|
||||
}
|
||||
} catch {
|
||||
patchSession(sid, { historyLoaded: true })
|
||||
}
|
||||
},
|
||||
|
||||
clearLocal: (sid) => {
|
||||
const es = streams[sid]
|
||||
if (es) {
|
||||
es.close()
|
||||
delete streams[sid]
|
||||
}
|
||||
patchSession(sid, { ...EMPTY })
|
||||
},
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user