mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
Compare commits
82 Commits
v0.0.3-des
...
2.1.3
| 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 | ||
|
|
9ea0017778 | ||
|
|
f1cdc2d2cc | ||
|
|
5db2998e3d | ||
|
|
0bc0f2b930 | ||
|
|
84d6848e67 |
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
|
||||||
161
.github/workflows/release.yml
vendored
161
.github/workflows/release.yml
vendored
@@ -1,14 +1,22 @@
|
|||||||
name: Release Desktop
|
name: Release Desktop
|
||||||
|
|
||||||
# Tag-driven release: push a tag like `v1.2.0` to build and publish the
|
# STAGE 1 of the decoupled release pipeline: BUILD ONLY.
|
||||||
# desktop client for macOS (arm64 + x64) and Windows (x64). The tag is the
|
# Builds the desktop client for macOS (arm64 + x64) and Windows (x64), mirrors
|
||||||
# single source of truth for the version — it's written into package.json at
|
# the installers to R2, and registers them in D1 as UNPUBLISHED (is_latest=0)
|
||||||
# build time, so the maintainer never edits the version by hand.
|
# 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:
|
on:
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
# Manual trigger for testing the full pipeline without cutting a real tag.
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
version:
|
version:
|
||||||
@@ -97,6 +105,14 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version
|
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)
|
- name: Build & publish (electron-builder)
|
||||||
working-directory: desktop
|
working-directory: desktop
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -108,41 +124,67 @@ jobs:
|
|||||||
# is the correct state for unsigned builds.
|
# is the correct state for unsigned builds.
|
||||||
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
|
||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
|
||||||
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
||||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
npm run build
|
# 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
|
||||||
|
|
||||||
# Only export signing vars when provided. Empty strings are NOT the
|
# Never let electron-builder publish: our publish target is a generic
|
||||||
# same as unset to electron-builder: an empty CSC_LINK is treated as
|
# (read-only) feed served from R2/D1, which it can't upload to. We mirror
|
||||||
# a (broken) certificate path and aborts the build. Leaving them
|
# installers to R2 and register them in D1 ourselves (publish-r2 job).
|
||||||
# unset makes electron-builder fall back to an unsigned build.
|
# `--publish never` still emits the latest*.yml files.
|
||||||
if [ -n "$MAC_CSC_LINK" ]; then
|
#
|
||||||
export CSC_LINK="$MAC_CSC_LINK"
|
# CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to
|
||||||
export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD"
|
# inject mac.binaries (the backend Mach-O files to hardened-sign for
|
||||||
fi
|
# notarization) — it's a pure no-op on Windows. Passing --config on
|
||||||
if [ -z "$WIN_CSC_LINK" ]; then
|
# Windows was what silently broke the Windows build (it produced no
|
||||||
unset WIN_CSC_LINK WIN_CSC_KEY_PASSWORD
|
# installer while the job still reported success; Windows worked fine
|
||||||
fi
|
# before --config was introduced). So Windows uses the plain
|
||||||
|
# package.json build config and only mac uses the dynamic one.
|
||||||
# Publish to the GitHub Release on tag pushes; otherwise build only.
|
#
|
||||||
if [ "${{ github.event_name }}" = "push" ]; then
|
# Invoke via `node <cli.js>` rather than `npx`: on Windows `npx` is
|
||||||
PUBLISH=always
|
# npx.cmd (a batch wrapper) and running it from this Git Bash step can
|
||||||
else
|
# make bash return before the wrapped process finishes. node skips it.
|
||||||
PUBLISH=never
|
case "${{ matrix.platform }}" in
|
||||||
fi
|
mac) config_arg="--config electron-builder.js" ;;
|
||||||
npx electron-builder ${{ matrix.eb_flags }} --publish "$PUBLISH"
|
*) 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
|
- name: Upload artifacts
|
||||||
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
# One bundle per platform/arch so the publish job can collect them all.
|
# One bundle per platform/arch so the publish job can collect them all.
|
||||||
name: cowagent-${{ matrix.platform }}-${{ matrix.arch }}
|
name: cowagent-${{ matrix.platform }}-${{ matrix.arch }}
|
||||||
path: |
|
path: |
|
||||||
desktop/release/*.dmg
|
desktop/release/*.dmg
|
||||||
|
desktop/release/*.zip
|
||||||
desktop/release/*.exe
|
desktop/release/*.exe
|
||||||
desktop/release/*.yml
|
desktop/release/*.yml
|
||||||
desktop/release/*.blockmap
|
desktop/release/*.blockmap
|
||||||
@@ -201,10 +243,13 @@ jobs:
|
|||||||
id: stage
|
id: stage
|
||||||
run: |
|
run: |
|
||||||
mkdir -p dist
|
mkdir -p dist
|
||||||
# Flatten installers from every per-platform artifact dir; only the
|
# Flatten installers + their .blockmap (used by electron-updater for
|
||||||
# user-facing installers go to R2 (updater .yml/.blockmap stay on the
|
# differential downloads) from every per-platform artifact dir. The
|
||||||
# GitHub Release, which electron-updater reads directly).
|
# .yml feed is generated dynamically by the /update Function from D1,
|
||||||
find artifacts -type f \( -name '*.dmg' -o -name '*.exe' \) -exec cp {} dist/ \;
|
# 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
|
echo "Staged files:"; ls -la dist
|
||||||
# When the whole matrix failed there's nothing to publish; flag it so
|
# 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.
|
# the R2/D1 steps skip instead of writing an empty/partial release.
|
||||||
@@ -239,36 +284,18 @@ jobs:
|
|||||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||||
VER: ${{ steps.ver.outputs.version }}
|
VER: ${{ steps.ver.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
# Map each installer filename to a platform id. dmg arch is in the
|
# This build job ALWAYS registers rows as unpublished (is_latest=0), so
|
||||||
# name (…-arm64.dmg / …-x64.dmg); .exe is the Windows installer.
|
# /download/<p>/latest keeps serving the previous release and the new
|
||||||
sql_file="$(mktemp)"
|
# 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)."
|
||||||
|
|
||||||
# Pre-releases (e.g. 1.0.0-test / -beta / -rc.1 / -alpha / -dev) are
|
# Build one upsert per (version, platform) carrying both the dmg
|
||||||
# recorded but NEVER marked latest, so /download/<p>/latest keeps
|
# (manual download) and the mac zip (auto-update) columns. See
|
||||||
# serving the last stable build. They also must not clear an existing
|
# .github/scripts/register-releases.mjs for the mapping. No --latest
|
||||||
# stable's latest flag. Only a final version (no pre-release suffix)
|
# here: rows stay unpublished until the publish workflow promotes them.
|
||||||
# becomes the new latest and clears the previous one per platform.
|
node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql
|
||||||
case "$VER" in
|
echo "==> D1 statements:"; cat d1.sql
|
||||||
*-*) is_latest=0; echo "==> $VER is a pre-release; not marking latest." ;;
|
npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql
|
||||||
*) is_latest=1; echo "==> $VER is a stable release; marking latest." ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
for f in dist/*; do
|
|
||||||
base="$(basename "$f")"
|
|
||||||
size="$(stat -c%s "$f")"
|
|
||||||
case "$base" in
|
|
||||||
*arm64.dmg) platform="mac-arm64" ;;
|
|
||||||
*x64.dmg) platform="mac-x64" ;;
|
|
||||||
*.exe) platform="win" ;;
|
|
||||||
*) echo "Skipping unrecognized artifact: $base"; continue ;;
|
|
||||||
esac
|
|
||||||
key="v${VER}/${base}"
|
|
||||||
# Stable only: clear the previous latest for THIS platform first, so
|
|
||||||
# a partial backfill never wipes other platforms' latest flag.
|
|
||||||
if [ "$is_latest" = "1" ]; then
|
|
||||||
echo "UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';" >> "$sql_file"
|
|
||||||
fi
|
|
||||||
echo "INSERT OR REPLACE INTO releases (version, platform, filename, size, is_latest) VALUES ('${VER}', '${platform}', '${key}', ${size}, ${is_latest});" >> "$sql_file"
|
|
||||||
done
|
|
||||||
echo "==> D1 statements:"; cat "$sql_file"
|
|
||||||
npx --yes wrangler@latest d1 execute cow-desktop --remote --file "$sql_file"
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -54,7 +54,11 @@ desktop/build/*
|
|||||||
!desktop/build/cowagent-backend.spec
|
!desktop/build/cowagent-backend.spec
|
||||||
!desktop/build/requirements-desktop.txt
|
!desktop/build/requirements-desktop.txt
|
||||||
!desktop/build/build-backend.sh
|
!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
|
# Icon authoring scratch dir: intermediate assets used to produce the final
|
||||||
# icons. Only the finished icons under desktop/resources/ should be committed.
|
# icons. Only the finished icons under desktop/resources/ should be committed.
|
||||||
desktop/resources/.icon-work/
|
desktop/resources/.icon-work/
|
||||||
|
|
||||||
|
.wrangler/
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -12,7 +12,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<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>
|
</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.
|
**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/intro/index">📖 Docs</a> ·
|
||||||
<a href="https://docs.cowagent.ai/guide/quick-start">🚀 Quick Start</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://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>
|
<a href="https://link-ai.tech/cowagent/create">☁️ Try Online</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -95,6 +96,8 @@ cow skill install <name> # install a skill
|
|||||||
cow install-browser # install browser automation
|
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/>
|
<br/>
|
||||||
|
|
||||||
## 🤖 Models
|
## 🤖 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 |
|
| 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 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
@@ -199,6 +202,8 @@ Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creatin
|
|||||||
|
|
||||||
## 🏷 Changelog
|
## 🏷 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.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements.
|
||||||
|
|
||||||
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — Self-Evolution, Web console upgrades (message management, parallel sessions), cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus), Python 3.13 support.
|
> **2026.06.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,
|
enable_skills=True,
|
||||||
runtime_info=getattr(agent, "runtime_info", None),
|
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.
|
# Reuse the live model so it follows the user's configured model.
|
||||||
review_agent.model = agent.model
|
review_agent.model = agent.model
|
||||||
# Inject the evolution task brief AFTER the full system prompt: the agent
|
# Inject the evolution task brief AFTER the full system prompt: the agent
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import shutil
|
|||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Iterable
|
from typing import Optional, Iterable
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
from config import conf
|
from config import conf
|
||||||
@@ -32,6 +33,10 @@ class KnowledgeService:
|
|||||||
|
|
||||||
PROTECTED_FILES = {"index.md", "log.md"}
|
PROTECTED_FILES = {"index.md", "log.md"}
|
||||||
INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]')
|
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):
|
def __init__(self, workspace_root: str, memory_manager=None):
|
||||||
self.workspace_root = os.path.abspath(workspace_root)
|
self.workspace_root = os.path.abspath(workspace_root)
|
||||||
@@ -75,7 +80,14 @@ class KnowledgeService:
|
|||||||
|
|
||||||
def _manager(self):
|
def _manager(self):
|
||||||
if self._memory_manager is None:
|
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
|
return self._memory_manager
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -100,9 +112,9 @@ class KnowledgeService:
|
|||||||
raise error[0]
|
raise error[0]
|
||||||
return result[0] if result else None
|
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))
|
old_paths = sorted(set(old_paths))
|
||||||
if not old_paths:
|
if not old_paths and not force:
|
||||||
return
|
return
|
||||||
manager = self._manager()
|
manager = self._manager()
|
||||||
for rel_path in old_paths:
|
for rel_path in old_paths:
|
||||||
@@ -110,6 +122,195 @@ class KnowledgeService:
|
|||||||
manager.mark_dirty()
|
manager.mark_dirty()
|
||||||
self._run_sync(manager.sync())
|
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:
|
def create_category(self, path: str) -> dict:
|
||||||
rel_path, full_path = self._resolve_path(path, kind="category")
|
rel_path, full_path = self._resolve_path(path, kind="category")
|
||||||
if full_path.exists():
|
if full_path.exists():
|
||||||
@@ -283,14 +484,18 @@ class KnowledgeService:
|
|||||||
if not is_root:
|
if not is_root:
|
||||||
stats["pages"] += 1
|
stats["pages"] += 1
|
||||||
stats["size"] += size
|
stats["size"] += size
|
||||||
title = name.replace(".md", "")
|
# Prefer the H1 heading as a readable title for normal docs.
|
||||||
try:
|
# System files (index.md / log.md) keep their filename so the
|
||||||
with open(full, "r", encoding="utf-8") as f:
|
# tree never hides what they actually are.
|
||||||
first_line = f.readline().strip()
|
title = name[:-3]
|
||||||
if first_line.startswith("# "):
|
if name not in self.PROTECTED_FILES:
|
||||||
title = first_line[2:].strip()
|
try:
|
||||||
except Exception:
|
with open(full, "r", encoding="utf-8") as f:
|
||||||
pass
|
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})
|
files.append({"name": name, "title": title, "size": size})
|
||||||
return files, children
|
return files, children
|
||||||
|
|
||||||
@@ -416,6 +621,15 @@ class KnowledgeService:
|
|||||||
result = self.delete_documents(payload.get("paths") or [])
|
result = self.delete_documents(payload.get("paths") or [])
|
||||||
elif action == "move_documents":
|
elif action == "move_documents":
|
||||||
result = self.move_documents(payload.get("paths") or [], payload.get("target_category"))
|
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:
|
else:
|
||||||
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None}
|
||||||
return {"action": action, "code": 200, "message": "success", "payload": result}
|
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.manager import MemoryManager
|
||||||
from agent.memory.config import MemoryConfig, get_default_memory_config, set_global_memory_config
|
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.conversation_store import ConversationStore, get_conversation_store
|
||||||
from agent.memory.summarizer import ensure_daily_memory_file
|
from agent.memory.summarizer import ensure_daily_memory_file
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ __all__ = [
|
|||||||
'get_default_memory_config',
|
'get_default_memory_config',
|
||||||
'set_global_memory_config',
|
'set_global_memory_config',
|
||||||
'create_embedding_provider',
|
'create_embedding_provider',
|
||||||
|
'create_default_embedding_provider',
|
||||||
'ConversationStore',
|
'ConversationStore',
|
||||||
'get_conversation_store',
|
'get_conversation_store',
|
||||||
'ensure_daily_memory_file',
|
'ensure_daily_memory_file',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from agent.memory.embedding.provider import (
|
|||||||
OpenAIEmbeddingProvider,
|
OpenAIEmbeddingProvider,
|
||||||
create_embedding_provider,
|
create_embedding_provider,
|
||||||
)
|
)
|
||||||
|
from agent.memory.embedding.factory import create_default_embedding_provider
|
||||||
from agent.memory.embedding.rebuild import (
|
from agent.memory.embedding.rebuild import (
|
||||||
RebuildResult,
|
RebuildResult,
|
||||||
clear_index,
|
clear_index,
|
||||||
@@ -33,6 +34,7 @@ __all__ = [
|
|||||||
"EmbeddingProvider",
|
"EmbeddingProvider",
|
||||||
"OpenAIEmbeddingProvider",
|
"OpenAIEmbeddingProvider",
|
||||||
"create_embedding_provider",
|
"create_embedding_provider",
|
||||||
|
"create_default_embedding_provider",
|
||||||
"RebuildResult",
|
"RebuildResult",
|
||||||
"clear_index",
|
"clear_index",
|
||||||
"rebuild_in_process",
|
"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
|
||||||
@@ -163,10 +163,9 @@ def main() -> int:
|
|||||||
logger.info(f"[RebuildIndex] Workspace: {workspace_root}")
|
logger.info(f"[RebuildIndex] Workspace: {workspace_root}")
|
||||||
logger.info(f"[RebuildIndex] Index db: {memory_config.get_db_path()}")
|
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 = create_default_embedding_provider()
|
||||||
embedding_provider = initializer._init_embedding_provider(memory_config, session_id=None)
|
|
||||||
if embedding_provider is None:
|
if embedding_provider is None:
|
||||||
logger.error(
|
logger.error(
|
||||||
"[RebuildIndex] No embedding provider could be initialized. "
|
"[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)
|
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)
|
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:
|
if not self.llm_model:
|
||||||
logger.warning("[DeepDream] No LLM model available, skipping")
|
logger.warning("[DeepDream] No LLM model available, skipping")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -379,6 +379,12 @@ class AgentStreamExecutor:
|
|||||||
|
|
||||||
self._emit_event("agent_start")
|
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 = ""
|
final_response = ""
|
||||||
turn = 0
|
turn = 0
|
||||||
|
|
||||||
@@ -702,6 +708,70 @@ class AgentStreamExecutor:
|
|||||||
|
|
||||||
return final_response
|
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,
|
def _call_llm_stream(self, retry_on_empty=True, retry_count=0, max_retries=3,
|
||||||
_overflow_retry: bool = False) -> Tuple[str, List[Dict]]:
|
_overflow_retry: bool = False) -> Tuple[str, List[Dict]]:
|
||||||
"""
|
"""
|
||||||
@@ -742,7 +812,7 @@ class AgentStreamExecutor:
|
|||||||
tools_schema = None
|
tools_schema = None
|
||||||
if self.tools:
|
if self.tools:
|
||||||
tools_schema = []
|
tools_schema = []
|
||||||
for tool in self.tools.values():
|
for tool in self._select_tools_for_injection():
|
||||||
input_schema = tool.params
|
input_schema = tool.params
|
||||||
try:
|
try:
|
||||||
dynamic = (tool.get_json_schema() or {}).get("parameters") or {}
|
dynamic = (tool.get_json_schema() or {}).get("parameters") or {}
|
||||||
|
|||||||
@@ -202,8 +202,12 @@ SAFETY:
|
|||||||
total_bytes = len(output.encode('utf-8'))
|
total_bytes = len(output.encode('utf-8'))
|
||||||
|
|
||||||
if total_bytes > DEFAULT_MAX_BYTES:
|
if total_bytes > DEFAULT_MAX_BYTES:
|
||||||
# Save full output to temp file
|
# Save full output to temp file. encoding='utf-8' is required:
|
||||||
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-') as f:
|
# 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)
|
f.write(output)
|
||||||
temp_file_path = f.name
|
temp_file_path = f.name
|
||||||
|
|
||||||
|
|||||||
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,7 @@ Supports text files, images (jpg, png, gif, webp), and PDF files
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from pathlib import Path
|
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
|
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):
|
class Read(BaseTool):
|
||||||
"""Tool for reading file contents"""
|
"""Tool for reading file contents"""
|
||||||
|
|
||||||
name: str = "read"
|
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 = {
|
params: dict = {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -79,9 +86,9 @@ class Read(BaseTool):
|
|||||||
# Resolve path
|
# Resolve path
|
||||||
absolute_path = self._resolve_path(path)
|
absolute_path = self._resolve_path(path)
|
||||||
|
|
||||||
# Security check: Prevent reading sensitive config files
|
# Security check: block credential files and their aliases.
|
||||||
env_config_path = expand_path("~/.cow/.env")
|
# See issue #2913 (/proc/self/environ bypass) and #2863 (scope).
|
||||||
if os.path.abspath(absolute_path) == os.path.abspath(env_config_path):
|
if self._is_credential_path(absolute_path):
|
||||||
return ToolResult.fail(
|
return ToolResult.fail(
|
||||||
"Error: Access denied. API keys and credentials must be accessed through the env_config tool only."
|
"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):
|
if os.path.isabs(path):
|
||||||
return path
|
return path
|
||||||
return os.path.abspath(os.path.join(self.cwd, 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:
|
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.)
|
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 is not None:
|
||||||
if offset < 0:
|
if offset < 0:
|
||||||
# Negative offset: read from end
|
# Negative offset: read from end
|
||||||
# -20 means "last 20 lines" → start from (total - 20)
|
# -20 means "last 20 lines" → start from (total - 20).
|
||||||
start_line = max(0, total_file_lines + offset)
|
# 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:
|
else:
|
||||||
# Positive offset: read from start (1-indexed)
|
# Positive offset: read from start (1-indexed)
|
||||||
start_line = max(0, offset - 1) # Convert to 0-indexed
|
start_line = max(0, offset - 1) # Convert to 0-indexed
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ class Send(BaseTool):
|
|||||||
if not path:
|
if not path:
|
||||||
return ToolResult.fail("Error: path parameter is required")
|
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
|
# Resolve path
|
||||||
absolute_path = self._resolve_path(path)
|
absolute_path = self._resolve_path(path)
|
||||||
|
|
||||||
@@ -112,6 +117,46 @@ class Send(BaseTool):
|
|||||||
|
|
||||||
return ToolResult.success(result)
|
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:
|
def _resolve_path(self, path: str) -> str:
|
||||||
"""Resolve path to absolute path"""
|
"""Resolve path to absolute path"""
|
||||||
path = expand_path(path)
|
path = expand_path(path)
|
||||||
|
|||||||
@@ -71,6 +71,22 @@ class ToolManager:
|
|||||||
if not hasattr(self, '_mcp_active_configs'):
|
if not hasattr(self, '_mcp_active_configs'):
|
||||||
# server_name -> normalized config dict, for diff-based reload.
|
# server_name -> normalized config dict, for diff-based reload.
|
||||||
self._mcp_active_configs: dict = {}
|
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):
|
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"):
|
if agent is None or not hasattr(agent, "tools"):
|
||||||
return ([], [])
|
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
|
from agent.tools.mcp.mcp_tool import McpTool
|
||||||
current = self._mcp_tool_instances
|
current = self._mcp_tool_instances
|
||||||
registry_names = set(current.keys())
|
registry_names = set(current.keys())
|
||||||
@@ -564,6 +590,91 @@ class ToolManager:
|
|||||||
|
|
||||||
return (sorted(added), sorted(removed))
|
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:
|
def create_tool(self, name: str) -> BaseTool:
|
||||||
"""
|
"""
|
||||||
Get a new instance of a tool by name.
|
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
|
content_for_replacement=content
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try fuzzy match
|
# Fuzzy match: the exact substring was not found, most likely because the
|
||||||
fuzzy_content = normalize_for_fuzzy_match(content)
|
# whitespace differs (indentation, spaces around operators, trailing
|
||||||
fuzzy_old_text = normalize_for_fuzzy_match(old_text)
|
# spaces). Locate the region in the ORIGINAL content using a
|
||||||
|
# whitespace-flexible pattern and return offsets into that original
|
||||||
index = fuzzy_content.find(fuzzy_old_text)
|
# content.
|
||||||
if index != -1:
|
#
|
||||||
# Fuzzy match successful, use normalized content for replacement
|
# This must NOT replace inside a whitespace-normalized copy of the file:
|
||||||
return FuzzyMatchResult(
|
# doing so previously returned the normalized copy as
|
||||||
found=True,
|
# content_for_replacement, which caused the whole file to be rewritten
|
||||||
index=index,
|
# with collapsed indentation (every untouched line got reformatted).
|
||||||
match_length=len(fuzzy_old_text),
|
stripped = old_text.strip('\n')
|
||||||
content_for_replacement=fuzzy_content
|
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
|
# Not found
|
||||||
return FuzzyMatchResult(found=False)
|
return FuzzyMatchResult(found=False)
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
"""
|
"""
|
||||||
Shared SSRF guard utilities for tools that fetch model-supplied URLs.
|
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
|
SSRF protection is OPT-IN and disabled by default, because legitimate use
|
||||||
hostname, that hostname resolves, and every resolved address is a public
|
cases (local dev servers, LAN services, proxy fake-ip resolution) need to
|
||||||
(internet-routable) address. Loopback, private (RFC1918 / ULA), link-local
|
reach non-public addresses. Enable it by setting the config option
|
||||||
(incl. the 169.254.169.254 cloud-metadata endpoint) and otherwise reserved
|
``web_security_ssrf_protection: true`` (or env ``WEB_SECURITY_SSRF_PROTECTION``).
|
||||||
addresses are rejected, for both IPv4 and IPv6.
|
|
||||||
|
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 ipaddress
|
||||||
|
import os
|
||||||
import socket
|
import socket
|
||||||
from urllib.parse import urlparse
|
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:
|
def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
|
||||||
"""Return True if the address is not safe to connect to (non-public)."""
|
"""Return True if the address is not safe to connect to (non-public)."""
|
||||||
return (
|
return (
|
||||||
@@ -28,8 +51,11 @@ def _is_blocked_ip(ip: "ipaddress._BaseAddress") -> bool:
|
|||||||
def assert_public_ip(ip_str: str) -> None:
|
def assert_public_ip(ip_str: str) -> None:
|
||||||
"""Raise ValueError if the given literal IP is a non-public address.
|
"""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)
|
ip = ipaddress.ip_address(ip_str)
|
||||||
if _is_blocked_ip(ip):
|
if _is_blocked_ip(ip):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -41,13 +67,17 @@ def assert_public_ip(ip_str: str) -> None:
|
|||||||
def validate_url_safe(url: str) -> None:
|
def validate_url_safe(url: str) -> None:
|
||||||
"""Reject URLs that target private/loopback/link-local addresses (SSRF guard).
|
"""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)
|
into non-public ranges. Also rejects URLs with no host, non-HTTP(S)
|
||||||
schemes, or hosts that fail DNS resolution.
|
schemes, or hosts that fail DNS resolution.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: if the URL targets a disallowed address.
|
ValueError: if the URL targets a disallowed address.
|
||||||
"""
|
"""
|
||||||
|
if not _ssrf_protection_enabled():
|
||||||
|
return
|
||||||
|
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
if parsed.scheme not in ("http", "https"):
|
if parsed.scheme not in ("http", "https"):
|
||||||
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
raise ValueError(f"Unsupported URL scheme: {parsed.scheme}")
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ _DISCOVERABLE_MODELS = [
|
|||||||
("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"),
|
("moonshot_api_key", const.MOONSHOT, const.KIMI_K2_6, "Moonshot"),
|
||||||
("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"),
|
("ark_api_key", const.DOUBAO, const.DOUBAO_SEED_2_PRO, "Doubao"),
|
||||||
("dashscope_api_key", const.QWEN_DASHSCOPE, const.QWEN37_PLUS, "DashScope"),
|
("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"),
|
("gemini_api_key", const.GEMINI, const.GEMINI_35_FLASH, "Gemini"),
|
||||||
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
|
("qianfan_api_key", const.QIANFAN, const.ERNIE_45_TURBO_VL, "Qianfan"),
|
||||||
("zhipu_ai_api_key", const.ZHIPU_AI, const.GLM_4_7, "ZhipuAI"),
|
("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"
|
"Error: No model available for Vision.\n"
|
||||||
"The main model does not support vision and no other API keys are configured.\n"
|
"The main model does not support vision and no other API keys are configured.\n"
|
||||||
"Options:\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"
|
" 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\")"
|
" 3. Configure LINKAI_API_KEY: env_config(action=\"set\", key=\"LINKAI_API_KEY\", value=\"your-key\")"
|
||||||
)
|
)
|
||||||
|
|||||||
18
app.py
18
app.py
@@ -15,9 +15,9 @@ import threading
|
|||||||
|
|
||||||
_channel_mgr = None
|
_channel_mgr = None
|
||||||
|
|
||||||
# Desktop mode: a lighter runtime for the packaged Electron client. The plugin
|
# Desktop mode: a lighter runtime for the packaged Electron client. Plugins are
|
||||||
# framework is still bundled (it's tiny and on the web channel's import path),
|
# loaded in a background thread (so command plugins like cow_cli/godcmd work
|
||||||
# but we skip loading actual plugins and MCP tools to keep startup fast.
|
# without slowing startup), while MCP warmup is still skipped to keep it fast.
|
||||||
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
|
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
|
||||||
|
|
||||||
|
|
||||||
@@ -80,8 +80,16 @@ class ChannelManager:
|
|||||||
if self._primary_channel is None and channels:
|
if self._primary_channel is None and channels:
|
||||||
self._primary_channel = channels[0][1]
|
self._primary_channel = channels[0][1]
|
||||||
|
|
||||||
if first_start and not DESKTOP_MODE:
|
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
|
# Cloud client is optional. It is only started when
|
||||||
# use_linkai=True AND cloud_deployment_id is set.
|
# use_linkai=True AND cloud_deployment_id is set.
|
||||||
|
|||||||
@@ -684,11 +684,21 @@ class AgentBridge:
|
|||||||
"""
|
"""
|
||||||
file_type = file_info.get("file_type", "file")
|
file_type = file_info.get("file_type", "file")
|
||||||
file_path = file_info.get("path")
|
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)
|
# For images, use IMAGE_URL type (channel will handle upload)
|
||||||
if file_type == "image":
|
if file_type == "image":
|
||||||
# Convert local path to file:// URL for channel processing
|
file_url = _to_channel_url(file_path)
|
||||||
file_url = f"file://{file_path}"
|
|
||||||
logger.info(f"[AgentBridge] Sending image: {file_url}")
|
logger.info(f"[AgentBridge] Sending image: {file_url}")
|
||||||
reply = Reply(ReplyType.IMAGE_URL, file_url)
|
reply = Reply(ReplyType.IMAGE_URL, file_url)
|
||||||
# Attach text message if present (for channels that support text+image)
|
# 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
|
# For all file types (document, video, audio), use FILE type
|
||||||
if file_type in ["document", "video", "audio"]:
|
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}")
|
logger.info(f"[AgentBridge] Sending {file_type}: {file_url}")
|
||||||
reply = Reply(ReplyType.FILE, file_url)
|
reply = Reply(ReplyType.FILE, file_url)
|
||||||
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
|
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
|
||||||
@@ -708,7 +718,7 @@ class AgentBridge:
|
|||||||
return reply
|
return reply
|
||||||
|
|
||||||
# For all other file types (tar.gz, zip, etc.), also use FILE type
|
# 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}")
|
logger.info(f"[AgentBridge] Sending generic file: {file_url}")
|
||||||
reply = Reply(ReplyType.FILE, file_url)
|
reply = Reply(ReplyType.FILE, file_url)
|
||||||
reply.file_name = file_info.get("file_name", os.path.basename(file_path))
|
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
|
# Module-level lock to serialize scheduler init across concurrent sessions
|
||||||
_scheduler_init_lock = threading.Lock()
|
_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:
|
class AgentInitializer:
|
||||||
"""
|
"""
|
||||||
@@ -306,224 +302,16 @@ class AgentInitializer:
|
|||||||
"""
|
"""
|
||||||
Initialize the embedding provider for memory.
|
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):
|
A. Default (no `embedding_provider` in config.json):
|
||||||
Auto-init OpenAI -> LinkAI fallback. Existing 1536-dim indices
|
Auto-init OpenAI -> LinkAI fallback.
|
||||||
keep working.
|
|
||||||
B. Explicit (`embedding_provider` is set):
|
B. Explicit (`embedding_provider` is set):
|
||||||
Initialize the requested vendor with unified dim (default 1024).
|
Initialize the requested vendor.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
from agent.memory import create_embedding_provider
|
from agent.memory import create_default_embedding_provider
|
||||||
from config import conf
|
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
|
|
||||||
|
|
||||||
# Custom providers ("custom:<id>") resolve credentials
|
|
||||||
# from the custom_providers list.
|
|
||||||
resolved_provider_key = provider_key
|
|
||||||
if provider_key.startswith("custom:"):
|
|
||||||
resolved_provider_key = "custom"
|
|
||||||
|
|
||||||
meta = EMBEDDING_VENDORS.get(resolved_provider_key)
|
|
||||||
if meta is None:
|
|
||||||
logger.error(
|
|
||||||
f"[AgentInitializer] Unknown embedding_provider '{provider_key}'. "
|
|
||||||
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()
|
|
||||||
# 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"[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
|
|
||||||
|
|
||||||
# Custom providers ("custom:<id>") resolve from the custom_providers list.
|
|
||||||
if provider_key.startswith("custom:"):
|
|
||||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
|
||||||
_, custom_id = parse_custom_bot_type(provider_key)
|
|
||||||
if custom_id:
|
|
||||||
providers = get_custom_providers()
|
|
||||||
entry = _find_provider_by_id(providers, custom_id)
|
|
||||||
if entry:
|
|
||||||
return entry.get("api_key", "")
|
|
||||||
return ""
|
|
||||||
|
|
||||||
key_map = {
|
|
||||||
"openai": "open_ai_api_key",
|
|
||||||
"linkai": "linkai_api_key",
|
|
||||||
"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
|
|
||||||
|
|
||||||
# Custom providers ("custom:<id>") resolve from the custom_providers list.
|
|
||||||
if provider_key.startswith("custom:"):
|
|
||||||
from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id
|
|
||||||
_, custom_id = parse_custom_bot_type(provider_key)
|
|
||||||
if custom_id:
|
|
||||||
providers = get_custom_providers()
|
|
||||||
entry = _find_provider_by_id(providers, custom_id)
|
|
||||||
if entry and entry.get("api_base"):
|
|
||||||
return entry["api_base"]
|
|
||||||
return default_base
|
|
||||||
|
|
||||||
base_map = {
|
|
||||||
"openai": "open_ai_api_base",
|
|
||||||
"linkai": "linkai_api_base",
|
|
||||||
"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):
|
def _sync_memory(self, memory_manager, session_id: Optional[str] = None):
|
||||||
"""Sync memory database"""
|
"""Sync memory database"""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -47,12 +47,15 @@
|
|||||||
This runs synchronously in <head> so the correct class is on <html>
|
This runs synchronously in <head> so the correct class is on <html>
|
||||||
before any CSS or body rendering occurs. -->
|
before any CSS or body rendering occurs. -->
|
||||||
<script>
|
<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.
|
// or '' when unrecognized so callers can fall through to the next source.
|
||||||
window.__cowNormalizeLang__ = function(raw) {
|
window.__cowNormalizeLang__ = function(raw) {
|
||||||
if (!raw) return '';
|
if (!raw) return '';
|
||||||
var v = String(raw).trim().toLowerCase();
|
var v = String(raw).trim().toLowerCase().replace('_', '-');
|
||||||
if (v === 'auto') return '';
|
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('zh') === 0) return 'zh';
|
||||||
if (v.indexOf('en') === 0) return 'en';
|
if (v.indexOf('en') === 0) return 'en';
|
||||||
return '';
|
return '';
|
||||||
@@ -267,14 +270,29 @@
|
|||||||
|
|
||||||
<div class="flex-1"></div>
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
<!-- Language Toggle -->
|
<!-- Language Selector (dropdown) -->
|
||||||
<button id="lang-toggle" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
|
<div id="lang-selector" class="relative">
|
||||||
text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
|
<button id="lang-toggle" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium
|
||||||
cursor-pointer transition-colors duration-150"
|
text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10
|
||||||
onclick="toggleLanguage()">
|
cursor-pointer transition-colors duration-150"
|
||||||
<i class="fas fa-globe text-xs"></i>
|
onclick="toggleLangMenu(event)">
|
||||||
<span id="lang-label">EN</span>
|
<i class="fas fa-globe text-xs"></i>
|
||||||
</button>
|
<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 -->
|
<!-- Theme Toggle -->
|
||||||
<button id="theme-toggle" class="p-2 rounded-lg text-slate-500 dark:text-slate-400
|
<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">
|
cursor-pointer transition-colors duration-150" title="GitHub">
|
||||||
<i class="fab fa-github text-lg"></i>
|
<i class="fab fa-github text-lg"></i>
|
||||||
</a>
|
</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>
|
</header>
|
||||||
|
|
||||||
<!-- Content Area -->
|
<!-- Content Area -->
|
||||||
@@ -829,11 +855,11 @@
|
|||||||
<!-- VIEW: Knowledge -->
|
<!-- VIEW: Knowledge -->
|
||||||
<!-- ====================================================== -->
|
<!-- ====================================================== -->
|
||||||
<div id="view-knowledge" class="view">
|
<div id="view-knowledge" class="view">
|
||||||
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10">
|
<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">
|
<div class="w-full max-w-[1600px] mx-auto md:flex-1 md:min-h-0 md:flex md:flex-col">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- 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>
|
<div>
|
||||||
<h2 class="text-xl font-bold text-slate-800 dark:text-slate-100" data-i18n="knowledge_title">知识库</h2>
|
<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>
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
|
<span id="knowledge-stats" class="text-xs text-slate-400 dark:text-slate-500 hidden sm:inline"></span>
|
||||||
<span id="knowledge-action-status" class="text-xs opacity-0 transition-opacity duration-200"></span>
|
<span id="knowledge-action-status" class="text-xs opacity-0 transition-opacity duration-200"></span>
|
||||||
<button onclick="createKnowledgeCategory()"
|
|
||||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-500 hover:bg-primary-600 text-white text-xs font-medium cursor-pointer transition-colors">
|
|
||||||
<i class="fas fa-folder-plus"></i><span data-i18n="knowledge_new_category">新建分类</span>
|
|
||||||
</button>
|
|
||||||
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
|
<div class="flex items-center bg-slate-100 dark:bg-white/10 rounded-lg p-0.5">
|
||||||
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
|
<button id="knowledge-tab-docs" onclick="switchKnowledgeTab('docs')"
|
||||||
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
|
class="knowledge-tab px-3 py-1.5 rounded-md text-xs font-medium cursor-pointer transition-colors duration-150 active">
|
||||||
@@ -855,6 +877,28 @@
|
|||||||
<i class="fas fa-diagram-project mr-1.5"></i><span data-i18n="knowledge_tab_graph">图谱</span>
|
<i class="fas fa-diagram-project mr-1.5"></i><span data-i18n="knowledge_tab_graph">图谱</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -877,12 +921,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Documents panel -->
|
<!-- Documents panel -->
|
||||||
<div id="knowledge-panel-docs" class="hidden">
|
<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" style="min-height: calc(100vh - 220px)">
|
<div class="flex flex-col md:flex-row gap-4 md:gap-6 md:h-full">
|
||||||
<!-- File tree -->
|
<!-- File tree -->
|
||||||
<div id="knowledge-sidebar" class="w-full md:w-72 lg:w-80 flex-shrink-0">
|
<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">
|
<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">
|
<div class="px-4 py-3 border-b border-slate-200 dark:border-white/10 flex-shrink-0">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<i class="fas fa-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-xs"></i>
|
<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..."
|
<input id="knowledge-search" type="text" placeholder="Search..."
|
||||||
@@ -890,19 +934,19 @@
|
|||||||
oninput="filterKnowledgeTree(this.value)">
|
oninput="filterKnowledgeTree(this.value)">
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Content viewer -->
|
<!-- Content viewer -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0 md:h-full">
|
||||||
<div id="knowledge-content-placeholder"
|
<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>
|
<i class="fas fa-file-lines text-3xl mb-3 opacity-40"></i>
|
||||||
<p class="text-sm" data-i18n="knowledge_select_hint">选择一个文档查看</p>
|
<p class="text-sm" data-i18n="knowledge_select_hint">选择一个文档查看</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="knowledge-content-viewer" class="hidden">
|
<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">
|
<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">
|
<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">
|
<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>
|
<i class="fas fa-arrow-left text-xs"></i>
|
||||||
</button>
|
</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>
|
<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>
|
||||||
<div id="knowledge-viewer-body"
|
<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"
|
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>
|
||||||
style="max-height: calc(100vh - 280px)"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1083,7 +1126,7 @@
|
|||||||
|
|
||||||
<!-- Knowledge Action Dialog -->
|
<!-- Knowledge Action Dialog -->
|
||||||
<div id="knowledge-dialog-overlay" class="fixed inset-0 bg-black/50 z-[200] hidden flex items-center justify-center">
|
<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="p-6">
|
||||||
<div class="flex items-center gap-3 mb-5">
|
<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">
|
<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>
|
<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"
|
<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">
|
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"
|
<div id="knowledge-dialog-select" class="cfg-dropdown hidden w-full" tabindex="0">
|
||||||
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 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-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>
|
<p id="knowledge-dialog-error" class="mt-2 text-xs text-red-500 hidden"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1293,6 +1293,18 @@
|
|||||||
background: rgba(74, 190, 110, 0.1);
|
background: rgba(74, 190, 110, 0.1);
|
||||||
color: #4ABE6E;
|
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 */
|
/* Graph legend */
|
||||||
.knowledge-graph-legend {
|
.knowledge-graph-legend {
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ const I18N = {
|
|||||||
knowledge_select_hint: '选择一个文档查看', knowledge_empty_hint: '暂无知识页面',
|
knowledge_select_hint: '选择一个文档查看', knowledge_empty_hint: '暂无知识页面',
|
||||||
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent,它会自动整理到你的知识库中。',
|
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent,它会自动整理到你的知识库中。',
|
||||||
knowledge_go_chat: '开始对话',
|
knowledge_go_chat: '开始对话',
|
||||||
|
knowledge_new: '新建',
|
||||||
knowledge_new_category: '新建分类',
|
knowledge_new_category: '新建分类',
|
||||||
|
knowledge_new_document: '新建文档',
|
||||||
|
knowledge_import_documents: '导入文档',
|
||||||
welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过<br>长期记忆和知识库不断成长',
|
welcome_subtitle: '我可以帮你解答问题、管理计算机、创造和执行技能,并通过<br>长期记忆和知识库不断成长',
|
||||||
example_sys_title: '系统管理', example_sys_text: '查看工作空间里有哪些文件',
|
example_sys_title: '系统管理', example_sys_text: '查看工作空间里有哪些文件',
|
||||||
example_task_title: '定时任务', example_task_text: '1分钟后提醒我检查服务器',
|
example_task_title: '定时任务', example_task_text: '1分钟后提醒我检查服务器',
|
||||||
@@ -144,8 +147,9 @@ const I18N = {
|
|||||||
config_custom_tip: '接口需遵循 OpenAI API 协议',
|
config_custom_tip: '接口需遵循 OpenAI API 协议',
|
||||||
config_security: '安全设置', config_password: '访问密码',
|
config_security: '安全设置', config_password: '访问密码',
|
||||||
config_password_hint: '留空则不启用密码保护',
|
config_password_hint: '留空则不启用密码保护',
|
||||||
config_password_changed: '密码已更新,请重新登录',
|
config_password_changed: '密码已更新',
|
||||||
config_password_cleared: '密码已清除',
|
config_password_cleared: '密码已清除',
|
||||||
|
config_password_security_warning: '⚠️ 警告:目前密码为空且对外连接埠开放,建议重启服务,或检查是否调整监听位址绑定。',
|
||||||
skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 工具和技能', skills_hub_btn: '探索技能广场',
|
skills_title: '技能管理', skills_desc: '查看、启用或禁用 Agent 工具和技能', skills_hub_btn: '探索技能广场',
|
||||||
skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处',
|
skills_loading: '加载技能中...', skills_loading_desc: '技能加载后将显示在此处',
|
||||||
tools_section_title: '内置工具', tools_loading: '加载工具中...',
|
tools_section_title: '内置工具', tools_loading: '加载工具中...',
|
||||||
@@ -251,7 +255,255 @@ const I18N = {
|
|||||||
regenerate_response: '重新生成',
|
regenerate_response: '重新生成',
|
||||||
edit_save: '保存并发送',
|
edit_save: '保存并发送',
|
||||||
edit_cancel: '取消',
|
edit_cancel: '取消',
|
||||||
|
logout: '退出',
|
||||||
},
|
},
|
||||||
|
'zh-Hant': {
|
||||||
|
|
||||||
|
console: '控制台',
|
||||||
|
nav_chat: '對話', nav_manage: '管理', nav_monitor: '監控',
|
||||||
|
menu_chat: '對話', menu_config: '設定', menu_models: '模型', menu_skills: '技能',
|
||||||
|
menu_memory: '記憶', menu_knowledge: '知識', menu_channels: '管道', menu_tasks: '定時',
|
||||||
|
menu_logs: '日誌',
|
||||||
|
models_title: '模型管理',
|
||||||
|
models_desc: '統一管理對話、影像、語音、向量、搜尋能力',
|
||||||
|
models_section_vendors: '廠商憑據',
|
||||||
|
models_section_vendors_desc: '一處設定,多個模型能力共享',
|
||||||
|
models_section_capabilities: '模型能力',
|
||||||
|
models_add_vendor: '新增廠商',
|
||||||
|
models_provider: '廠商',
|
||||||
|
models_model: '模型',
|
||||||
|
models_voice: '音色',
|
||||||
|
models_configured: '已設定',
|
||||||
|
models_not_configured: '未設定',
|
||||||
|
models_pick_to_configure: '選擇以設定',
|
||||||
|
models_clear_credential: '清除憑據',
|
||||||
|
models_base_default_hint: '留空將使用官方預設地址',
|
||||||
|
models_base_default: '預設',
|
||||||
|
models_custom_vendor_label: '自定義',
|
||||||
|
models_custom_name: '名稱',
|
||||||
|
models_custom_delete: '刪除',
|
||||||
|
models_custom_delete_confirm_title: '刪除自定義廠商',
|
||||||
|
models_custom_delete_confirm_msg: '確定刪除該自定義廠商嗎?此操作無法撤銷。',
|
||||||
|
models_custom_name_required: '請填寫名稱',
|
||||||
|
models_custom_base_required: '請填寫 API Base',
|
||||||
|
models_custom_edit_title: '編輯自定義廠商',
|
||||||
|
models_custom_add_title: '新增自定義廠商',
|
||||||
|
models_capability_chat: '主模型',
|
||||||
|
models_capability_chat_desc: '用於基礎對話和 Agent 推理',
|
||||||
|
models_capability_vision: '影像理解',
|
||||||
|
models_capability_vision_desc: '識別圖片內容,用於影像識別工具',
|
||||||
|
models_capability_image: '影像生成',
|
||||||
|
models_capability_image_desc: '生成圖片,用於影像生成技能',
|
||||||
|
models_auto_using: '當前優先使用',
|
||||||
|
models_capability_asr: '語音識別',
|
||||||
|
models_capability_asr_desc: '語音轉文字',
|
||||||
|
models_capability_tts: '語音合成',
|
||||||
|
models_capability_tts_desc: '文字轉語音',
|
||||||
|
models_capability_embedding: '向量',
|
||||||
|
models_capability_embedding_desc: '用於記憶與知識的向量化檢索',
|
||||||
|
models_capability_search: '聯網搜尋',
|
||||||
|
models_capability_search_desc: '實時網頁檢索能力,用於搜尋工具',
|
||||||
|
models_strategy_auto: '自動',
|
||||||
|
models_search_strategy_label: '策略',
|
||||||
|
models_search_strategy_fixed: '指定',
|
||||||
|
models_search_strategy_auto_hint: '從已設定廠商中自動選擇',
|
||||||
|
models_search_strategy_fixed_hint: '指定使用搜尋廠商',
|
||||||
|
models_pending_config: '待設定',
|
||||||
|
models_search_available_label: '可用搜尋廠商:',
|
||||||
|
models_search_none_configured: '暫未啟用任何搜尋廠商,點選新增',
|
||||||
|
models_search_add_provider: '新增廠商',
|
||||||
|
models_search_add_desc: '選擇一個搜尋廠商進行設定',
|
||||||
|
models_search_bocha_title: '設定博查 API Key',
|
||||||
|
models_search_bocha_desc: '前往博查開放平臺建立 API Key',
|
||||||
|
models_search_edit_hint: '點選修改設定',
|
||||||
|
models_unavailable: '不可用',
|
||||||
|
models_set_via_env: '透過環境變數啟用',
|
||||||
|
models_dim_label: '維度',
|
||||||
|
models_save_success: '已儲存',
|
||||||
|
models_save_failed: '儲存失敗',
|
||||||
|
models_cleared: '已清除',
|
||||||
|
models_clear_failed: '清除失敗',
|
||||||
|
models_embedding_change_title: '更改向量模型',
|
||||||
|
models_embedding_change_msg: '切換向量模型後,已有索引將失效,需要重建。是否繼續?',
|
||||||
|
models_embedding_saved_title: '向量模型已更新',
|
||||||
|
models_embedding_saved_msg: '請在聊天框輸入 /memory rebuild-index 重建索引。',
|
||||||
|
models_embedding_saved_ok: '去執行',
|
||||||
|
models_pick_provider: '待選擇',
|
||||||
|
models_clear_confirm_title: '清除廠商憑據',
|
||||||
|
models_clear_confirm_msg: '確認清除該廠商的 API Key 與 Base URL 嗎?相關能力將不再可用。',
|
||||||
|
cancel: '取消',
|
||||||
|
save: '儲存',
|
||||||
|
ok: '確定',
|
||||||
|
knowledge_title: '知識庫', knowledge_desc: '瀏覽和探索你的知識庫',
|
||||||
|
knowledge_tab_docs: '檔案', knowledge_tab_graph: '圖譜',
|
||||||
|
knowledge_loading: '載入知識庫中...', knowledge_loading_desc: '知識頁面將顯示在這裡',
|
||||||
|
knowledge_select_hint: '選擇一個檔案檢視', knowledge_empty_hint: '暫無知識頁面',
|
||||||
|
knowledge_empty_guide: '在對話中傳送檔案、連結或主題給 Agent,它會自動整理到你的知識庫中。',
|
||||||
|
knowledge_go_chat: '開始對話',
|
||||||
|
knowledge_new: '新建',
|
||||||
|
knowledge_new_category: '新建分類',
|
||||||
|
knowledge_new_document: '新建檔案',
|
||||||
|
knowledge_import_documents: '匯入檔案',
|
||||||
|
welcome_subtitle: '我可以幫你解答問題、管理電腦、創造和執行技能,並透過<br>長期記憶和知識庫不斷成長',
|
||||||
|
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: '檢視全部命令',
|
||||||
|
slash_help: '顯示命令幫助',
|
||||||
|
slash_status: '檢視執行狀態',
|
||||||
|
slash_context: '檢視對話上下文',
|
||||||
|
slash_context_clear: '清除對話上下文',
|
||||||
|
slash_skill_list: '檢視已安裝技能',
|
||||||
|
slash_skill_list_remote: '瀏覽技能廣場',
|
||||||
|
slash_skill_search: '搜尋技能',
|
||||||
|
slash_skill_install: '安裝技能 (名稱或 GitHub URL)',
|
||||||
|
slash_skill_uninstall: '解除安裝技能',
|
||||||
|
slash_skill_info: '檢視技能詳情',
|
||||||
|
slash_skill_enable: '啟用技能',
|
||||||
|
slash_skill_disable: '禁用技能',
|
||||||
|
slash_memory_dream: '手動觸發記憶蒸餾 (可指定天數, 預設3)',
|
||||||
|
slash_knowledge: '檢視知識庫統計',
|
||||||
|
slash_knowledge_list: '檢視知識庫檔案樹',
|
||||||
|
slash_knowledge_on: '開啟知識庫',
|
||||||
|
slash_knowledge_off: '關閉知識庫',
|
||||||
|
slash_config: '檢視當前設定',
|
||||||
|
slash_cancel: '中止當前正在執行的 Agent 任務',
|
||||||
|
slash_logs: '檢視最近日誌',
|
||||||
|
slash_version: '檢視版本',
|
||||||
|
input_placeholder: '輸入訊息,或輸入 / 使用指令',
|
||||||
|
config_title: '設定管理', config_desc: '管理模型和 Agent 設定',
|
||||||
|
config_model: '模型設定', config_agent: 'Agent 設定',
|
||||||
|
config_language: '語言', config_language_hint: '介面展示、命令文案、系統提示詞等使用的語言(與右上角切換同步)',
|
||||||
|
config_model_advanced: '高階設定',
|
||||||
|
config_channel: '管道設定',
|
||||||
|
config_agent_enabled: 'Agent 模式',
|
||||||
|
config_max_tokens: '最大上下文 Token', config_max_tokens_hint: '對話中 Agent 能輸入的最大 Token 長度,超過後會智慧壓縮處理',
|
||||||
|
config_max_turns: '最大記憶輪次', config_max_turns_hint: '一問一答為一輪,超過後會智慧壓縮處理',
|
||||||
|
config_max_steps: '最大執行步數', config_max_steps_hint: '單次對話中 Agent 最多呼叫工具的次數',
|
||||||
|
config_enable_thinking: '深度思考', config_enable_thinking_hint: '是否啟用深度思考模式',
|
||||||
|
config_self_evolution: '自主進化', config_self_evolution_hint: '會話空閒後自動覆盤,沉澱記憶、最佳化技能、處理未完成事項',
|
||||||
|
evolution_badge: '自主學習',
|
||||||
|
config_channel_type: '管道型別',
|
||||||
|
config_provider: '模型廠商', config_model_name: '模型',
|
||||||
|
config_custom_model_hint: '輸入自定義模型名稱',
|
||||||
|
config_save: '儲存', config_saved: '已儲存',
|
||||||
|
config_save_error: '儲存失敗',
|
||||||
|
config_custom_option: '自定義',
|
||||||
|
config_custom_tip: '介面需遵循 OpenAI API 協議',
|
||||||
|
config_security: '安全設定', config_password: '訪問密碼',
|
||||||
|
config_password_hint: '留空則不啟用密碼保護',
|
||||||
|
config_password_changed: '密碼已更新',
|
||||||
|
config_password_cleared: '密碼已清除',
|
||||||
|
config_password_security_warning: '⚠️ 警告:目前密碼為空且對外連接埠開放,建議重啟服務,或檢查是否調整監聽位址綁定。',
|
||||||
|
skills_title: '技能管理', skills_desc: '檢視、啟用或禁用 Agent 工具和技能', skills_hub_btn: '探索技能廣場',
|
||||||
|
skills_loading: '載入技能中...', skills_loading_desc: '技能載入後將顯示在此處',
|
||||||
|
tools_section_title: '內建工具', tools_loading: '載入工具中...',
|
||||||
|
skills_section_title: '技能', skill_enable: '啟用', skill_disable: '禁用',
|
||||||
|
skill_toggle_error: '操作失敗,請稍後再試',
|
||||||
|
memory_title: '記憶管理', memory_desc: '檢視 Agent 記憶檔案和內容',
|
||||||
|
memory_tab_files: '記憶檔案', memory_tab_dreams: '自主進化',
|
||||||
|
memory_loading: '載入記憶檔案中...', memory_loading_desc: '記憶檔案將顯示在此處',
|
||||||
|
memory_back: '返回列表',
|
||||||
|
memory_col_name: '檔名', memory_col_type: '型別', memory_col_size: '大小', memory_col_updated: '更新時間',
|
||||||
|
channels_title: '管道管理', channels_desc: '管理已接入的訊息管道',
|
||||||
|
channels_add: '接入管道', channels_disconnect: '斷開',
|
||||||
|
channels_save: '儲存設定', channels_saved: '已儲存', channels_save_error: '儲存失敗',
|
||||||
|
channels_restarted: '已儲存並重啟',
|
||||||
|
channels_connect_btn: '接入', channels_cancel: '取消',
|
||||||
|
channels_select_placeholder: '選擇要接入的管道...',
|
||||||
|
channels_empty: '暫未接入任何管道', channels_empty_desc: '點選右上角「接入管道」按鈕開始設定',
|
||||||
|
channels_disconnect_confirm: '確認斷開該管道?設定將保留但管道會停止執行。',
|
||||||
|
channels_connected: '已接入', channels_connecting: '接入中...',
|
||||||
|
weixin_scan_title: '微信掃碼登入', weixin_scan_desc: '請使用微信掃描下方二維碼',
|
||||||
|
weixin_scan_loading: '正在獲取二維碼...', weixin_scan_waiting: '等待掃碼...',
|
||||||
|
weixin_scan_scanned: '已掃碼,請在手機上確認', weixin_scan_expired: '二維碼已過期,正在重新整理...',
|
||||||
|
weixin_scan_success: '登入成功,正在啟動管道...', weixin_scan_fail: '獲取二維碼失敗',
|
||||||
|
weixin_qr_tip: '二維碼約2分鐘後過期',
|
||||||
|
wecom_scan_btn: '掃碼建立企微機器人', wecom_scan_desc: '使用企業微信掃碼,一鍵建立智慧機器人',
|
||||||
|
wecom_scan_success: '建立成功,正在啟動管道...',
|
||||||
|
wecom_scan_fail: '建立失敗',
|
||||||
|
wecom_mode_scan: '掃碼接入', wecom_mode_manual: '手動填寫',
|
||||||
|
feishu_scan_btn: '一鍵建立飛書應用',
|
||||||
|
feishu_scan_desc: '使用飛書 App 掃碼,自動建立應用並預置全部許可權與事件訂閱',
|
||||||
|
feishu_scan_replace_desc: '使用飛書 App 掃碼建立新機器人,將覆蓋當前的 App ID / Secret',
|
||||||
|
feishu_scan_loading: '正在向飛書申請二維碼...',
|
||||||
|
feishu_scan_waiting: '等待掃碼...',
|
||||||
|
feishu_scan_tip: '二維碼 10 分鐘內有效,僅供一次掃描',
|
||||||
|
feishu_scan_open_link: '或點選此處在瀏覽器中開啟',
|
||||||
|
feishu_scan_success: '應用建立成功,正在啟動管道...',
|
||||||
|
feishu_scan_expired: '二維碼已過期,請重試',
|
||||||
|
feishu_scan_denied: '已取消授權',
|
||||||
|
feishu_scan_fail: '建立失敗',
|
||||||
|
feishu_scan_retry: '重試',
|
||||||
|
feishu_mode_scan: '掃碼建立', feishu_mode_manual: '手動填寫',
|
||||||
|
tasks_title: '定時任務', tasks_desc: '檢視和管理定時任務',
|
||||||
|
tasks_coming: '即將推出', tasks_coming_desc: '定時任務管理功能即將在此提供',
|
||||||
|
task_add_btn: '新增任務',
|
||||||
|
task_edit_title: '編輯定時任務',
|
||||||
|
task_add_title: '新增定時任務',
|
||||||
|
task_name: '任務名稱',
|
||||||
|
task_enabled: '啟用任務',
|
||||||
|
task_schedule_type: '排程型別',
|
||||||
|
task_schedule_cron: 'Cron 表示式',
|
||||||
|
task_schedule_interval: '固定間隔',
|
||||||
|
task_schedule_once: '一次性任務',
|
||||||
|
task_cron_expression: 'Cron 表示式',
|
||||||
|
task_cron_hint: '格式: 分 時 日 月 周,例如 "0 9 * * *" 表示每天 9:00',
|
||||||
|
task_interval_seconds: '間隔秒數',
|
||||||
|
task_interval_hint: '最小 60 秒,例如 3600 表示每小時執行一次',
|
||||||
|
task_once_time: '執行時間',
|
||||||
|
task_action_type: '動作型別',
|
||||||
|
task_action_send_message: '傳送訊息',
|
||||||
|
task_action_agent_task: 'AI 任務',
|
||||||
|
task_channel_type: '管道型別',
|
||||||
|
task_channel_hint: '選擇定時訊息傳送的管道',
|
||||||
|
task_message_content: '訊息內容',
|
||||||
|
task_task_description: '任務描述',
|
||||||
|
task_delete_btn: '刪除任務',
|
||||||
|
task_delete_confirm_title: '刪除定時任務',
|
||||||
|
task_delete_confirm_msg: '確定刪除該定時任務嗎?此操作無法撤銷。',
|
||||||
|
logs_title: '日誌', logs_desc: '實時日誌輸出 (run.log)',
|
||||||
|
logs_live: '實時', logs_coming_msg: '日誌流即將在此提供。將連線 run.log 實現類似 tail -f 的實時輸出。',
|
||||||
|
new_chat: '新對話',
|
||||||
|
session_history: '歷史會話',
|
||||||
|
today: '今天', yesterday: '昨天', earlier: '更早',
|
||||||
|
delete_session_confirm: '確認刪除該會話?所有訊息將被清除。',
|
||||||
|
delete_session_title: '刪除會話',
|
||||||
|
rename_session: '重新命名',
|
||||||
|
delete_message_confirm: '確認刪除這條訊息?',
|
||||||
|
delete_message_title: '刪除訊息',
|
||||||
|
edit_disabled_reply_active: '正在生成回覆,暫時無法編輯。',
|
||||||
|
delete_disabled_reply_active: '正在生成回覆,暫時無法刪除。',
|
||||||
|
untitled_session: '新對話',
|
||||||
|
context_cleared: '— 以上內容已從上下文中移除 —',
|
||||||
|
tip_new_chat: '新建對話',
|
||||||
|
tip_clear_context: '清除上下文',
|
||||||
|
tip_attach: '新增附件',
|
||||||
|
attach_menu_file: '上傳檔案',
|
||||||
|
mic_idle_title: '點選錄音 / 再按一次結束',
|
||||||
|
mic_recording_title: '錄音中,再次點選結束',
|
||||||
|
mic_busy_title: '識別中…',
|
||||||
|
mic_permission_denied: '無法訪問麥克風,請檢查瀏覽器許可權',
|
||||||
|
mic_too_short: '錄音太短,請重試',
|
||||||
|
mic_error: '語音識別失敗',
|
||||||
|
speak_msg: '朗讀這段回覆',
|
||||||
|
voice_reply_mode_label: '語音回覆策略',
|
||||||
|
voice_reply_off: '關閉',
|
||||||
|
voice_reply_if_voice: '僅語音問/語音答',
|
||||||
|
voice_reply_always: '總是語音回覆',
|
||||||
|
attach_menu_folder: '上傳資料夾',
|
||||||
|
confirm_yes: '確認',
|
||||||
|
confirm_cancel: '取消',
|
||||||
|
error_send: '傳送失敗,請稍後再試。', error_timeout: '請求超時,請再試一次。',
|
||||||
|
thinking_in_progress: '思考中...', thinking_done: '已深度思考', thinking_duration: '耗時',
|
||||||
|
edit_message: '編輯訊息',
|
||||||
|
regenerate_response: '重新生成',
|
||||||
|
edit_save: '儲存併傳送',
|
||||||
|
edit_cancel: '取消',
|
||||||
|
logout: '登出',
|
||||||
|
},
|
||||||
en: {
|
en: {
|
||||||
console: 'Console',
|
console: 'Console',
|
||||||
nav_chat: 'Chat', nav_manage: 'Management', nav_monitor: 'Monitor',
|
nav_chat: 'Chat', nav_manage: 'Management', nav_monitor: 'Monitor',
|
||||||
@@ -334,7 +586,10 @@ const I18N = {
|
|||||||
knowledge_select_hint: 'Select a document to view', knowledge_empty_hint: 'No knowledge pages yet',
|
knowledge_select_hint: 'Select a document to view', knowledge_empty_hint: 'No knowledge pages yet',
|
||||||
knowledge_empty_guide: 'Send documents, links or topics to the agent in chat, and it will automatically organize them into your knowledge base.',
|
knowledge_empty_guide: 'Send documents, links or topics to the agent in chat, and it will automatically organize them into your knowledge base.',
|
||||||
knowledge_go_chat: 'Start a conversation',
|
knowledge_go_chat: 'Start a conversation',
|
||||||
|
knowledge_new: 'New',
|
||||||
knowledge_new_category: 'New category',
|
knowledge_new_category: 'New category',
|
||||||
|
knowledge_new_document: 'New document',
|
||||||
|
knowledge_import_documents: 'Import documents',
|
||||||
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through <br> long-term memory and a personal knowledge base.',
|
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills, and keep growing through <br> long-term memory and a personal knowledge base.',
|
||||||
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace',
|
example_sys_title: 'System', example_sys_text: 'Show me the files in the workspace',
|
||||||
example_task_title: 'Scheduler', example_task_text: 'Remind me to check the server in 5 minutes',
|
example_task_title: 'Scheduler', example_task_text: 'Remind me to check the server in 5 minutes',
|
||||||
@@ -385,8 +640,9 @@ const I18N = {
|
|||||||
config_custom_tip: 'API must follow OpenAI protocol.',
|
config_custom_tip: 'API must follow OpenAI protocol.',
|
||||||
config_security: 'Security', config_password: 'Password',
|
config_security: 'Security', config_password: 'Password',
|
||||||
config_password_hint: 'Leave empty to disable password protection',
|
config_password_hint: 'Leave empty to disable password protection',
|
||||||
config_password_changed: 'Password updated, please re-login',
|
config_password_changed: 'Password updated',
|
||||||
config_password_cleared: 'Password cleared',
|
config_password_cleared: 'Password cleared',
|
||||||
|
config_password_security_warning: '⚠️ Warning: Password is now empty and the port is exposed. Consider restarting the service or adjusting the listening address binding.',
|
||||||
skills_title: 'Skills', skills_desc: 'View, enable, or disable agent tools and skills', skills_hub_btn: 'Skill Hub',
|
skills_title: 'Skills', skills_desc: 'View, enable, or disable agent tools and skills', skills_hub_btn: 'Skill Hub',
|
||||||
skills_loading: 'Loading skills...', skills_loading_desc: 'Skills will be displayed here after loading',
|
skills_loading: 'Loading skills...', skills_loading_desc: 'Skills will be displayed here after loading',
|
||||||
tools_section_title: 'Built-in Tools', tools_loading: 'Loading tools...',
|
tools_section_title: 'Built-in Tools', tools_loading: 'Loading tools...',
|
||||||
@@ -492,6 +748,7 @@ const I18N = {
|
|||||||
regenerate_response: 'Regenerate',
|
regenerate_response: 'Regenerate',
|
||||||
edit_save: 'Save and send',
|
edit_save: 'Save and send',
|
||||||
edit_cancel: 'Cancel',
|
edit_cancel: 'Cancel',
|
||||||
|
logout: 'Logout',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -505,6 +762,9 @@ let currentLang = (typeof window.__cowResolveLang__ === 'function')
|
|||||||
if (!raw) return '';
|
if (!raw) return '';
|
||||||
const v = String(raw).trim().toLowerCase();
|
const v = String(raw).trim().toLowerCase();
|
||||||
if (v === 'auto') return '';
|
if (v === 'auto') return '';
|
||||||
|
// Handle Traditional Chinese variants first (more specific)
|
||||||
|
if (v === 'zh-hant' || v.startsWith('zh-hant-') || v === 'zh-tw' || v === 'zh-hk') return 'zh-Hant';
|
||||||
|
// Then Simplified Chinese
|
||||||
if (v.indexOf('zh') === 0) return 'zh';
|
if (v.indexOf('zh') === 0) return 'zh';
|
||||||
if (v.indexOf('en') === 0) return 'en';
|
if (v.indexOf('en') === 0) return 'en';
|
||||||
return '';
|
return '';
|
||||||
@@ -538,12 +798,32 @@ function applyI18n() {
|
|||||||
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
|
||||||
el.placeholder = t(el.dataset['i18nPlaceholder']);
|
el.placeholder = t(el.dataset['i18nPlaceholder']);
|
||||||
});
|
});
|
||||||
|
document.querySelectorAll('[data-i18n-title]').forEach(el => {
|
||||||
|
el.title = t(el.dataset['i18nTitle']);
|
||||||
|
});
|
||||||
document.querySelectorAll('[data-tip-key]').forEach(el => {
|
document.querySelectorAll('[data-tip-key]').forEach(el => {
|
||||||
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
|
el.setAttribute('data-tooltip', t(el.dataset.tipKey));
|
||||||
});
|
});
|
||||||
installCfgTipPortal();
|
installCfgTipPortal();
|
||||||
|
|
||||||
|
// Clear any status messages when language changes
|
||||||
|
document.querySelectorAll('[id$="-status"]').forEach(el => {
|
||||||
|
el.classList.add('opacity-0');
|
||||||
|
});
|
||||||
|
|
||||||
const langLabel = document.getElementById('lang-label');
|
const langLabel = document.getElementById('lang-label');
|
||||||
if (langLabel) langLabel.textContent = currentLang === 'zh' ? '中文' : 'EN';
|
if (langLabel) {
|
||||||
|
if (currentLang === 'zh-Hant') langLabel.textContent = '繁体';
|
||||||
|
else if (currentLang === 'zh') langLabel.textContent = '简体';
|
||||||
|
else langLabel.textContent = 'EN';
|
||||||
|
}
|
||||||
|
// Highlight the active option in the header language dropdown menu.
|
||||||
|
document.querySelectorAll('#lang-menu .lang-menu-item').forEach(item => {
|
||||||
|
const active = item.dataset.lang === currentLang;
|
||||||
|
item.classList.toggle('text-blue-600', active);
|
||||||
|
item.classList.toggle('dark:text-blue-400', active);
|
||||||
|
item.classList.toggle('font-medium', active);
|
||||||
|
});
|
||||||
// Point the docs link to the locale-specific documentation site.
|
// Point the docs link to the locale-specific documentation site.
|
||||||
const docsLink = document.getElementById('docs-link');
|
const docsLink = document.getElementById('docs-link');
|
||||||
if (docsLink) docsLink.href = currentLang === 'zh' ? 'https://docs.cowagent.ai/zh' : 'https://docs.cowagent.ai';
|
if (docsLink) docsLink.href = currentLang === 'zh' ? 'https://docs.cowagent.ai/zh' : 'https://docs.cowagent.ai';
|
||||||
@@ -553,7 +833,7 @@ function applyI18n() {
|
|||||||
// persists the user choice locally, re-renders the UI, and binds the choice to
|
// persists the user choice locally, re-renders the UI, and binds the choice to
|
||||||
// the backend `cow_lang` config so logs / agent replies / CLI follow suit.
|
// the backend `cow_lang` config so logs / agent replies / CLI follow suit.
|
||||||
function setLanguage(lang) {
|
function setLanguage(lang) {
|
||||||
const next = (lang === 'en') ? 'en' : 'zh';
|
const next = (lang === 'en' || lang === 'zh' || lang === 'zh-Hant') ? lang : 'zh';
|
||||||
if (next === currentLang) {
|
if (next === currentLang) {
|
||||||
// Still persist + sync in case storage/backend drifted from the UI.
|
// Still persist + sync in case storage/backend drifted from the UI.
|
||||||
syncLanguageToBackend(next);
|
syncLanguageToBackend(next);
|
||||||
@@ -563,48 +843,90 @@ function setLanguage(lang) {
|
|||||||
localStorage.setItem('cow_lang', currentLang);
|
localStorage.setItem('cow_lang', currentLang);
|
||||||
applyI18n();
|
applyI18n();
|
||||||
_applyInputTooltips();
|
_applyInputTooltips();
|
||||||
// Re-render views whose DOM is built in JS (data-i18n alone does not
|
|
||||||
// cover strings interpolated via t() into innerHTML).
|
|
||||||
try { rerenderDynamicViews(); } catch (e) {}
|
|
||||||
// Keep the language switch button and config selector visually in sync.
|
// Keep the language switch button and config selector visually in sync.
|
||||||
try { updateLangControls(); } catch (e) {}
|
try { updateLangControls(); } catch (e) {}
|
||||||
syncLanguageToBackend(currentLang);
|
|
||||||
|
// Sync language choice to backend first, then trigger dynamic views reload
|
||||||
|
// to avoid race conditions on API endpoints.
|
||||||
|
syncLanguageToBackend(currentLang, () => {
|
||||||
|
try { rerenderDynamicViews(); } catch (e) {}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the language to the backend `cow_lang` config (best-effort; the UI
|
// Persist the language to the backend `cow_lang` config (best-effort; the UI
|
||||||
// has already switched locally, so a network failure is non-blocking).
|
// has already switched locally, so a network failure is non-blocking).
|
||||||
function syncLanguageToBackend(lang) {
|
function syncLanguageToBackend(lang, callback) {
|
||||||
try {
|
try {
|
||||||
fetch('/config', {
|
fetch('/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ updates: { cow_lang: lang } })
|
body: JSON.stringify({ updates: { cow_lang: lang } })
|
||||||
}).catch(() => {});
|
})
|
||||||
} catch (e) {}
|
.then(() => { if (callback) callback(); })
|
||||||
|
.catch(() => { if (callback) callback(); });
|
||||||
|
} catch (e) {
|
||||||
|
if (callback) callback();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reflect the current language on both the top-right toggle and the config
|
// Reflect the current language on both the top-right toggle and the config
|
||||||
// selector (if present), so the two entry points stay synchronized.
|
// selector (if present), so the two entry points stay synchronized.
|
||||||
function updateLangControls() {
|
function updateLangControls() {
|
||||||
const langLabel = document.getElementById('lang-label');
|
const langLabel = document.getElementById('lang-label');
|
||||||
if (langLabel) langLabel.textContent = currentLang === 'zh' ? '中文' : 'EN';
|
if (langLabel) {
|
||||||
|
if (currentLang === 'zh-Hant') langLabel.textContent = '繁体';
|
||||||
|
else if (currentLang === 'zh') langLabel.textContent = '简体';
|
||||||
|
else langLabel.textContent = 'EN';
|
||||||
|
}
|
||||||
|
// Highlight the active option in the header language dropdown menu.
|
||||||
|
document.querySelectorAll('#lang-menu .lang-menu-item').forEach(item => {
|
||||||
|
const active = item.dataset.lang === currentLang;
|
||||||
|
item.classList.toggle('text-blue-600', active);
|
||||||
|
item.classList.toggle('dark:text-blue-400', active);
|
||||||
|
item.classList.toggle('font-medium', active);
|
||||||
|
});
|
||||||
// The config language picker is the custom .cfg-dropdown component. Only
|
// The config language picker is the custom .cfg-dropdown component. Only
|
||||||
// sync it once it has been initialized (i.e. the config panel was opened).
|
// sync it once it has been initialized (i.e. the config panel was opened).
|
||||||
const sel = document.getElementById('cfg-lang-select');
|
const sel = document.getElementById('cfg-lang-select');
|
||||||
if (sel && sel._ddValue !== undefined && sel._ddValue !== currentLang) {
|
if (sel && sel._ddValue !== undefined && sel._ddValue !== currentLang) {
|
||||||
sel._ddValue = currentLang;
|
sel._ddValue = currentLang;
|
||||||
const textEl = sel.querySelector('.cfg-dropdown-text');
|
const textEl = sel.querySelector('.cfg-dropdown-text');
|
||||||
if (textEl) textEl.textContent = currentLang === 'zh' ? '中文' : 'English';
|
if (textEl) {
|
||||||
|
if (currentLang === 'zh-Hant') textEl.textContent = '繁體中文';
|
||||||
|
else if (currentLang === 'zh') textEl.textContent = '简体中文';
|
||||||
|
else textEl.textContent = 'English';
|
||||||
|
}
|
||||||
sel.querySelectorAll('.cfg-dropdown-item').forEach(i => {
|
sel.querySelectorAll('.cfg-dropdown-item').forEach(i => {
|
||||||
i.classList.toggle('active', i.dataset.value === currentLang);
|
i.classList.toggle('active', i.dataset.value === currentLang);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLanguage() {
|
// Toggle the header language dropdown menu open/closed.
|
||||||
setLanguage(currentLang === 'zh' ? 'en' : 'zh');
|
function toggleLangMenu(event) {
|
||||||
|
if (event) event.stopPropagation();
|
||||||
|
const menu = document.getElementById('lang-menu');
|
||||||
|
if (menu) menu.classList.toggle('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pick a language from the dropdown, then close the menu.
|
||||||
|
function selectLanguage(lang) {
|
||||||
|
const menu = document.getElementById('lang-menu');
|
||||||
|
if (menu) menu.classList.add('hidden');
|
||||||
|
setLanguage(lang);
|
||||||
|
}
|
||||||
|
window.toggleLangMenu = toggleLangMenu;
|
||||||
|
window.selectLanguage = selectLanguage;
|
||||||
|
|
||||||
|
// Close the language menu when clicking outside of it.
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const selector = document.getElementById('lang-selector');
|
||||||
|
const menu = document.getElementById('lang-menu');
|
||||||
|
if (menu && !menu.classList.contains('hidden') && selector && !selector.contains(e.target)) {
|
||||||
|
menu.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Refresh JS-rendered views after a language switch. Each branch uses the
|
// Refresh JS-rendered views after a language switch. Each branch uses the
|
||||||
// lightweight in-memory re-render path (no extra network round-trips).
|
// lightweight in-memory re-render path (no extra network round-trips).
|
||||||
function rerenderDynamicViews() {
|
function rerenderDynamicViews() {
|
||||||
@@ -617,6 +939,19 @@ function rerenderDynamicViews() {
|
|||||||
tasksLoaded = false;
|
tasksLoaded = false;
|
||||||
loadTasksView();
|
loadTasksView();
|
||||||
}
|
}
|
||||||
|
// Reload skills and tools after language switch
|
||||||
|
if (currentView === 'skills') {
|
||||||
|
toolsLoaded = false;
|
||||||
|
loadSkillsView();
|
||||||
|
}
|
||||||
|
// Reload channels after language switch
|
||||||
|
if (currentView === 'channels') {
|
||||||
|
loadChannelsView();
|
||||||
|
}
|
||||||
|
// Reload config after language switch
|
||||||
|
if (currentView === 'config') {
|
||||||
|
loadConfigView();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floating tooltip portal for [data-tip-key] elements. Tooltip nodes are
|
// Floating tooltip portal for [data-tip-key] elements. Tooltip nodes are
|
||||||
@@ -724,6 +1059,12 @@ function navigateTo(viewId) {
|
|||||||
document.getElementById('breadcrumb-page').textContent = t(meta.page);
|
document.getElementById('breadcrumb-page').textContent = t(meta.page);
|
||||||
document.getElementById('breadcrumb-page').dataset.i18n = meta.page;
|
document.getElementById('breadcrumb-page').dataset.i18n = meta.page;
|
||||||
currentView = viewId;
|
currentView = viewId;
|
||||||
|
|
||||||
|
// Clear status messages when navigating away
|
||||||
|
document.querySelectorAll('[id$="-status"]').forEach(el => {
|
||||||
|
el.classList.add('opacity-0');
|
||||||
|
});
|
||||||
|
|
||||||
if (window.innerWidth < 1024) closeSidebar();
|
if (window.innerWidth < 1024) closeSidebar();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4260,7 +4601,7 @@ function initConfigView(data) {
|
|||||||
if (langSel) {
|
if (langSel) {
|
||||||
initDropdown(
|
initDropdown(
|
||||||
langSel,
|
langSel,
|
||||||
[{ value: 'zh', label: '中文' }, { value: 'en', label: 'English' }],
|
[{ value: 'zh', label: '简体中文' }, { value: 'zh-Hant', label: '繁體中文' }, { value: 'en', label: 'English' }],
|
||||||
currentLang,
|
currentLang,
|
||||||
(val) => setLanguage(val)
|
(val) => setLanguage(val)
|
||||||
);
|
);
|
||||||
@@ -4433,7 +4774,10 @@ function showStatus(elId, msgKey, isError) {
|
|||||||
el.classList.toggle('text-red-500', !!isError);
|
el.classList.toggle('text-red-500', !!isError);
|
||||||
el.classList.toggle('text-primary-500', !isError);
|
el.classList.toggle('text-primary-500', !isError);
|
||||||
el.classList.remove('opacity-0');
|
el.classList.remove('opacity-0');
|
||||||
setTimeout(() => el.classList.add('opacity-0'), 2500);
|
// Warning messages (errors) should stay visible, success messages auto-hide
|
||||||
|
if (!isError) {
|
||||||
|
setTimeout(() => el.classList.add('opacity-0'), 2500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveModelConfig() {
|
function saveModelConfig() {
|
||||||
@@ -4546,15 +4890,33 @@ function savePasswordConfig() {
|
|||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
console.log('[Password Config] Response:', data); // Debug
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
if (newPwd) {
|
if (newPwd) {
|
||||||
showStatus('cfg-password-status', 'config_password_changed', false);
|
showStatus('cfg-password-status', 'config_password_changed', false);
|
||||||
setTimeout(() => { window.location.reload(); }, 1500);
|
// Mark as masked so user needs to re-enter to change again
|
||||||
|
input.dataset.masked = '1';
|
||||||
|
input.dataset.maskedVal = newPwd;
|
||||||
|
input.value = '••••••••';
|
||||||
|
input.classList.add('cfg-key-masked');
|
||||||
|
|
||||||
|
// Show logout button since password is now enabled
|
||||||
|
const logoutBtn = document.getElementById('logout-btn-header');
|
||||||
|
if (logoutBtn) logoutBtn.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
input.dataset.masked = '';
|
input.dataset.masked = '';
|
||||||
input.dataset.maskedVal = '';
|
input.dataset.maskedVal = '';
|
||||||
input.classList.remove('cfg-key-masked');
|
input.classList.remove('cfg-key-masked');
|
||||||
showStatus('cfg-password-status', 'config_password_cleared', false);
|
|
||||||
|
// Show security warning if password was cleared with public host
|
||||||
|
if (data.warning === 'password_cleared_with_public_host') {
|
||||||
|
showStatus('cfg-password-status', 'config_password_security_warning', true);
|
||||||
|
} else {
|
||||||
|
showStatus('cfg-password-status', 'config_password_cleared', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoutBtn = document.getElementById('logout-btn-header');
|
||||||
|
if (logoutBtn) logoutBtn.classList.add('hidden');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showStatus('cfg-password-status', 'config_save_error', true);
|
showStatus('cfg-password-status', 'config_save_error', true);
|
||||||
@@ -7763,8 +8125,11 @@ let _knowledgeTreeData = [];
|
|||||||
let _knowledgeRootFiles = [];
|
let _knowledgeRootFiles = [];
|
||||||
let _knowledgeCurrentFile = null;
|
let _knowledgeCurrentFile = null;
|
||||||
let _knowledgeGraphLoaded = false;
|
let _knowledgeGraphLoaded = false;
|
||||||
|
const KNOWLEDGE_IMPORT_MAX_FILES = 100;
|
||||||
|
const KNOWLEDGE_IMPORT_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
|
const KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE = 200 * 1024 * 1024;
|
||||||
|
|
||||||
function loadKnowledgeView() {
|
function loadKnowledgeView(targetPath) {
|
||||||
// Reset to docs tab
|
// Reset to docs tab
|
||||||
switchKnowledgeTab('docs');
|
switchKnowledgeTab('docs');
|
||||||
_knowledgeGraphLoaded = false;
|
_knowledgeGraphLoaded = false;
|
||||||
@@ -7772,6 +8137,7 @@ function loadKnowledgeView() {
|
|||||||
|
|
||||||
fetch('/api/knowledge/list').then(r => r.json()).then(data => {
|
fetch('/api/knowledge/list').then(r => r.json()).then(data => {
|
||||||
if (data.status !== 'success') return;
|
if (data.status !== 'success') return;
|
||||||
|
initKnowledgeImportDropZone();
|
||||||
|
|
||||||
const emptyEl = document.getElementById('knowledge-empty');
|
const emptyEl = document.getElementById('knowledge-empty');
|
||||||
const docsPanel = document.getElementById('knowledge-panel-docs');
|
const docsPanel = document.getElementById('knowledge-panel-docs');
|
||||||
@@ -7800,6 +8166,15 @@ function loadKnowledgeView() {
|
|||||||
|
|
||||||
renderKnowledgeTree(tree, rootFiles);
|
renderKnowledgeTree(tree, rootFiles);
|
||||||
|
|
||||||
|
// Prefer opening the just created/imported file; ensure its group is
|
||||||
|
// expanded so the active item is visible in the tree.
|
||||||
|
const targetTitle = targetPath ? _findKnowledgeFileTitle(targetPath) : null;
|
||||||
|
if (targetTitle !== null) {
|
||||||
|
_expandKnowledgeGroupFor(targetPath);
|
||||||
|
openKnowledgeFile(targetPath, targetTitle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-select the first file (desktop only)
|
// Auto-select the first file (desktop only)
|
||||||
if (window.innerWidth >= 768) {
|
if (window.innerWidth >= 768) {
|
||||||
const firstFile = rootFiles.length > 0 ? rootFiles[0] : null;
|
const firstFile = rootFiles.length > 0 ? rootFiles[0] : null;
|
||||||
@@ -7817,6 +8192,36 @@ function loadKnowledgeView() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find a file's display title by its relative path within the knowledge tree.
|
||||||
|
// Returns the title, or null when the path is not present.
|
||||||
|
function _findKnowledgeFileTitle(path) {
|
||||||
|
if (!path) return null;
|
||||||
|
const rootHit = (_knowledgeRootFiles || []).find(f => f.name === path);
|
||||||
|
if (rootHit) return rootHit.title || rootHit.name;
|
||||||
|
const walk = (groups, parentPath) => {
|
||||||
|
for (const group of groups || []) {
|
||||||
|
const groupPath = parentPath ? `${parentPath}/${group.dir}` : group.dir;
|
||||||
|
const hit = (group.files || []).find(f => `${groupPath}/${f.name}` === path);
|
||||||
|
if (hit) return hit.title || hit.name;
|
||||||
|
const childHit = walk(group.children, groupPath);
|
||||||
|
if (childHit !== null) return childHit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return walk(_knowledgeTreeData, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open every ancestor group of the given file path so it is visible.
|
||||||
|
function _expandKnowledgeGroupFor(path) {
|
||||||
|
if (!path || !path.includes('/')) return;
|
||||||
|
const target = document.querySelector(`.knowledge-tree-file[data-path="${CSS.escape(path)}"]`);
|
||||||
|
let node = target ? target.closest('.knowledge-tree-group') : null;
|
||||||
|
while (node) {
|
||||||
|
node.classList.add('open');
|
||||||
|
node = node.parentElement ? node.parentElement.closest('.knowledge-tree-group') : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderKnowledgeTree(tree, rootFilesOrFilter, filter) {
|
function renderKnowledgeTree(tree, rootFilesOrFilter, filter) {
|
||||||
const container = document.getElementById('knowledge-tree');
|
const container = document.getElementById('knowledge-tree');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@@ -7898,7 +8303,7 @@ function _knowledgeCategoryActions(path) {
|
|||||||
return `<span class="knowledge-actions">${_knowledgeActionButton('fa-pen', '重命名', `renameKnowledgeCategory(${value})`)}${_knowledgeActionButton('fa-trash', '删除', `deleteKnowledgeCategory(${value})`)}</span>`;
|
return `<span class="knowledge-actions">${_knowledgeActionButton('fa-pen', '重命名', `renameKnowledgeCategory(${value})`)}${_knowledgeActionButton('fa-trash', '删除', `deleteKnowledgeCategory(${value})`)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchKnowledgeAction(action, payload) {
|
async function dispatchKnowledgeAction(action, payload, openPathResolver) {
|
||||||
_setKnowledgeStatus(currentLang === 'zh' ? '处理中...' : 'Working...', false, true);
|
_setKnowledgeStatus(currentLang === 'zh' ? '处理中...' : 'Working...', false, true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/knowledge/action', {
|
const response = await fetch('/api/knowledge/action', {
|
||||||
@@ -7913,7 +8318,9 @@ async function dispatchKnowledgeAction(action, payload) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
_setKnowledgeStatus(_knowledgeResultMessage(action, result.payload), false);
|
_setKnowledgeStatus(_knowledgeResultMessage(action, result.payload), false);
|
||||||
loadKnowledgeView();
|
// Optionally auto-open the affected file after the tree refreshes.
|
||||||
|
const openPath = openPathResolver ? openPathResolver(result.payload) : null;
|
||||||
|
loadKnowledgeView(openPath || undefined);
|
||||||
return result.payload;
|
return result.payload;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_setKnowledgeStatus(currentLang === 'zh' ? '请求失败,请稍后重试' : 'Request failed, please try again', true);
|
_setKnowledgeStatus(currentLang === 'zh' ? '请求失败,请稍后重试' : 'Request failed, please try again', true);
|
||||||
@@ -7933,14 +8340,18 @@ function _setKnowledgeStatus(message, isError, persistent) {
|
|||||||
function _knowledgeResultMessage(action, payload) {
|
function _knowledgeResultMessage(action, payload) {
|
||||||
if (currentLang !== 'zh') {
|
if (currentLang !== 'zh') {
|
||||||
return action === 'create_category' ? 'Category created' :
|
return action === 'create_category' ? 'Category created' :
|
||||||
|
action === 'create_document' ? 'Document created' :
|
||||||
action === 'rename_category' ? 'Category renamed' :
|
action === 'rename_category' ? 'Category renamed' :
|
||||||
action === 'delete_category' ? 'Category deleted' :
|
action === 'delete_category' ? 'Category deleted' :
|
||||||
|
action === 'import_documents' ? `${payload?.imported || 0} imported · ${payload?.skipped || 0} skipped · ${payload?.failed || 0} failed` :
|
||||||
action === 'move_documents' ? `${payload?.moved || 0} document moved` :
|
action === 'move_documents' ? `${payload?.moved || 0} document moved` :
|
||||||
`${payload?.deleted || 0} document deleted`;
|
`${payload?.deleted || 0} document deleted`;
|
||||||
}
|
}
|
||||||
return action === 'create_category' ? '分类已创建' :
|
return action === 'create_category' ? '分类已创建' :
|
||||||
|
action === 'create_document' ? '文档已创建' :
|
||||||
action === 'rename_category' ? '分类已重命名' :
|
action === 'rename_category' ? '分类已重命名' :
|
||||||
action === 'delete_category' ? '分类已删除' :
|
action === 'delete_category' ? '分类已删除' :
|
||||||
|
action === 'import_documents' ? `导入 ${payload?.imported || 0} 个,跳过 ${payload?.skipped || 0} 个,失败 ${payload?.failed || 0} 个` :
|
||||||
action === 'move_documents' ? `已移动 ${payload?.moved || 0} 个文档` :
|
action === 'move_documents' ? `已移动 ${payload?.moved || 0} 个文档` :
|
||||||
`已删除 ${payload?.deleted || 0} 个文档`;
|
`已删除 ${payload?.deleted || 0} 个文档`;
|
||||||
}
|
}
|
||||||
@@ -7956,8 +8367,15 @@ function _knowledgeCategoryPaths(groups, parent = '') {
|
|||||||
|
|
||||||
function openKnowledgeDialog(options) {
|
function openKnowledgeDialog(options) {
|
||||||
const overlay = document.getElementById('knowledge-dialog-overlay');
|
const overlay = document.getElementById('knowledge-dialog-overlay');
|
||||||
|
const card = document.getElementById('knowledge-dialog-card');
|
||||||
const input = document.getElementById('knowledge-dialog-input');
|
const input = document.getElementById('knowledge-dialog-input');
|
||||||
const select = document.getElementById('knowledge-dialog-select');
|
const select = document.getElementById('knowledge-dialog-select');
|
||||||
|
const textarea = document.getElementById('knowledge-dialog-textarea');
|
||||||
|
const documentForm = document.getElementById('knowledge-document-form');
|
||||||
|
const documentFilename = document.getElementById('knowledge-document-filename');
|
||||||
|
const documentContent = document.getElementById('knowledge-document-content');
|
||||||
|
const templateBtn = document.getElementById('knowledge-document-template');
|
||||||
|
const documentPathPreview = document.getElementById('knowledge-document-path-preview');
|
||||||
const submit = document.getElementById('knowledge-dialog-submit');
|
const submit = document.getElementById('knowledge-dialog-submit');
|
||||||
const cancel = document.getElementById('knowledge-dialog-cancel');
|
const cancel = document.getElementById('knowledge-dialog-cancel');
|
||||||
document.getElementById('knowledge-dialog-title').textContent = options.title;
|
document.getElementById('knowledge-dialog-title').textContent = options.title;
|
||||||
@@ -7966,11 +8384,36 @@ function openKnowledgeDialog(options) {
|
|||||||
document.getElementById('knowledge-dialog-hint').textContent = options.hint || '';
|
document.getElementById('knowledge-dialog-hint').textContent = options.hint || '';
|
||||||
document.getElementById('knowledge-dialog-error').classList.add('hidden');
|
document.getElementById('knowledge-dialog-error').classList.add('hidden');
|
||||||
document.getElementById('knowledge-dialog-icon').className = `fas ${options.icon || 'fa-folder'} text-emerald-500`;
|
document.getElementById('knowledge-dialog-icon').className = `fas ${options.icon || 'fa-folder'} text-emerald-500`;
|
||||||
input.classList.toggle('hidden', options.type === 'select');
|
card.classList.toggle('knowledge-document-dialog', options.type === 'document');
|
||||||
|
input.classList.toggle('hidden', options.type === 'select' || options.type === 'textarea' || options.type === 'document');
|
||||||
select.classList.toggle('hidden', options.type !== 'select');
|
select.classList.toggle('hidden', options.type !== 'select');
|
||||||
|
textarea.classList.toggle('hidden', options.type !== 'textarea');
|
||||||
|
documentForm.classList.toggle('hidden', options.type !== 'document');
|
||||||
input.value = options.value || '';
|
input.value = options.value || '';
|
||||||
|
textarea.value = options.value || '';
|
||||||
|
documentFilename.value = options.filename || '';
|
||||||
|
documentContent.value = options.content || '';
|
||||||
|
document.getElementById('knowledge-document-category-label').textContent = currentLang === 'zh' ? '目标分类' : 'Destination category';
|
||||||
|
documentPathPreview.textContent = options.category
|
||||||
|
? `knowledge/${options.category}/`
|
||||||
|
: 'knowledge/';
|
||||||
|
documentFilename.oninput = null;
|
||||||
|
document.getElementById('knowledge-document-filename-label').textContent = currentLang === 'zh' ? '文件名' : 'Filename';
|
||||||
|
document.getElementById('knowledge-document-content-label').textContent = currentLang === 'zh' ? 'Markdown 内容' : 'Markdown content';
|
||||||
|
templateBtn.textContent = currentLang === 'zh' ? '插入模板' : 'Insert template';
|
||||||
|
templateBtn.onclick = () => {
|
||||||
|
if (documentContent.value.trim()) return;
|
||||||
|
const title = (documentFilename.value || 'untitled').replace(/\.md$/i, '');
|
||||||
|
documentContent.value = currentLang === '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`;
|
||||||
|
documentContent.focus();
|
||||||
|
};
|
||||||
if (options.type === 'select') {
|
if (options.type === 'select') {
|
||||||
select.innerHTML = (options.choices || []).map(value => `<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`).join('');
|
// Use the shared custom dropdown component instead of a native
|
||||||
|
// <select> so the arrow / menu match the rest of the console.
|
||||||
|
const ddOptions = (options.choices || []).map(value => ({ value, label: value }));
|
||||||
|
initDropdown(select, ddOptions, (options.choices || [])[0] || '', null);
|
||||||
}
|
}
|
||||||
submit.textContent = currentLang === 'zh' ? '确定' : 'Confirm';
|
submit.textContent = currentLang === 'zh' ? '确定' : 'Confirm';
|
||||||
cancel.textContent = currentLang === 'zh' ? '取消' : 'Cancel';
|
cancel.textContent = currentLang === 'zh' ? '取消' : 'Cancel';
|
||||||
@@ -7978,7 +8421,13 @@ function openKnowledgeDialog(options) {
|
|||||||
|
|
||||||
const close = () => overlay.classList.add('hidden');
|
const close = () => overlay.classList.add('hidden');
|
||||||
const submitAction = async () => {
|
const submitAction = async () => {
|
||||||
const value = (options.type === 'select' ? select.value : input.value).trim();
|
const rawValue = options.type === 'select' ? getDropdownValue(select) :
|
||||||
|
(options.type === 'textarea' ? textarea.value :
|
||||||
|
(options.type === 'document' ? {
|
||||||
|
filename: documentFilename.value.trim(),
|
||||||
|
content: documentContent.value,
|
||||||
|
} : input.value));
|
||||||
|
const value = options.type === 'textarea' || options.type === 'document' ? rawValue : rawValue.trim();
|
||||||
const error = options.validate ? options.validate(value) : (!value ? (currentLang === 'zh' ? '此项不能为空' : 'This field is required') : '');
|
const error = options.validate ? options.validate(value) : (!value ? (currentLang === 'zh' ? '此项不能为空' : 'This field is required') : '');
|
||||||
if (error) {
|
if (error) {
|
||||||
const errorEl = document.getElementById('knowledge-dialog-error');
|
const errorEl = document.getElementById('knowledge-dialog-error');
|
||||||
@@ -7996,7 +8445,31 @@ function openKnowledgeDialog(options) {
|
|||||||
overlay.onclick = event => { if (event.target === overlay) close(); };
|
overlay.onclick = event => { if (event.target === overlay) close(); };
|
||||||
input.onkeydown = event => { if (event.key === 'Enter') submitAction(); };
|
input.onkeydown = event => { if (event.key === 'Enter') submitAction(); };
|
||||||
overlay.classList.remove('hidden');
|
overlay.classList.remove('hidden');
|
||||||
setTimeout(() => (options.type === 'select' ? select : input).focus(), 0);
|
setTimeout(() => (options.type === 'select' ? select : (options.type === 'textarea' ? textarea : (options.type === 'document' ? documentFilename : input))).focus(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeKnowledgeNewMenu() {
|
||||||
|
const list = document.getElementById('knowledge-new-menu-list');
|
||||||
|
if (list) list.classList.add('hidden');
|
||||||
|
document.removeEventListener('click', _knowledgeNewMenuOutside, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _knowledgeNewMenuOutside(event) {
|
||||||
|
const menu = document.getElementById('knowledge-new-menu');
|
||||||
|
if (menu && !menu.contains(event.target)) closeKnowledgeNewMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleKnowledgeNewMenu(event) {
|
||||||
|
if (event) event.stopPropagation();
|
||||||
|
const list = document.getElementById('knowledge-new-menu-list');
|
||||||
|
if (!list) return;
|
||||||
|
const willOpen = list.classList.contains('hidden');
|
||||||
|
list.classList.toggle('hidden');
|
||||||
|
if (willOpen) {
|
||||||
|
document.addEventListener('click', _knowledgeNewMenuOutside, true);
|
||||||
|
} else {
|
||||||
|
document.removeEventListener('click', _knowledgeNewMenuOutside, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createKnowledgeCategory() {
|
function createKnowledgeCategory() {
|
||||||
@@ -8010,6 +8483,166 @@ function createKnowledgeCategory() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createKnowledgeDocument() {
|
||||||
|
const categories = _knowledgeCategoryPaths(_knowledgeTreeData);
|
||||||
|
if (!categories.length) {
|
||||||
|
_setKnowledgeStatus(currentLang === 'zh' ? '请先创建分类' : 'Create a category first', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openKnowledgeDialog({
|
||||||
|
title: currentLang === 'zh' ? '新建文档' : 'New document',
|
||||||
|
subtitle: currentLang === 'zh' ? '先选择分类,然后输入文件名' : 'Choose a category, then enter a filename',
|
||||||
|
label: currentLang === 'zh' ? '目标分类' : 'Destination category',
|
||||||
|
type: 'select',
|
||||||
|
choices: categories,
|
||||||
|
icon: 'fa-file-circle-plus',
|
||||||
|
onSubmit: category => {
|
||||||
|
openKnowledgeDocumentEditor(category);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openKnowledgeDocumentEditor(category) {
|
||||||
|
openKnowledgeDialog({
|
||||||
|
title: currentLang === 'zh' ? '新建文档' : 'New document',
|
||||||
|
subtitle: currentLang === 'zh' ? `保存到 ${category}` : `Save to ${category}`,
|
||||||
|
label: '',
|
||||||
|
hint: currentLang === 'zh' ? '文件名可省略 .md 后缀;保存后会自动同步索引。' : 'The .md suffix is optional. Index sync runs after saving.',
|
||||||
|
type: 'document',
|
||||||
|
category,
|
||||||
|
filename: '',
|
||||||
|
content: '',
|
||||||
|
icon: 'fa-file-circle-plus',
|
||||||
|
validate: value => {
|
||||||
|
if (!value.filename) return currentLang === 'zh' ? '文件名不能为空' : 'Filename is required';
|
||||||
|
if (/\.[^.]+$/i.test(value.filename) && !/\.md$/i.test(value.filename)) {
|
||||||
|
return currentLang === 'zh' ? '新建文档仅支持 .md 文件名' : 'New documents must be .md files';
|
||||||
|
}
|
||||||
|
if (!value.content.trim()) return currentLang === 'zh' ? '内容不能为空' : 'Content is required';
|
||||||
|
if (new Blob([value.content]).size > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
|
||||||
|
return currentLang === 'zh' ? '内容不能超过 10MB' : 'Content cannot exceed 10MB';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
onSubmit: value => {
|
||||||
|
const safeName = value.filename.endsWith('.md') ? value.filename : `${value.filename}.md`;
|
||||||
|
return dispatchKnowledgeAction('create_document', {
|
||||||
|
path: `${category}/${safeName}`,
|
||||||
|
content: value.content,
|
||||||
|
overwrite: false,
|
||||||
|
}, payload => payload?.path || `${category}/${safeName}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectKnowledgeImportFiles() {
|
||||||
|
const input = document.getElementById('knowledge-import-input');
|
||||||
|
input.value = '';
|
||||||
|
input.onchange = () => {
|
||||||
|
if (input.files && input.files.length) openKnowledgeImportDialog(Array.from(input.files));
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openKnowledgeImportDialog(files) {
|
||||||
|
const validationError = validateKnowledgeImportFiles(files);
|
||||||
|
if (validationError) {
|
||||||
|
_setKnowledgeStatus(validationError, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const choices = _knowledgeCategoryPaths(_knowledgeTreeData);
|
||||||
|
openKnowledgeDialog({
|
||||||
|
title: currentLang === 'zh' ? '导入文档' : 'Import documents',
|
||||||
|
subtitle: currentLang === 'zh' ? `已选择 ${files.length} 个文件` : `${files.length} file(s) selected`,
|
||||||
|
label: currentLang === 'zh' ? '目标分类' : 'Destination category',
|
||||||
|
hint: choices.length ? (currentLang === 'zh' ? '支持 Markdown 和 TXT,TXT 会转成 Markdown 文档' : 'Markdown and TXT are supported. TXT is converted to Markdown.') :
|
||||||
|
(currentLang === 'zh' ? '请先创建一个分类' : 'Create a category first'),
|
||||||
|
type: 'select',
|
||||||
|
choices,
|
||||||
|
icon: 'fa-file-arrow-up',
|
||||||
|
onSubmit: target => importKnowledgeDocuments(files, target),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importKnowledgeDocuments(files, targetCategory) {
|
||||||
|
const validationError = validateKnowledgeImportFiles(files);
|
||||||
|
if (validationError) {
|
||||||
|
_setKnowledgeStatus(validationError, true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const supported = files.filter(file => /\.(md|txt)$/i.test(file.name || ''));
|
||||||
|
if (!supported.length) {
|
||||||
|
_setKnowledgeStatus(currentLang === 'zh' ? '请选择 .md 或 .txt 文件' : 'Choose .md or .txt files', true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('target_category', targetCategory);
|
||||||
|
formData.append('conflict_strategy', 'rename');
|
||||||
|
supported.forEach(file => formData.append('files', file, file.name));
|
||||||
|
_setKnowledgeStatus(currentLang === 'zh' ? '正在导入...' : 'Importing...', false, true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/knowledge/import', { method: 'POST', body: formData });
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.status !== 'success') {
|
||||||
|
_setKnowledgeStatus(result.message || (currentLang === 'zh' ? '导入失败' : 'Import failed'), true);
|
||||||
|
loadKnowledgeView();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_setKnowledgeStatus(_knowledgeResultMessage('import_documents', result.payload), false);
|
||||||
|
// Auto-open the first successfully imported document.
|
||||||
|
const firstImported = (result.payload?.results || []).find(item => item.status === 'imported');
|
||||||
|
loadKnowledgeView(firstImported ? firstImported.path : undefined);
|
||||||
|
return result.payload;
|
||||||
|
} catch (error) {
|
||||||
|
_setKnowledgeStatus(currentLang === 'zh' ? '导入请求失败' : 'Import request failed', true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateKnowledgeImportFiles(files) {
|
||||||
|
if (!files || !files.length) return currentLang === 'zh' ? '请选择文件' : 'Choose files';
|
||||||
|
if (files.length > KNOWLEDGE_IMPORT_MAX_FILES) {
|
||||||
|
return currentLang === 'zh' ? `一次最多导入 ${KNOWLEDGE_IMPORT_MAX_FILES} 个文件` : `Import at most ${KNOWLEDGE_IMPORT_MAX_FILES} files at a time`;
|
||||||
|
}
|
||||||
|
let total = 0;
|
||||||
|
for (const file of files) {
|
||||||
|
total += file.size || 0;
|
||||||
|
if ((file.size || 0) > KNOWLEDGE_IMPORT_MAX_FILE_SIZE) {
|
||||||
|
return currentLang === 'zh' ? `${file.name} 超过 10MB` : `${file.name} exceeds 10MB`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (total > KNOWLEDGE_IMPORT_MAX_TOTAL_SIZE) {
|
||||||
|
return currentLang === 'zh' ? '单次导入总大小不能超过 200MB' : 'Total import size cannot exceed 200MB';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let _knowledgeImportDropReady = false;
|
||||||
|
function initKnowledgeImportDropZone() {
|
||||||
|
if (_knowledgeImportDropReady) return;
|
||||||
|
const panel = document.getElementById('knowledge-panel-docs');
|
||||||
|
if (!panel) return;
|
||||||
|
_knowledgeImportDropReady = true;
|
||||||
|
['dragenter', 'dragover'].forEach(name => {
|
||||||
|
panel.addEventListener(name, event => {
|
||||||
|
if (!event.dataTransfer || !event.dataTransfer.types.includes('Files')) return;
|
||||||
|
event.preventDefault();
|
||||||
|
panel.classList.add('knowledge-import-drag-over');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
['dragleave', 'drop'].forEach(name => {
|
||||||
|
panel.addEventListener(name, event => {
|
||||||
|
if (event.type === 'drop') {
|
||||||
|
event.preventDefault();
|
||||||
|
const files = Array.from(event.dataTransfer?.files || []);
|
||||||
|
if (files.length) openKnowledgeImportDialog(files);
|
||||||
|
}
|
||||||
|
panel.classList.remove('knowledge-import-drag-over');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renameKnowledgeCategory(path) {
|
function renameKnowledgeCategory(path) {
|
||||||
openKnowledgeDialog({
|
openKnowledgeDialog({
|
||||||
title: currentLang === 'zh' ? '重命名分类' : 'Rename category',
|
title: currentLang === 'zh' ? '重命名分类' : 'Rename category',
|
||||||
@@ -8451,6 +9084,9 @@ function showLoginScreen() {
|
|||||||
if (currentLang === 'en') {
|
if (currentLang === 'en') {
|
||||||
subtitle.textContent = 'Enter password to access the console';
|
subtitle.textContent = 'Enter password to access the console';
|
||||||
loginBtn.textContent = 'Login';
|
loginBtn.textContent = 'Login';
|
||||||
|
} else if (currentLang === 'zh-Hant') {
|
||||||
|
subtitle.textContent = '請輸入密碼以存取控制台';
|
||||||
|
loginBtn.textContent = '登入';
|
||||||
} else {
|
} else {
|
||||||
subtitle.textContent = '请输入密码以访问控制台';
|
subtitle.textContent = '请输入密码以访问控制台';
|
||||||
loginBtn.textContent = '登录';
|
loginBtn.textContent = '登录';
|
||||||
@@ -8477,16 +9113,30 @@ function showLoginScreen() {
|
|||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
overlay.classList.add('hidden');
|
overlay.classList.add('hidden');
|
||||||
document.getElementById('app').classList.remove('hidden');
|
document.getElementById('app').classList.remove('hidden');
|
||||||
|
const logoutBtn = document.getElementById('logout-btn-header');
|
||||||
|
if (logoutBtn) logoutBtn.classList.remove('hidden');
|
||||||
initApp();
|
initApp();
|
||||||
} else {
|
} else {
|
||||||
errEl.textContent = currentLang === 'zh' ? '密码错误' : 'Wrong password';
|
if (currentLang === 'zh-Hant') {
|
||||||
|
errEl.textContent = '密碼錯誤';
|
||||||
|
} else if (currentLang === 'zh') {
|
||||||
|
errEl.textContent = '密码错误';
|
||||||
|
} else {
|
||||||
|
errEl.textContent = 'Wrong password';
|
||||||
|
}
|
||||||
errEl.classList.remove('hidden');
|
errEl.classList.remove('hidden');
|
||||||
pwdInput.value = '';
|
pwdInput.value = '';
|
||||||
pwdInput.focus();
|
pwdInput.focus();
|
||||||
}
|
}
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
errEl.textContent = currentLang === 'zh' ? '网络错误,请重试' : 'Network error, please retry';
|
if (currentLang === 'zh-Hant') {
|
||||||
|
errEl.textContent = '網路錯誤,請重試';
|
||||||
|
} else if (currentLang === 'zh') {
|
||||||
|
errEl.textContent = '网络错误,请重试';
|
||||||
|
} else {
|
||||||
|
errEl.textContent = 'Network error, please retry';
|
||||||
|
}
|
||||||
errEl.classList.remove('hidden');
|
errEl.classList.remove('hidden');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
});
|
});
|
||||||
@@ -8494,6 +9144,19 @@ function showLoginScreen() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
fetch('/auth/logout', {
|
||||||
|
method: 'POST'
|
||||||
|
}).then(r => r.json()).then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.handleLogout = handleLogout;
|
||||||
|
|
||||||
// Intercept 401 responses globally to show login screen on session expiry
|
// Intercept 401 responses globally to show login screen on session expiry
|
||||||
const _originalFetch = window.fetch;
|
const _originalFetch = window.fetch;
|
||||||
window.fetch = function(...args) {
|
window.fetch = function(...args) {
|
||||||
@@ -8539,6 +9202,10 @@ fetch('/auth/check').then(r => r.json()).then(data => {
|
|||||||
if (data.auth_required && !data.authenticated) {
|
if (data.auth_required && !data.authenticated) {
|
||||||
showLoginScreen();
|
showLoginScreen();
|
||||||
} else {
|
} else {
|
||||||
|
if (data.auth_required) {
|
||||||
|
const logoutBtn = document.getElementById('logout-btn-header');
|
||||||
|
if (logoutBtn) logoutBtn.classList.remove('hidden');
|
||||||
|
}
|
||||||
initApp();
|
initApp();
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
|
|||||||
@@ -181,6 +181,29 @@ def _read_uploaded_file_bytes(file_obj) -> bytes:
|
|||||||
raise TypeError(f"Unsupported uploaded content type: {type(content).__name__}")
|
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():
|
def _raw_web_input():
|
||||||
"""Return unprocessed multipart form data when web.py exposes rawinput."""
|
"""Return unprocessed multipart form data when web.py exposes rawinput."""
|
||||||
rawinput = getattr(getattr(web, "webapi", None), "rawinput", None)
|
rawinput = getattr(getattr(web, "webapi", None), "rawinput", None)
|
||||||
@@ -240,7 +263,13 @@ class WebChannel(ChatChannel):
|
|||||||
self.session_queues = {} # session_id -> Queue (fallback polling)
|
self.session_queues = {} # session_id -> Queue (fallback polling)
|
||||||
self.request_to_session = {} # request_id -> session_id
|
self.request_to_session = {} # request_id -> session_id
|
||||||
self.sse_queues = {} # request_id -> Queue (SSE streaming)
|
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._http_server = None
|
||||||
|
self._sse_janitor_started = False
|
||||||
|
|
||||||
def _generate_msg_id(self):
|
def _generate_msg_id(self):
|
||||||
"""生成唯一的消息ID"""
|
"""生成唯一的消息ID"""
|
||||||
@@ -527,14 +556,29 @@ class WebChannel(ChatChannel):
|
|||||||
file_path = data.get("path", "")
|
file_path = data.get("path", "")
|
||||||
file_name = data.get("file_name", os.path.basename(file_path))
|
file_name = data.get("file_name", os.path.basename(file_path))
|
||||||
file_type = data.get("file_type", "file")
|
file_type = data.get("file_type", "file")
|
||||||
from urllib.parse import quote
|
# Remote URLs are passed through as-is; local files are served
|
||||||
web_url = f"/api/file?path={quote(file_path)}"
|
# 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"
|
is_image = file_type == "image"
|
||||||
q.put({
|
payload = {
|
||||||
"type": "image" if is_image else "file",
|
"type": "image" if is_image else "file",
|
||||||
"content": web_url,
|
"content": web_url,
|
||||||
"file_name": file_name,
|
"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
|
return on_event
|
||||||
|
|
||||||
@@ -865,6 +909,7 @@ class WebChannel(ChatChannel):
|
|||||||
|
|
||||||
if use_sse:
|
if use_sse:
|
||||||
self.sse_queues[request_id] = Queue()
|
self.sse_queues[request_id] = Queue()
|
||||||
|
self.sse_last_active[request_id] = time.time()
|
||||||
|
|
||||||
trigger_prefixs = conf().get("single_chat_prefix", [""])
|
trigger_prefixs = conf().get("single_chat_prefix", [""])
|
||||||
if check_prefix(prompt, trigger_prefixs) is None:
|
if check_prefix(prompt, trigger_prefixs) is None:
|
||||||
@@ -879,8 +924,7 @@ class WebChannel(ChatChannel):
|
|||||||
|
|
||||||
if context is None:
|
if context is None:
|
||||||
logger.warning(f"[WebChannel] Context is None for session {session_id}, message may be filtered")
|
logger.warning(f"[WebChannel] Context is None for session {session_id}, message may be filtered")
|
||||||
if request_id in self.sse_queues:
|
self._drop_sse_request(request_id)
|
||||||
del self.sse_queues[request_id]
|
|
||||||
return json.dumps({"status": "error", "message": "Message was filtered"})
|
return json.dumps({"status": "error", "message": "Message was filtered"})
|
||||||
|
|
||||||
context["session_id"] = session_id
|
context["session_id"] = session_id
|
||||||
@@ -903,6 +947,60 @@ class WebChannel(ChatChannel):
|
|||||||
logger.error(f"Error processing message: {e}")
|
logger.error(f"Error processing message: {e}")
|
||||||
return json.dumps({"status": "error", "message": str(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):
|
def stream_response(self, request_id: str):
|
||||||
"""
|
"""
|
||||||
SSE generator for a given request_id.
|
SSE generator for a given request_id.
|
||||||
@@ -927,6 +1025,10 @@ class WebChannel(ChatChannel):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
while time.time() < deadline:
|
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:
|
try:
|
||||||
item = q.get(timeout=1)
|
item = q.get(timeout=1)
|
||||||
except Empty:
|
except Empty:
|
||||||
@@ -955,13 +1057,21 @@ class WebChannel(ChatChannel):
|
|||||||
# voice_attach payload through to the browser.
|
# voice_attach payload through to the browser.
|
||||||
post_done = True
|
post_done = True
|
||||||
post_deadline = time.time() + 2 # 2s post-attach tail
|
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:
|
finally:
|
||||||
# Only drop the queue once the reply is actually complete. If the
|
# Drop the queue once the reply is actually complete or the idle
|
||||||
# client disconnected early (e.g. switched sessions and will
|
# deadline has passed. Early client disconnects are handled by the
|
||||||
# re-attach with the same request_id), keep the queue so the new
|
# GeneratorExit branch above and the background janitor.
|
||||||
# connection can resume reading the remaining events.
|
|
||||||
if post_done or time.time() >= deadline:
|
if post_done or time.time() >= deadline:
|
||||||
self.sse_queues.pop(request_id, None)
|
self._drop_sse_request(request_id)
|
||||||
|
|
||||||
def cancel_request(self):
|
def cancel_request(self):
|
||||||
"""
|
"""
|
||||||
@@ -1062,7 +1172,10 @@ class WebChannel(ChatChannel):
|
|||||||
def startup(self):
|
def startup(self):
|
||||||
configured_host = conf().get("web_host", "")
|
configured_host = conf().get("web_host", "")
|
||||||
host = configured_host or ("0.0.0.0" if _is_password_enabled() else "127.0.0.1")
|
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", "::")
|
is_public_bind = host in ("0.0.0.0", "::")
|
||||||
|
|
||||||
self._cleanup_stale_voice_recordings()
|
self._cleanup_stale_voice_recordings()
|
||||||
@@ -1162,6 +1275,7 @@ class WebChannel(ChatChannel):
|
|||||||
'/api/knowledge/read', 'KnowledgeReadHandler',
|
'/api/knowledge/read', 'KnowledgeReadHandler',
|
||||||
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
'/api/knowledge/graph', 'KnowledgeGraphHandler',
|
||||||
'/api/knowledge/action', 'KnowledgeActionHandler',
|
'/api/knowledge/action', 'KnowledgeActionHandler',
|
||||||
|
'/api/knowledge/import', 'KnowledgeImportHandler',
|
||||||
'/api/scheduler', 'SchedulerHandler',
|
'/api/scheduler', 'SchedulerHandler',
|
||||||
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
'/api/scheduler/toggle', 'SchedulerToggleHandler',
|
||||||
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
'/api/scheduler/update', 'SchedulerUpdateHandler',
|
||||||
@@ -1198,6 +1312,8 @@ class WebChannel(ChatChannel):
|
|||||||
server.requests.min = 20
|
server.requests.min = 20
|
||||||
server.requests.max = 80
|
server.requests.max = 80
|
||||||
self._http_server = server
|
self._http_server = server
|
||||||
|
# Reclaim orphaned SSE queues so disconnected clients don't leak fds.
|
||||||
|
self._start_sse_janitor()
|
||||||
try:
|
try:
|
||||||
server.start()
|
server.start()
|
||||||
except (KeyboardInterrupt, SystemExit):
|
except (KeyboardInterrupt, SystemExit):
|
||||||
@@ -1481,14 +1597,14 @@ class ConfigHandler:
|
|||||||
_RECOMMENDED_MODELS = [
|
_RECOMMENDED_MODELS = [
|
||||||
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
const.DEEPSEEK_V4_FLASH, const.DEEPSEEK_V4_PRO,
|
||||||
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
const.MINIMAX_M3, const.MINIMAX_M2_7_HIGHSPEED, const.MINIMAX_M2_7,
|
||||||
# claude-fable-5 is placed after claude-opus-4-7 (not as the Claude
|
# claude-sonnet-5 is the Claude default; claude-fable-5 is dropped
|
||||||
# default) since it is often unavailable due to policy restrictions.
|
# from this web console list for now.
|
||||||
const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_FABLE_5, const.CLAUDE_4_6_SONNET, const.CLAUDE_4_6_OPUS,
|
const.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.GEMINI_35_FLASH, const.GEMINI_31_FLASH_LITE_PRE, const.GEMINI_31_PRO_PRE, const.GEMINI_3_FLASH_PRE,
|
||||||
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
const.GPT_55, const.GPT_54, const.GPT_54_MINI, const.GPT_54_NANO, const.GPT_5, const.GPT_41, const.GPT_4o,
|
||||||
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
const.GLM_5_2, const.GLM_5_1, const.GLM_5_TURBO, const.GLM_5, const.GLM_4_7,
|
||||||
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
const.QWEN37_PLUS, const.QWEN37_MAX, const.QWEN36_PLUS,
|
||||||
const.DOUBAO_SEED_2_PRO, const.DOUBAO_SEED_2_CODE,
|
const.DOUBAO_SEED_2_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.KIMI_K2_7_CODE, const.KIMI_K2_7_CODE_HIGHSPEED, const.KIMI_K2_6, const.KIMI_K2_5, const.KIMI_K2,
|
||||||
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
const.ERNIE_5_1, const.ERNIE_5, const.ERNIE_X1_1, const.ERNIE_45_TURBO_128K, const.ERNIE_45_TURBO_32K,
|
||||||
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
const.MIMO_V2_5_PRO, const.MIMO_V2_5,
|
||||||
@@ -1528,7 +1644,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "claude_api_base",
|
"api_base_key": "claude_api_base",
|
||||||
"api_base_default": "https://api.anthropic.com/v1",
|
"api_base_default": "https://api.anthropic.com/v1",
|
||||||
"api_base_placeholder": _PLACEHOLDER_V1,
|
"api_base_placeholder": _PLACEHOLDER_V1,
|
||||||
"models": [const.CLAUDE_4_8_OPUS, const.CLAUDE_4_7_OPUS, const.CLAUDE_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", {
|
("gemini", {
|
||||||
"label": "Gemini",
|
"label": "Gemini",
|
||||||
@@ -1568,7 +1684,7 @@ class ConfigHandler:
|
|||||||
"api_base_key": "ark_base_url",
|
"api_base_key": "ark_base_url",
|
||||||
"api_base_default": "https://ark.cn-beijing.volces.com/api/v3",
|
"api_base_default": "https://ark.cn-beijing.volces.com/api/v3",
|
||||||
"api_base_placeholder": _PLACEHOLDER_DOUBAO,
|
"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", {
|
("moonshot", {
|
||||||
"label": "Kimi",
|
"label": "Kimi",
|
||||||
@@ -1738,9 +1854,13 @@ class ConfigHandler:
|
|||||||
return json.dumps({"status": "error", "message": "no valid keys to update"})
|
return json.dumps({"status": "error", "message": "no valid keys to update"})
|
||||||
|
|
||||||
config_path = os.path.join(get_data_root(), "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):
|
if os.path.exists(config_path):
|
||||||
with open(config_path, "r", encoding="utf-8") as f:
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
file_cfg = json.load(f)
|
file_cfg = json.load(f)
|
||||||
|
# Capture old password before updating
|
||||||
|
if "web_password" in applied:
|
||||||
|
old_password = file_cfg.get("web_password", "")
|
||||||
else:
|
else:
|
||||||
file_cfg = {}
|
file_cfg = {}
|
||||||
file_cfg.update(applied)
|
file_cfg.update(applied)
|
||||||
@@ -1758,6 +1878,26 @@ class ConfigHandler:
|
|||||||
except Exception as lang_err:
|
except Exception as lang_err:
|
||||||
logger.warning(f"[WebChannel] Failed to apply language: {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.
|
# Reset Bridge so that bot routing reflects the new config.
|
||||||
# Without this, Bridge keeps its cached bot instance (e.g. LinkAIBot)
|
# Without this, Bridge keeps its cached bot instance (e.g. LinkAIBot)
|
||||||
# even after the user switches bot_type / use_linkai / model in UI.
|
# even after the user switches bot_type / use_linkai / model in UI.
|
||||||
@@ -1770,7 +1910,7 @@ class ConfigHandler:
|
|||||||
except Exception as reset_err:
|
except Exception as reset_err:
|
||||||
logger.warning(f"[WebChannel] Failed to reset bridge: {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:
|
except Exception as e:
|
||||||
logger.error(f"Error updating config: {e}")
|
logger.error(f"Error updating config: {e}")
|
||||||
return json.dumps({"status": "error", "message": str(e)})
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
@@ -2102,10 +2242,10 @@ class ModelsHandler:
|
|||||||
const.GPT_41_MINI,
|
const.GPT_41_MINI,
|
||||||
const.GPT_4o,
|
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],
|
"moonshot": [const.KIMI_K2_6],
|
||||||
"dashscope": [const.QWEN37_PLUS, const.QWEN36_PLUS],
|
"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],
|
"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],
|
"qianfan": [const.ERNIE_45_TURBO_VL],
|
||||||
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
# Zhipu's bot hard-codes the call to glm-5v-turbo regardless of what
|
||||||
@@ -2126,9 +2266,9 @@ class ModelsHandler:
|
|||||||
const.GPT_41_MINI,
|
const.GPT_41_MINI,
|
||||||
const.GPT_54_MINI,
|
const.GPT_54_MINI,
|
||||||
const.QWEN37_PLUS,
|
const.QWEN37_PLUS,
|
||||||
const.DOUBAO_SEED_2_PRO,
|
const.DOUBAO_SEED_2_1_PRO,
|
||||||
const.KIMI_K2_6,
|
const.KIMI_K2_6,
|
||||||
const.CLAUDE_4_6_SONNET,
|
const.CLAUDE_SONNET_5,
|
||||||
const.GEMINI_31_FLASH_LITE_PRE,
|
const.GEMINI_31_FLASH_LITE_PRE,
|
||||||
],
|
],
|
||||||
# Custom OpenAI-compatible providers have no preset list — model
|
# Custom OpenAI-compatible providers have no preset list — model
|
||||||
@@ -2357,7 +2497,7 @@ class ModelsHandler:
|
|||||||
("moonshot", "moonshot_api_key", const.KIMI_K2_6),
|
("moonshot", "moonshot_api_key", const.KIMI_K2_6),
|
||||||
("doubao", "ark_api_key", const.DOUBAO_SEED_2_PRO),
|
("doubao", "ark_api_key", const.DOUBAO_SEED_2_PRO),
|
||||||
("dashscope", "dashscope_api_key", const.QWEN37_PLUS),
|
("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),
|
("gemini", "gemini_api_key", const.GEMINI_35_FLASH),
|
||||||
("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL),
|
("qianfan", "qianfan_api_key", const.ERNIE_45_TURBO_VL),
|
||||||
("zhipu", "zhipu_ai_api_key", const.GLM_5V_TURBO),
|
("zhipu", "zhipu_ai_api_key", const.GLM_5V_TURBO),
|
||||||
@@ -3551,11 +3691,13 @@ class ChannelsHandler:
|
|||||||
_require_auth()
|
_require_auth()
|
||||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
try:
|
try:
|
||||||
|
from common import i18n
|
||||||
local_config = conf()
|
local_config = conf()
|
||||||
active_channels = self._active_channel_set()
|
active_channels = self._active_channel_set()
|
||||||
# Desktop build ships without lark-oapi, so hide Feishu from the list.
|
# Desktop build ships without lark-oapi, so hide Feishu from the list.
|
||||||
desktop_mode = os.environ.get("COW_DESKTOP") == "1"
|
desktop_mode = os.environ.get("COW_DESKTOP") == "1"
|
||||||
channels = []
|
channels = []
|
||||||
|
is_hant = i18n.get_language() == i18n.ZH_HANT
|
||||||
for ch_name, ch_def in self.CHANNEL_DEFS.items():
|
for ch_name, ch_def in self.CHANNEL_DEFS.items():
|
||||||
if desktop_mode and ch_name == "feishu":
|
if desktop_mode and ch_name == "feishu":
|
||||||
continue
|
continue
|
||||||
@@ -3566,16 +3708,32 @@ class ChannelsHandler:
|
|||||||
display_val = self._mask_secret(str(raw_val))
|
display_val = self._mask_secret(str(raw_val))
|
||||||
else:
|
else:
|
||||||
display_val = raw_val
|
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({
|
fields_out.append({
|
||||||
"key": f["key"],
|
"key": f["key"],
|
||||||
"label": f["label"],
|
"label": label_val,
|
||||||
"type": f["type"],
|
"type": f["type"],
|
||||||
"value": display_val,
|
"value": display_val,
|
||||||
"default": f.get("default", ""),
|
"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 = {
|
ch_info = {
|
||||||
"name": ch_name,
|
"name": ch_name,
|
||||||
"label": ch_def["label"],
|
"label": label_val,
|
||||||
"icon": ch_def["icon"],
|
"icon": ch_def["icon"],
|
||||||
"color": ch_def["color"],
|
"color": ch_def["color"],
|
||||||
"active": ch_name in active_channels,
|
"active": ch_name in active_channels,
|
||||||
@@ -4132,16 +4290,26 @@ class ToolsHandler:
|
|||||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
try:
|
try:
|
||||||
from agent.tools.tool_manager import ToolManager
|
from agent.tools.tool_manager import ToolManager
|
||||||
|
from common import i18n
|
||||||
tm = ToolManager()
|
tm = ToolManager()
|
||||||
if not tm.tool_classes:
|
if not tm.tool_classes:
|
||||||
tm.load_tools()
|
tm.load_tools()
|
||||||
tools = []
|
tools = []
|
||||||
|
lang = i18n.get_language()
|
||||||
for name, cls in tm.tool_classes.items():
|
for name, cls in tm.tool_classes.items():
|
||||||
try:
|
try:
|
||||||
instance = cls()
|
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({
|
tools.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"description": instance.description,
|
"description": desc,
|
||||||
})
|
})
|
||||||
except Exception:
|
except Exception:
|
||||||
tools.append({"name": name, "description": ""})
|
tools.append({"name": name, "description": ""})
|
||||||
@@ -4158,10 +4326,17 @@ class SkillsHandler:
|
|||||||
try:
|
try:
|
||||||
from agent.skills.service import SkillService
|
from agent.skills.service import SkillService
|
||||||
from agent.skills.manager import SkillManager
|
from agent.skills.manager import SkillManager
|
||||||
|
from common import i18n
|
||||||
workspace_root = _get_workspace_root()
|
workspace_root = _get_workspace_root()
|
||||||
manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills"))
|
manager = SkillManager(custom_dir=os.path.join(workspace_root, "skills"))
|
||||||
service = SkillService(manager)
|
service = SkillService(manager)
|
||||||
skills = service.query()
|
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)
|
return json.dumps({"status": "success", "skills": skills}, ensure_ascii=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[WebChannel] Skills API error: {e}")
|
logger.error(f"[WebChannel] Skills API error: {e}")
|
||||||
@@ -4731,6 +4906,71 @@ class KnowledgeActionHandler:
|
|||||||
return json.dumps({"status": "error", "code": 500, "message": str(e), "payload": None})
|
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:
|
class VersionHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
web.header('Content-Type', 'application/json; charset=utf-8')
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2.1.1
|
2.1.3
|
||||||
|
|||||||
@@ -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_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_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_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 (Google)
|
||||||
GEMINI_PRO = "gemini-1.0-pro"
|
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 (Volcengine Ark)
|
||||||
DOUBAO = "doubao"
|
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_CODE = "doubao-seed-2-0-code-preview-260215"
|
||||||
DOUBAO_SEED_2_PRO = "doubao-seed-2-0-pro-260215"
|
DOUBAO_SEED_2_PRO = "doubao-seed-2-0-pro-260215"
|
||||||
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
|
DOUBAO_SEED_2_LITE = "doubao-seed-2-0-lite-260215"
|
||||||
@@ -197,7 +200,7 @@ MODEL_LIST = [
|
|||||||
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
MIMO, MIMO_V2_5_PRO, MIMO_V2_5, MIMO_V2_PRO, MIMO_V2_OMNI, MIMO_V2_FLASH,
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
CLAUDE3, CLAUDE_4_8_OPUS, CLAUDE_4_7_OPUS, CLAUDE_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_35_SONNET, CLAUDE_35_SONNET_1022, CLAUDE_35_SONNET_0620, CLAUDE_3_SONNET, CLAUDE_3_HAIKU,
|
||||||
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
"claude", "claude-3-haiku", "claude-3-sonnet", "claude-3-opus", "claude-3.5-sonnet",
|
||||||
|
|
||||||
@@ -223,7 +226,8 @@ MODEL_LIST = [
|
|||||||
QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
|
QWEN37_PLUS, QWEN37_MAX, QWEN36_PLUS, QWEN35_PLUS, QWEN3_MAX, QWEN_MAX, QWEN_PLUS, QWEN_TURBO, QWEN_LONG,
|
||||||
|
|
||||||
# Doubao
|
# Doubao
|
||||||
DOUBAO, DOUBAO_SEED_2_CODE, DOUBAO_SEED_2_PRO, DOUBAO_SEED_2_LITE, DOUBAO_SEED_2_MINI,
|
DOUBAO, DOUBAO_SEED_2_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)
|
# Kimi (Moonshot)
|
||||||
MOONSHOT, "moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k",
|
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
|
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.
|
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):
|
Resolution priority (highest first):
|
||||||
1. Explicit `cow_lang` from config.json — also covers Docker/CI, since any
|
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),
|
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
|
5. Default -> English
|
||||||
|
|
||||||
A value of "auto" (the default) triggers detection (steps 2-5). Explicitly
|
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
|
import os
|
||||||
@@ -28,10 +36,85 @@ import sys
|
|||||||
|
|
||||||
# Supported language codes
|
# Supported language codes
|
||||||
ZH = "zh"
|
ZH = "zh"
|
||||||
|
ZH_HANT = "zh-Hant"
|
||||||
EN = "en"
|
EN = "en"
|
||||||
SUPPORTED = (ZH, EN)
|
SUPPORTED = (ZH, ZH_HANT, EN)
|
||||||
DEFAULT_LANG = 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 language cache; None until first resolution.
|
||||||
_resolved_lang = None
|
_resolved_lang = None
|
||||||
|
|
||||||
@@ -48,7 +131,10 @@ def _normalize(raw):
|
|||||||
value = str(raw).strip().lower().replace("_", "-")
|
value = str(raw).strip().lower().replace("_", "-")
|
||||||
if value in ("auto", ""):
|
if value in ("auto", ""):
|
||||||
return None
|
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"):
|
if value.startswith("zh") or value.startswith("chinese"):
|
||||||
return ZH
|
return ZH
|
||||||
if value.startswith("en") or value.startswith("english"):
|
if value.startswith("en") or value.startswith("english"):
|
||||||
@@ -167,7 +253,7 @@ def get_language():
|
|||||||
|
|
||||||
|
|
||||||
def is_zh():
|
def is_zh():
|
||||||
return get_language() == ZH
|
return get_language() in (ZH, ZH_HANT)
|
||||||
|
|
||||||
|
|
||||||
def t(zh_text, en_text):
|
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:
|
Intended for one-off strings where a full message catalog is overkill:
|
||||||
t("已中止", "Cancelled")
|
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
|
||||||
|
|||||||
@@ -33,15 +33,28 @@ def _reset_logger(log):
|
|||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
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)
|
|
||||||
log.addHandler(console_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():
|
def _get_logger():
|
||||||
|
|||||||
@@ -41,5 +41,8 @@
|
|||||||
"enable_thinking": false,
|
"enable_thinking": false,
|
||||||
"reasoning_effort": "high",
|
"reasoning_effort": "high",
|
||||||
"knowledge": true,
|
"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
|
||||||
}
|
}
|
||||||
|
|||||||
32
config.py
32
config.py
@@ -1,5 +1,6 @@
|
|||||||
# encoding:utf-8
|
# encoding:utf-8
|
||||||
|
|
||||||
|
import ast
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -264,8 +265,17 @@ available_setting = {
|
|||||||
"self_evolution_enabled": False, # switch to enable/disable self-evolution
|
"self_evolution_enabled": False, # switch to enable/disable self-evolution
|
||||||
"self_evolution_idle_minutes": 10, # idle time before a session is reviewed
|
"self_evolution_idle_minutes": 10, # idle time before a session is reviewed
|
||||||
"self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger
|
"self_evolution_min_turns": 6, # min user turns (or context pressure) to trigger
|
||||||
|
# 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
|
"skill": {}, # Per-skill runtime config; nested keys flatten to SKILL_<NAME>_<KEY> env vars at startup
|
||||||
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
|
"mcp_servers": [], # MCP server list; each entry supports type "stdio" (local process) or "sse" (remote URL)
|
||||||
|
# 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -307,6 +317,12 @@ class Config(dict):
|
|||||||
self.user_datas[user] = {}
|
self.user_datas[user] = {}
|
||||||
return 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):
|
def load_user_datas(self):
|
||||||
try:
|
try:
|
||||||
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "rb") as f:
|
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "rb") as f:
|
||||||
@@ -320,6 +336,8 @@ class Config(dict):
|
|||||||
|
|
||||||
def save_user_datas(self):
|
def save_user_datas(self):
|
||||||
try:
|
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:
|
with open(os.path.join(get_appdata_dir(), "user_datas.pkl"), "wb") as f:
|
||||||
pickle.dump(self.user_datas, f)
|
pickle.dump(self.user_datas, f)
|
||||||
logger.info("[Config] User datas saved.")
|
logger.info("[Config] User datas saved.")
|
||||||
@@ -415,11 +433,19 @@ def load_config():
|
|||||||
if name in available_setting:
|
if name in available_setting:
|
||||||
logger.info("[INIT] override config by environ args: {}={}".format(name, value))
|
logger.info("[INIT] override config by environ args: {}={}".format(name, value))
|
||||||
try:
|
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:
|
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
|
config[name] = False
|
||||||
elif value == "true":
|
elif value.lower() == "true":
|
||||||
config[name] = True
|
config[name] = True
|
||||||
else:
|
else:
|
||||||
config[name] = value
|
config[name] = value
|
||||||
|
|||||||
@@ -52,15 +52,36 @@ hiddenimports += collect_submodules('models')
|
|||||||
hiddenimports += collect_submodules('voice')
|
hiddenimports += collect_submodules('voice')
|
||||||
hiddenimports += collect_submodules('bridge')
|
hiddenimports += collect_submodules('bridge')
|
||||||
|
|
||||||
# Plugin framework: WebChannel -> ChatChannel imports `from plugins import *`,
|
# Plugin framework + plugins. WebChannel -> ChatChannel imports
|
||||||
# so the framework package must be present even though desktop mode never loads
|
# `from plugins import *`, and desktop mode loads plugins (in a background
|
||||||
# actual plugins (it's only ~tens of KB of code).
|
# 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 += [
|
hiddenimports += [
|
||||||
'plugins',
|
'plugins',
|
||||||
'plugins.event',
|
'plugins.event',
|
||||||
'plugins.plugin',
|
'plugins.plugin',
|
||||||
'plugins.plugin_manager',
|
'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.
|
# Third-party SDKs that use lazy/conditional imports internally.
|
||||||
hiddenimports += collect_submodules('dashscope')
|
hiddenimports += collect_submodules('dashscope')
|
||||||
@@ -69,12 +90,34 @@ hiddenimports += [
|
|||||||
'tiktoken_ext.openai_public',
|
'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 -----------------------------------------------------------
|
# --- Data files -----------------------------------------------------------
|
||||||
# Runtime-read files/dirs that must travel with the executable. Paths are
|
# Runtime-read files/dirs that must travel with the executable. Paths are
|
||||||
# (source, dest_dir_in_bundle).
|
# (source, dest_dir_in_bundle).
|
||||||
datas = [
|
datas = [
|
||||||
(rp('config-template.json'), '.'),
|
(rp('config-template.json'), '.'),
|
||||||
(rp('skills'), 'skills'),
|
(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
|
# 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
|
# assets (~1.9MB) so the browser-based console works as a debug/fallback
|
||||||
# entry alongside the Electron UI.
|
# entry alongside the Electron UI.
|
||||||
@@ -85,6 +128,12 @@ datas = [
|
|||||||
# Some libraries (tiktoken encodings, etc.) ship data files.
|
# Some libraries (tiktoken encodings, etc.) ship data files.
|
||||||
datas += collect_data_files('tiktoken_ext', include_py_files=False)
|
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 -------------------------------------------------------------
|
# --- Excludes -------------------------------------------------------------
|
||||||
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
|
# Keep the bundle lean: drop Feishu's heavy SDK, plugins (disabled in desktop
|
||||||
# mode), tests/docs, and dev-only packages.
|
# mode), tests/docs, and dev-only packages.
|
||||||
|
|||||||
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."
|
||||||
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
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cowagent-desktop",
|
"name": "cowagent-desktop",
|
||||||
"version": "1.0.0",
|
"version": "2.1.3",
|
||||||
"description": "CowAgent Desktop Client - AI Agent on your desktop",
|
"description": "CowAgent Desktop Client - AI Agent on your desktop",
|
||||||
"main": "dist/main/index.js",
|
"main": "dist/main/index.js",
|
||||||
"author": "CowAgent",
|
"author": "CowAgent",
|
||||||
@@ -67,6 +67,12 @@
|
|||||||
"mac": {
|
"mac": {
|
||||||
"category": "public.app-category.productivity",
|
"category": "public.app-category.productivity",
|
||||||
"icon": "resources/icon.icns",
|
"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": [
|
||||||
{
|
{
|
||||||
"target": "dmg",
|
"target": "dmg",
|
||||||
@@ -74,11 +80,19 @@
|
|||||||
"arm64",
|
"arm64",
|
||||||
"x64"
|
"x64"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"target": "zip",
|
||||||
|
"arch": [
|
||||||
|
"arm64",
|
||||||
|
"x64"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"icon": "resources/icon.ico",
|
"icon": "resources/icon.ico",
|
||||||
|
"artifactName": "${productName}-Setup-${version}-${arch}.${ext}",
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
@@ -90,14 +104,14 @@
|
|||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
|
"perMachine": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
"createDesktopShortcut": true,
|
"createDesktopShortcut": true,
|
||||||
"createStartMenuShortcut": true
|
"createStartMenuShortcut": true
|
||||||
},
|
},
|
||||||
"publish": {
|
"publish": {
|
||||||
"provider": "github",
|
"provider": "generic",
|
||||||
"owner": "zhayujie",
|
"url": "https://cowagent.ai/update/"
|
||||||
"repo": "chatgpt-on-wechat"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import http from 'http'
|
|||||||
import { PythonBackend } from './python-manager'
|
import { PythonBackend } from './python-manager'
|
||||||
import { buildAppMenu } from './menu'
|
import { buildAppMenu } from './menu'
|
||||||
import { createTray, destroyTray } from './tray'
|
import { createTray, destroyTray } from './tray'
|
||||||
import { initUpdater, checkForUpdates, startDownload, quitAndInstall } from './updater'
|
import { initUpdater, checkForUpdates, startDownload, quitAndInstall, setUpdateLanguage } from './updater'
|
||||||
|
|
||||||
// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
|
// Force the product name so the Dock/menu shows "CowAgent" even in dev mode,
|
||||||
// where the default Electron binary would otherwise report "Electron".
|
// where the default Electron binary would otherwise report "Electron".
|
||||||
@@ -125,6 +125,16 @@ function createWindow() {
|
|||||||
mainWindow.loadFile(rendererHtml)
|
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.once('ready-to-show', () => {
|
||||||
mainWindow?.show()
|
mainWindow?.show()
|
||||||
})
|
})
|
||||||
@@ -160,14 +170,20 @@ async function startBackend() {
|
|||||||
pythonBackend = new PythonBackend(backendPath)
|
pythonBackend = new PythonBackend(backendPath)
|
||||||
|
|
||||||
pythonBackend.on('ready', (port: number) => {
|
pythonBackend.on('ready', (port: number) => {
|
||||||
|
console.log(`[backend] ready on port ${port}`)
|
||||||
mainWindow?.webContents.send('backend-status', { status: 'ready', port })
|
mainWindow?.webContents.send('backend-status', { status: 'ready', port })
|
||||||
})
|
})
|
||||||
|
|
||||||
pythonBackend.on('error', (error: string) => {
|
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 })
|
mainWindow?.webContents.send('backend-status', { status: 'error', error })
|
||||||
})
|
})
|
||||||
|
|
||||||
pythonBackend.on('log', (line: string) => {
|
pythonBackend.on('log', (line: string) => {
|
||||||
|
console.log(`[backend] ${line}`)
|
||||||
mainWindow?.webContents.send('backend-log', line)
|
mainWindow?.webContents.send('backend-log', line)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -203,6 +219,15 @@ function setupIPC() {
|
|||||||
return result.canceled ? null : result.filePaths[0]
|
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)
|
// Custom window controls (used by Windows frameless titlebar)
|
||||||
ipcMain.handle('window-minimize', () => mainWindow?.minimize())
|
ipcMain.handle('window-minimize', () => mainWindow?.minimize())
|
||||||
ipcMain.handle('window-maximize', () => {
|
ipcMain.handle('window-maximize', () => {
|
||||||
@@ -214,10 +239,28 @@ function setupIPC() {
|
|||||||
ipcMain.handle('window-close', () => mainWindow?.close())
|
ipcMain.handle('window-close', () => mainWindow?.close())
|
||||||
ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false)
|
ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false)
|
||||||
|
|
||||||
// Auto-update controls (renderer-driven: check, then opt-in download/install)
|
// Current app version, shown in the NavRail footer.
|
||||||
ipcMain.handle('update-check', () => checkForUpdates())
|
ipcMain.handle('get-app-version', () => app.getVersion())
|
||||||
ipcMain.handle('update-download', () => startDownload())
|
|
||||||
ipcMain.handle('update-install', () => quitAndInstall())
|
// 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
|
// 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.
|
// to pick a sensible default UI language on first run before any paint.
|
||||||
@@ -279,10 +322,14 @@ app.whenReady().then(async () => {
|
|||||||
}
|
}
|
||||||
await startBackend()
|
await startBackend()
|
||||||
|
|
||||||
// Wire auto-update and do a first silent check a few seconds after launch so
|
// Wire auto-update: a first silent check a few seconds after launch (so it
|
||||||
// it doesn't compete with backend startup for resources.
|
// 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)
|
initUpdater(() => mainWindow)
|
||||||
setTimeout(() => checkForUpdates(), 5000)
|
setTimeout(() => checkForUpdates(), 5000)
|
||||||
|
const UPDATE_POLL_MS = 4 * 60 * 60 * 1000
|
||||||
|
setInterval(() => checkForUpdates(), UPDATE_POLL_MS)
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
restartBackend: () => ipcRenderer.invoke('restart-backend'),
|
restartBackend: () => ipcRenderer.invoke('restart-backend'),
|
||||||
selectDirectory: () => ipcRenderer.invoke('select-directory'),
|
selectDirectory: () => ipcRenderer.invoke('select-directory'),
|
||||||
selectFile: (filters?: Electron.FileFilter[]) => ipcRenderer.invoke('select-file', filters),
|
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
|
// Each listener registrar returns an unsubscribe fn so renderers can clean
|
||||||
// up on unmount / effect re-run and avoid accumulating duplicate handlers.
|
// up on unmount / effect re-run and avoid accumulating duplicate handlers.
|
||||||
@@ -39,9 +40,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
return () => ipcRenderer.removeListener('menu-action', handler)
|
return () => ipcRenderer.removeListener('menu-action', handler)
|
||||||
},
|
},
|
||||||
|
|
||||||
// Auto-update: trigger checks/download/install and subscribe to status.
|
// Current app version (e.g. "0.0.5"), shown in the NavRail footer.
|
||||||
checkForUpdate: () => ipcRenderer.invoke('update-check'),
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
downloadUpdate: () => ipcRenderer.invoke('update-download'),
|
|
||||||
|
// 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'),
|
installUpdate: () => ipcRenderer.invoke('update-install'),
|
||||||
onUpdateStatus: (callback: (status: unknown) => void) => {
|
onUpdateStatus: (callback: (status: unknown) => void) => {
|
||||||
const handler = (_event: unknown, status: unknown) => callback(status)
|
const handler = (_event: unknown, status: unknown) => callback(status)
|
||||||
|
|||||||
@@ -4,16 +4,26 @@ import path from 'path'
|
|||||||
import os from 'os'
|
import os from 'os'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import http from 'http'
|
import http from 'http'
|
||||||
|
import net from 'net'
|
||||||
|
|
||||||
// Writable data dir for the packaged app (config.json, run.log, user data).
|
// 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
|
// 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.
|
// the read-only app bundle. Source/dev runs keep using the repo CWD instead.
|
||||||
const COW_DATA_DIR = path.join(os.homedir(), '.cow')
|
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 {
|
export class PythonBackend extends EventEmitter {
|
||||||
private process: ChildProcess | null = null
|
private process: ChildProcess | null = null
|
||||||
private backendPath: string
|
private backendPath: string
|
||||||
private port: number = 9899
|
private port: number = DESKTOP_BACKEND_PORT
|
||||||
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
|
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
|
||||||
|
|
||||||
constructor(backendPath: string) {
|
constructor(backendPath: string) {
|
||||||
@@ -66,23 +76,125 @@ export class PythonBackend extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve config.json from the given data dir to read the web port. The
|
* 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
|
* packaged build keeps config in COW_DATA_DIR (~/.cow); dev reads it from the
|
||||||
* repo path. Returns the default port when no config (or web_port) is found.
|
* repo path. Returns null when unset, so the caller can auto-pick a free port
|
||||||
|
* instead of fighting over a fixed one.
|
||||||
*/
|
*/
|
||||||
private readPort(dataDir: string): number {
|
private readConfiguredPort(dataDir: string): number | null {
|
||||||
try {
|
try {
|
||||||
const configPath = path.join(dataDir, 'config.json')
|
const configPath = path.join(dataDir, 'config.json')
|
||||||
if (fs.existsSync(configPath)) {
|
if (fs.existsSync(configPath)) {
|
||||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||||
if (config.web_port) {
|
const p = Number(config.web_port)
|
||||||
return config.web_port
|
if (Number.isInteger(p) && p > 0 && p < 65536) {
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore — fall through to auto-selection
|
||||||
}
|
}
|
||||||
return 9899
|
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> {
|
async start(): Promise<void> {
|
||||||
@@ -97,15 +209,16 @@ export class PythonBackend extends EventEmitter {
|
|||||||
const bundled = this.findBundledBackend()
|
const bundled = this.findBundledBackend()
|
||||||
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
|
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
|
||||||
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
|
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
|
||||||
this.port = this.readPort(dataDir)
|
|
||||||
|
|
||||||
const alreadyRunning = await this.probeHealth()
|
// Always launch our OWN backend (re-entrancy is guarded above by the status
|
||||||
if (alreadyRunning) {
|
// check, so we never double-spawn for this instance). We don't reuse
|
||||||
this.status = 'ready'
|
// whatever happens to be on the port: that's how the app previously attached
|
||||||
this.emit('log', `Backend already running on port ${this.port}`)
|
// to a source-run web console and read the wrong config. The port is fixed
|
||||||
this.emit('ready', this.port)
|
// (or the user's pinned web_port) — never random — so the renderer always
|
||||||
return
|
// 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 command: string
|
||||||
let args: string[]
|
let args: string[]
|
||||||
@@ -114,9 +227,19 @@ export class PythonBackend extends EventEmitter {
|
|||||||
if (bundled) {
|
if (bundled) {
|
||||||
command = bundled
|
command = bundled
|
||||||
args = []
|
args = []
|
||||||
// The onedir bundle reads data files relative to the executable's dir.
|
// Run from the writable data dir (~/.cow), NOT the install dir. When the
|
||||||
cwd = path.dirname(bundled)
|
// app is installed under Program Files, a non-admin user has no write
|
||||||
this.emit('log', `Starting bundled backend: ${bundled}`)
|
// 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 {
|
} else {
|
||||||
const pythonPath = this.findPython()
|
const pythonPath = this.findPython()
|
||||||
const appPath = path.join(this.backendPath, 'app.py')
|
const appPath = path.join(this.backendPath, 'app.py')
|
||||||
@@ -140,6 +263,9 @@ export class PythonBackend extends EventEmitter {
|
|||||||
...process.env,
|
...process.env,
|
||||||
PYTHONUNBUFFERED: '1',
|
PYTHONUNBUFFERED: '1',
|
||||||
COW_DESKTOP: '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 } : {}),
|
...(bundled ? { COW_DATA_DIR } : {}),
|
||||||
},
|
},
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
@@ -160,10 +286,15 @@ export class PythonBackend extends EventEmitter {
|
|||||||
})
|
})
|
||||||
|
|
||||||
this.process.on('exit', (code) => {
|
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.status = 'stopped'
|
||||||
this.emit('log', `Python process exited with code ${code}`)
|
this.emit('log', `Python process exited with code ${code}`)
|
||||||
if (code !== 0 && code !== null) {
|
if (!wasReady && code !== 0 && code !== null) {
|
||||||
this.emit('error', `Python process exited with code ${code}`)
|
this.status = 'error'
|
||||||
|
this.emit('error', `Backend exited during startup (code ${code})`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -175,16 +306,6 @@ export class PythonBackend extends EventEmitter {
|
|||||||
await this.waitForReady()
|
await this.waitForReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
private probeHealth(): Promise<boolean> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
|
|
||||||
resolve(res.statusCode === 200)
|
|
||||||
})
|
|
||||||
req.on('error', () => resolve(false))
|
|
||||||
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private waitForReady(): Promise<void> {
|
private waitForReady(): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// Wall-clock deadline rather than an attempt counter: if the machine
|
// Wall-clock deadline rather than an attempt counter: if the machine
|
||||||
@@ -213,7 +334,9 @@ export class PythonBackend extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const retry = () => {
|
const retry = () => {
|
||||||
if (this.status === 'stopped' || this.status === 'ready') {
|
// 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()
|
resolve()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { app, BrowserWindow } from 'electron'
|
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
|
// electron-updater is CommonJS: its members live on module.exports, with no
|
||||||
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
// meaningful default export. Under module=commonjs + esModuleInterop, a named
|
||||||
// import compiles to `electron_updater_1.autoUpdater` and resolves correctly,
|
// import compiles to `electron_updater_1.autoUpdater` and resolves correctly,
|
||||||
@@ -17,16 +19,93 @@ export type UpdateStatus =
|
|||||||
|
|
||||||
let getWindow: () => BrowserWindow | null = () => null
|
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) {
|
function send(status: UpdateStatus) {
|
||||||
getWindow()?.webContents.send('update-status', status)
|
getWindow()?.webContents.send('update-status', status)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initUpdater(windowGetter: () => BrowserWindow | null): void {
|
export function initUpdater(windowGetter: () => BrowserWindow | null): void {
|
||||||
getWindow = windowGetter
|
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
|
// In dev (not packaged) there's no update feed; skip wiring entirely so
|
||||||
// electron-updater doesn't throw on the missing app-update.yml.
|
// electron-updater doesn't throw on the missing app-update.yml.
|
||||||
if (!app.isPackaged) {
|
if (!app.isPackaged) {
|
||||||
|
log('not packaged — updater wiring skipped')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,40 +113,133 @@ export function initUpdater(windowGetter: () => BrowserWindow | null): void {
|
|||||||
// download, rather than pulling bytes silently in the background.
|
// download, rather than pulling bytes silently in the background.
|
||||||
autoUpdater.autoDownload = false
|
autoUpdater.autoDownload = false
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
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', () => send({ state: 'checking' }))
|
autoUpdater.on('checking-for-update', () => {
|
||||||
autoUpdater.on('update-available', (info) =>
|
log(`checking-for-update: current=${app.getVersion()}`)
|
||||||
send({ state: 'available', version: info.version, notes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined })
|
send({ state: 'checking' })
|
||||||
)
|
})
|
||||||
autoUpdater.on('update-not-available', () => send({ state: 'not-available' }))
|
autoUpdater.on('update-available', (info) => {
|
||||||
autoUpdater.on('download-progress', (p) =>
|
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) })
|
send({ state: 'downloading', percent: Math.round(p.percent) })
|
||||||
)
|
})
|
||||||
autoUpdater.on('update-downloaded', (info) =>
|
autoUpdater.on('update-downloaded', (info) => {
|
||||||
|
log(`update-downloaded: version=${info.version} -> ready to install`)
|
||||||
send({ state: 'downloaded', version: info.version })
|
send({ state: 'downloaded', version: info.version })
|
||||||
)
|
})
|
||||||
autoUpdater.on('error', (err) =>
|
autoUpdater.on('error', (err) => {
|
||||||
send({ state: 'error', message: err == null ? 'unknown' : (err.message || String(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; safe to call when not packaged (no-op).
|
// 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 {
|
export function checkForUpdates(): void {
|
||||||
if (!app.isPackaged) return
|
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) => {
|
autoUpdater.checkForUpdates().catch((err) => {
|
||||||
send({ state: 'error', message: err?.message || String(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 {
|
export function startDownload(): void {
|
||||||
if (!app.isPackaged) return
|
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) => {
|
autoUpdater.downloadUpdate().catch((err) => {
|
||||||
send({ state: 'error', message: err?.message || String(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 {
|
export function quitAndInstall(): void {
|
||||||
if (!app.isPackaged) return
|
if (!app.isPackaged) return
|
||||||
// isSilent=false (show installer), isForceRunAfter=true (relaunch after).
|
log('quitAndInstall: relaunching to install update')
|
||||||
autoUpdater.quitAndInstall(false, true)
|
// 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
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data: blob: http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:*; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*;" />
|
<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>
|
<title>CowAgent</title>
|
||||||
<!-- Local fonts & icons (offline, no CDN) served from publicDir -->
|
<!-- Local fonts & icons (offline, no CDN) served from publicDir -->
|
||||||
<link rel="stylesheet" href="./vendor/fonts/inter/inter.css" />
|
<link rel="stylesheet" href="./vendor/fonts/inter/inter.css" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react'
|
import React, { useState, useCallback, useEffect } from 'react'
|
||||||
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'
|
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { PanelLeftOpen } from 'lucide-react'
|
import { History } from 'lucide-react'
|
||||||
import NavRail from './layout/NavRail'
|
import NavRail from './layout/NavRail'
|
||||||
import SessionList from './layout/SessionList'
|
import SessionList from './layout/SessionList'
|
||||||
import WindowControls from './layout/WindowControls'
|
import WindowControls from './layout/WindowControls'
|
||||||
@@ -27,8 +27,8 @@ const App: React.FC = () => {
|
|||||||
const backend = useBackend()
|
const backend = useBackend()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { isWin } = usePlatform()
|
const { isWin, isMac } = usePlatform()
|
||||||
const { sessionsCollapsed, toggleSessions } = useUIStore()
|
const { sessionsCollapsed, toggleSessions, navCollapsed } = useUIStore()
|
||||||
const onboardingOpen = useOnboardingStore((s) => s.open)
|
const onboardingOpen = useOnboardingStore((s) => s.open)
|
||||||
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
|
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
|
||||||
const [, forceUpdate] = useState(0)
|
const [, forceUpdate] = useState(0)
|
||||||
@@ -107,10 +107,13 @@ const App: React.FC = () => {
|
|||||||
{isChat && sessionsCollapsed && (
|
{isChat && sessionsCollapsed && (
|
||||||
<button
|
<button
|
||||||
onClick={toggleSessions}
|
onClick={toggleSessions}
|
||||||
title={t('nav_expand')}
|
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"
|
// 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' : ''}`}
|
||||||
>
|
>
|
||||||
<PanelLeftOpen size={16} />
|
<History size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0" />
|
<div className="flex-1 min-w-0" />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
KnowledgeList,
|
KnowledgeList,
|
||||||
KnowledgeGraph,
|
KnowledgeGraph,
|
||||||
KnowledgeAction,
|
KnowledgeAction,
|
||||||
|
KnowledgeImportPayload,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
interface ApiResult {
|
interface ApiResult {
|
||||||
@@ -24,7 +25,7 @@ interface ApiResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private baseUrl = 'http://127.0.0.1:9899'
|
private baseUrl = 'http://127.0.0.1:9876'
|
||||||
|
|
||||||
setBaseUrl(url: string) {
|
setBaseUrl(url: string) {
|
||||||
this.baseUrl = url
|
this.baseUrl = url
|
||||||
@@ -315,6 +316,23 @@ class ApiClient {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Scheduler
|
||||||
// ---------------------------------------------------------
|
// ---------------------------------------------------------
|
||||||
|
|||||||
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 |
@@ -1,15 +1,18 @@
|
|||||||
import React, { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
import React, { useState, useRef, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||||
import { Plus, Paperclip, Send, Square, X, File as FileIcon, Loader2 } from 'lucide-react'
|
import { Plus, Paperclip, Square, X, File as FileIcon, Loader2, Trash2 } from 'lucide-react'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
import type { Attachment } from '../types'
|
import type { Attachment } from '../types'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
|
import { PaperPlaneIcon } from './icons'
|
||||||
|
|
||||||
export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
|
export type ChatInputHandle = (text: string, attachments: Attachment[]) => void
|
||||||
|
|
||||||
interface SlashCommand {
|
interface SlashCommand {
|
||||||
cmd: string
|
cmd: string
|
||||||
desc: string
|
desc: string
|
||||||
action: 'new' | 'clear'
|
// '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 {
|
interface ChatInputProps {
|
||||||
@@ -35,16 +38,53 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
|||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(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[] = [
|
const slashCommands: SlashCommand[] = [
|
||||||
{ cmd: '/new', desc: t('session_new'), action: 'new' },
|
{ cmd: '/new', desc: t('slash_new'), action: 'new' },
|
||||||
{ cmd: '/clear', desc: t('chat_clear_context'), action: 'clear' },
|
{ 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()))
|
const filtered = slashCommands.filter((c) => c.cmd.startsWith(text.trim().toLowerCase()))
|
||||||
|
|
||||||
const resetHeight = () => {
|
// Resize the textarea to fit its content (single line = 42px, capped at
|
||||||
if (textareaRef.current) textareaRef.current.style.height = '42px'
|
// 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).
|
// Allow the parent to load a draft (e.g. when editing a past user message).
|
||||||
useImperativeHandle(ref, () => (draft: string, atts: Attachment[]) => {
|
useImperativeHandle(ref, () => (draft: string, atts: Attachment[]) => {
|
||||||
setText(draft)
|
setText(draft)
|
||||||
@@ -53,18 +93,36 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
|||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
if (el) {
|
if (el) {
|
||||||
el.focus()
|
el.focus()
|
||||||
el.style.height = '42px'
|
autoSize(el)
|
||||||
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const runSlash = (c: SlashCommand) => {
|
const runSlash = (c: SlashCommand) => {
|
||||||
setText('')
|
|
||||||
setSlashOpen(false)
|
setSlashOpen(false)
|
||||||
resetHeight()
|
if (c.action === 'new') {
|
||||||
if (c.action === 'new') onNewChat()
|
setText('')
|
||||||
else if (c.action === 'clear') onClearContext()
|
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 handleSubmit = useCallback(() => {
|
||||||
@@ -111,9 +169,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
|||||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const v = e.target.value
|
const v = e.target.value
|
||||||
setText(v)
|
setText(v)
|
||||||
const el = e.target
|
autoSize(e.target)
|
||||||
el.style.height = '42px'
|
|
||||||
el.style.height = Math.min(el.scrollHeight, 180) + 'px'
|
|
||||||
// open slash menu when the input starts with "/" and has no space
|
// open slash menu when the input starts with "/" and has no space
|
||||||
setSlashOpen(v.startsWith('/') && !v.includes(' '))
|
setSlashOpen(v.startsWith('/') && !v.includes(' '))
|
||||||
setSlashIndex(0)
|
setSlashIndex(0)
|
||||||
@@ -205,18 +261,27 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
|||||||
|
|
||||||
{/* Slash command menu */}
|
{/* Slash command menu */}
|
||||||
{slashOpen && filtered.length > 0 && (
|
{slashOpen && filtered.length > 0 && (
|
||||||
<div className="absolute bottom-full left-0 mb-2 w-64 rounded-xl border border-default bg-elevated shadow-lg overflow-hidden z-30">
|
<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) => (
|
{filtered.map((c, i) => (
|
||||||
<button
|
<button
|
||||||
key={c.cmd}
|
key={c.cmd}
|
||||||
onMouseEnter={() => setSlashIndex(i)}
|
onMouseEnter={() => setSlashIndex(i)}
|
||||||
onClick={() => runSlash(c)}
|
onClick={() => runSlash(c)}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2 text-left cursor-pointer transition-colors ${
|
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'
|
i === slashIndex ? 'bg-accent-soft' : 'hover:bg-surface-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium text-accent">{c.cmd}</span>
|
<span
|
||||||
<span className="text-xs text-content-tertiary">{c.desc}</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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -275,6 +340,13 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
|||||||
>
|
>
|
||||||
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Paperclip size={18} />}
|
{uploading ? <Loader2 size={18} className="animate-spin" /> : <Paperclip size={18} />}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -296,7 +368,7 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
|||||||
onCompositionEnd={() => (composingRef.current = false)}
|
onCompositionEnd={() => (composingRef.current = false)}
|
||||||
placeholder={t('input_placeholder')}
|
placeholder={t('input_placeholder')}
|
||||||
rows={1}
|
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"
|
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 ? (
|
{isStreaming ? (
|
||||||
@@ -311,10 +383,10 @@ const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput
|
|||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!canSend}
|
disabled={!canSend}
|
||||||
className="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
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')}
|
title={t('chat_send')}
|
||||||
>
|
>
|
||||||
<Send size={17} />
|
<PaperPlaneIcon size={15} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { Copy, Check, RefreshCw, Pencil, Trash2, File as FileIcon, Sprout } from 'lucide-react'
|
import { Copy, Check, RefreshCw, Trash2, File as FileIcon, Sprout } from 'lucide-react'
|
||||||
import type { ChatMessage } from '../types'
|
import type { ChatMessage } from '../types'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
@@ -11,6 +11,9 @@ interface MessageBubbleProps {
|
|||||||
onRegenerate?: (id: string) => void
|
onRegenerate?: (id: string) => void
|
||||||
onEdit?: (id: string) => void
|
onEdit?: (id: string) => void
|
||||||
onDelete?: (msg: ChatMessage) => 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 {
|
function fmtTime(ts: number): string {
|
||||||
@@ -36,7 +39,7 @@ const HoverAction: React.FC<{ onClick: () => void; title: string; danger?: boole
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
||||||
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete }) => {
|
const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, onEdit, onDelete, onMediaLoad }) => {
|
||||||
const isUser = message.role === 'user'
|
const isUser = message.role === 'user'
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
@@ -46,6 +49,16 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
|||||||
setTimeout(() => setCopied(false), 1800)
|
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) {
|
if (isUser) {
|
||||||
return (
|
return (
|
||||||
<div className="group flex flex-col items-end px-4 sm:px-6 py-2">
|
<div className="group flex flex-col items-end px-4 sm:px-6 py-2">
|
||||||
@@ -68,16 +81,14 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="max-w-[75%] rounded-2xl rounded-br-md px-4 py-2.5 bg-[var(--user-bubble-bg)] text-content">
|
<div className="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 className="text-sm whitespace-pre-wrap break-words">{message.content}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-0.5 mt-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<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>
|
<span className="text-[11px] text-content-tertiary mr-1">{fmtTime(message.timestamp)}</span>
|
||||||
{onEdit && message.userSeq != null && (
|
{/* Edit entry hidden: editing a past question cascade-deletes all
|
||||||
<HoverAction onClick={() => onEdit(message.id)} title={t('msg_edit')}>
|
subsequent turns, which surprises users. Kept off until we support
|
||||||
<Pencil size={13} />
|
non-destructive editing. */}
|
||||||
</HoverAction>
|
|
||||||
)}
|
|
||||||
{onDelete && message.userSeq != null && (
|
{onDelete && message.userSeq != null && (
|
||||||
<HoverAction onClick={() => onDelete(message)} title={t('msg_delete')} danger>
|
<HoverAction onClick={() => onDelete(message)} title={t('msg_delete')} danger>
|
||||||
<Trash2 size={13} />
|
<Trash2 size={13} />
|
||||||
@@ -118,6 +129,42 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({ message, onRegenerate, on
|
|||||||
{/* Final answer */}
|
{/* Final answer */}
|
||||||
{message.content && <Markdown content={message.content} />}
|
{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 && (
|
{showCursor && (
|
||||||
<div className="flex items-center gap-1 py-0.5">
|
<div className="flex items-center gap-1 py-0.5">
|
||||||
<span className="typing-dot" />
|
<span className="typing-dot" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { ChevronRight, Loader2, Check, X, Brain, Wrench } from 'lucide-react'
|
import { ChevronRight, Loader2, Check, X, Brain } from 'lucide-react'
|
||||||
import type { MessageStep } from '../types'
|
import type { MessageStep } from '../types'
|
||||||
import Markdown from './Markdown'
|
import Markdown from './Markdown'
|
||||||
|
|
||||||
@@ -49,7 +49,6 @@ const ToolStep: React.FC<{ step: MessageStep }> = ({ step }) => {
|
|||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
>
|
>
|
||||||
<span className="flex-shrink-0">{icon}</span>
|
<span className="flex-shrink-0">{icon}</span>
|
||||||
<Wrench size={11} className="flex-shrink-0 opacity-70" />
|
|
||||||
<span className={`font-medium ${isError ? 'text-danger' : ''}`}>{step.name}</span>
|
<span className={`font-medium ${isError ? 'text-danger' : ''}`}>{step.name}</span>
|
||||||
{step.execution_time !== undefined && (
|
{step.execution_time !== undefined && (
|
||||||
<span className="opacity-60">{step.execution_time}s</span>
|
<span className="opacity-60">{step.execution_time}s</span>
|
||||||
|
|||||||
@@ -1,63 +1,100 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
import React from 'react'
|
||||||
import { Download, RefreshCw, X, Loader2 } from 'lucide-react'
|
import { Download, RefreshCw, X, Loader2, AlertTriangle } from 'lucide-react'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
import { useUpdateStore, hasPendingUpdate } from '../store/updateStore'
|
import { useUpdateStore, hasAvailableUpdate } from '../store/updateStore'
|
||||||
|
|
||||||
// Compact update panel anchored to the NavRail footer. Only mounts content
|
// Compact update panel anchored to the NavRail footer. Shown whenever an update
|
||||||
// when there's a pending update; otherwise renders nothing so it stays out of
|
// is available AND the panel is open (auto-opened on detection, re-openable via
|
||||||
// the way until electron-updater reports a new version.
|
// "check for update"). Dismissing (×) just closes it; the menu can re-open it.
|
||||||
const UpdateBanner: React.FC = () => {
|
const UpdateBanner: React.FC = () => {
|
||||||
const state = useUpdateStore()
|
const state = useUpdateStore()
|
||||||
const [open, setOpen] = useState(false)
|
const open = state.panelOpen
|
||||||
|
|
||||||
const pending = hasPendingUpdate(state)
|
const available = hasAvailableUpdate(state)
|
||||||
const status = state.status
|
const status = state.status
|
||||||
|
const errored = status?.state === 'error'
|
||||||
|
|
||||||
// Auto-open the panel the moment a new version is first detected.
|
// Full-screen "installing…" overlay: bridges the otherwise blank window
|
||||||
useEffect(() => {
|
// between clicking "restart to install" and the app actually quitting to
|
||||||
if (status?.state === 'available') setOpen(true)
|
// swap the bundle. (The gap AFTER quit, before relaunch, is OS-level and
|
||||||
}, [status?.state])
|
// 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!pending) return null
|
// 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 version = state.version
|
||||||
|
const preparing = state.preparing
|
||||||
const downloading = status?.state === 'downloading'
|
const downloading = status?.state === 'downloading'
|
||||||
const downloaded = status?.state === 'downloaded'
|
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 (
|
return (
|
||||||
<div className="absolute bottom-14 left-2 right-2 z-40">
|
<div className="absolute bottom-2 left-2 right-2 z-40">
|
||||||
{/* Collapsed pill: a red-dotted button that re-opens the panel. */}
|
<div className="rounded-lg border border-default bg-elevated shadow-lg p-3 space-y-2.5">
|
||||||
{!open && (
|
<div className="flex items-start justify-between gap-2">
|
||||||
<button
|
<div className="min-w-0">
|
||||||
onClick={() => setOpen(true)}
|
<p className="text-[13px] font-semibold text-content">
|
||||||
className="relative w-full flex items-center gap-2 rounded-btn bg-accent-soft text-accent px-3 py-2 text-[13px] font-medium cursor-pointer hover:bg-accent-soft/80 transition-colors"
|
{errored ? t('update_failed') : t('update_available')}
|
||||||
>
|
</p>
|
||||||
<span className="absolute -top-1 -left-1 h-2 w-2 rounded-full bg-danger" />
|
{!errored && version && (
|
||||||
<Download size={15} />
|
<p className="text-xs text-content-tertiary mt-0.5">v{version}</p>
|
||||||
<span className="truncate">{t('update_available')}</span>
|
)}
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{open && (
|
|
||||||
<div className="rounded-lg border border-default bg-elevated shadow-lg p-3 space-y-2.5">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-[13px] font-semibold text-content">{t('update_available')}</p>
|
|
||||||
{version && <p className="text-xs text-content-tertiary mt-0.5">v{version}</p>}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setOpen(false)
|
|
||||||
state.dismiss()
|
|
||||||
}}
|
|
||||||
className="text-content-tertiary hover:text-content cursor-pointer flex-shrink-0"
|
|
||||||
title={t('update_later')}
|
|
||||||
>
|
|
||||||
<X size={15} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
{downloading && (
|
{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="space-y-1">
|
||||||
<div className="flex items-center gap-2 text-xs text-content-secondary">
|
<div className="flex items-center gap-2 text-xs text-content-secondary">
|
||||||
<Loader2 size={13} className="animate-spin" />
|
<Loader2 size={13} className="animate-spin" />
|
||||||
@@ -69,7 +106,7 @@ const UpdateBanner: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!downloading && !downloaded && (
|
{!errored && !busy && !downloaded && (
|
||||||
<button
|
<button
|
||||||
onClick={() => state.download()}
|
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"
|
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"
|
||||||
@@ -79,7 +116,7 @@ const UpdateBanner: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{downloaded && (
|
{!errored && downloaded && (
|
||||||
<button
|
<button
|
||||||
onClick={() => state.install()}
|
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"
|
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"
|
||||||
@@ -89,7 +126,6 @@ const UpdateBanner: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
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 {
|
interface BackendState {
|
||||||
status: 'connecting' | 'ready' | 'error'
|
status: 'connecting' | 'ready' | 'error'
|
||||||
port: number
|
port: number
|
||||||
@@ -9,7 +16,7 @@ interface BackendState {
|
|||||||
export function useBackend() {
|
export function useBackend() {
|
||||||
const [state, setState] = useState<BackendState>({
|
const [state, setState] = useState<BackendState>({
|
||||||
status: 'connecting',
|
status: 'connecting',
|
||||||
port: 9899,
|
port: BACKEND_PORT,
|
||||||
})
|
})
|
||||||
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
@@ -31,7 +38,7 @@ export function useBackend() {
|
|||||||
const readyRef = useRef(false)
|
const readyRef = useRef(false)
|
||||||
// Holds the latest resolved port so the visibility handler (registered once)
|
// Holds the latest resolved port so the visibility handler (registered once)
|
||||||
// always probes the correct port without re-running the effect.
|
// always probes the correct port without re-running the effect.
|
||||||
const portRef = useRef(9899)
|
const portRef = useRef(BACKEND_PORT)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -74,12 +81,21 @@ export function useBackend() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (api) {
|
if (api) {
|
||||||
api.getBackendPort().then((port) => {
|
// Always start polling, even if getBackendPort rejects or the ready event
|
||||||
const p = port || 9899
|
// was already emitted before we subscribed: polling /config is the
|
||||||
portRef.current = p
|
// self-sufficient path to "ready" and must never depend on the IPC round
|
||||||
setState((prev) => ({ ...prev, port: p }))
|
// trip succeeding (otherwise the app can hang forever on "connecting").
|
||||||
startPolling(p)
|
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) => {
|
offStatus = api.onBackendStatus((data) => {
|
||||||
if (data.status === 'ready' && data.port) {
|
if (data.status === 'ready' && data.port) {
|
||||||
@@ -98,7 +114,7 @@ export function useBackend() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
startPolling(9899)
|
startPolling(BACKEND_PORT)
|
||||||
}
|
}
|
||||||
|
|
||||||
// When the window comes back to the foreground, re-probe immediately so a
|
// When the window comes back to the foreground, re-probe immediately so a
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ function getSystemTheme(): ResolvedTheme {
|
|||||||
function readStored(): ThemePref {
|
function readStored(): ThemePref {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
|
if (saved === 'dark' || saved === 'light' || saved === 'system') return saved
|
||||||
// Default to dark to match the app's flagship look
|
// First run: follow the OS appearance rather than forcing a fixed theme.
|
||||||
return 'dark'
|
return 'system'
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(resolved: ResolvedTheme) {
|
function applyTheme(resolved: ResolvedTheme) {
|
||||||
|
|||||||
@@ -29,14 +29,67 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
knowledge_graph_empty: '暂无关联图谱',
|
knowledge_graph_empty: '暂无关联图谱',
|
||||||
knowledge_disabled: '知识库未启用',
|
knowledge_disabled: '知识库未启用',
|
||||||
knowledge_doc_load_error: '文档加载失败',
|
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_expand: '展开侧栏',
|
||||||
nav_collapse: '收起侧栏',
|
nav_collapse: '收起侧栏',
|
||||||
|
session_history: '历史会话',
|
||||||
update_available: '发现新版本',
|
update_available: '发现新版本',
|
||||||
update_download: '下载更新',
|
update_download: '下载更新',
|
||||||
update_downloading: '正在下载',
|
update_downloading: '正在下载',
|
||||||
|
update_preparing: '正在准备…',
|
||||||
|
update_verifying: '正在校验,即将完成…',
|
||||||
|
update_installing: '正在安装并重启,请稍候…',
|
||||||
update_restart: '重启以更新',
|
update_restart: '重启以更新',
|
||||||
update_later: '稍后',
|
update_later: '稍后',
|
||||||
update_latest: '已是最新版本',
|
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
|
||||||
onboarding_welcome_title: '欢迎使用 CowAgent',
|
onboarding_welcome_title: '欢迎使用 CowAgent',
|
||||||
onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
|
onboarding_welcome_desc: '你的私人超级 AI 助手。几步设置,即可开始对话。',
|
||||||
@@ -85,11 +138,17 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
chat_empty_hint: '发送一条消息开始对话',
|
chat_empty_hint: '发送一条消息开始对话',
|
||||||
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
welcome_subtitle: '我可以帮你解答问题、管理你的电脑、创建并执行技能,\n还能通过长期记忆不断成长。',
|
||||||
example_sys_title: '系统管理',
|
example_sys_title: '系统管理',
|
||||||
example_sys_text: '帮我查看工作空间里有哪些文件',
|
example_sys_text: '查看工作空间里有哪些文件',
|
||||||
example_task_title: '技能系统',
|
example_task_title: '定时任务',
|
||||||
example_task_text: '查看所有支持的工具和技能',
|
example_task_text: '1分钟后提醒我检查服务器',
|
||||||
example_code_title: '编程助手',
|
example_code_title: '编程助手',
|
||||||
example_code_text: '帮我编写一个Python爬虫脚本',
|
example_code_text: '搜索AI资讯生成可视化网页报告',
|
||||||
|
example_knowledge_title: '知识库',
|
||||||
|
example_knowledge_text: '查看知识库当前文档情况',
|
||||||
|
example_skill_title: '技能系统',
|
||||||
|
example_skill_text: '查看所有支持的工具和技能',
|
||||||
|
example_web_title: '指令中心',
|
||||||
|
example_web_text: '查看全部命令',
|
||||||
input_placeholder: '输入消息...',
|
input_placeholder: '输入消息...',
|
||||||
config_title: '配置管理',
|
config_title: '配置管理',
|
||||||
config_desc: '管理模型和 Agent 配置',
|
config_desc: '管理模型和 Agent 配置',
|
||||||
@@ -216,7 +275,10 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
memory_next: '下一页',
|
memory_next: '下一页',
|
||||||
channels_title: '通道管理',
|
channels_title: '通道管理',
|
||||||
channels_desc: '查看和管理消息通道',
|
channels_desc: '查看和管理消息通道',
|
||||||
channels_add: '添加通道',
|
channels_add: '接入通道',
|
||||||
|
channels_select_label: '选择要接入的通道',
|
||||||
|
channels_select_placeholder: '请选择通道...',
|
||||||
|
channels_add_close: '关闭',
|
||||||
channels_connected: '已连接',
|
channels_connected: '已连接',
|
||||||
channels_disconnected: '未连接',
|
channels_disconnected: '未连接',
|
||||||
channels_connect: '连接',
|
channels_connect: '连接',
|
||||||
@@ -294,6 +356,23 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
status_error: '初始化失败',
|
status_error: '初始化失败',
|
||||||
status_error_desc: '客户端初始化失败,请重试',
|
status_error_desc: '客户端初始化失败,请重试',
|
||||||
status_retry: '重试',
|
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: {
|
en: {
|
||||||
console: 'Console',
|
console: 'Console',
|
||||||
@@ -324,15 +403,68 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
knowledge_graph_empty: 'No graph available',
|
knowledge_graph_empty: 'No graph available',
|
||||||
knowledge_disabled: 'Knowledge base is disabled',
|
knowledge_disabled: 'Knowledge base is disabled',
|
||||||
knowledge_doc_load_error: 'Failed to load document',
|
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',
|
menu_settings: 'Settings',
|
||||||
nav_expand: 'Expand sidebar',
|
nav_expand: 'Expand sidebar',
|
||||||
nav_collapse: 'Collapse sidebar',
|
nav_collapse: 'Collapse sidebar',
|
||||||
|
session_history: 'Chat history',
|
||||||
update_available: 'New version available',
|
update_available: 'New version available',
|
||||||
update_download: 'Download update',
|
update_download: 'Download update',
|
||||||
update_downloading: 'Downloading',
|
update_downloading: 'Downloading',
|
||||||
|
update_preparing: 'Preparing…',
|
||||||
|
update_verifying: 'Verifying, almost done…',
|
||||||
|
update_installing: 'Installing and restarting, please wait…',
|
||||||
update_restart: 'Restart to update',
|
update_restart: 'Restart to update',
|
||||||
update_later: 'Later',
|
update_later: 'Later',
|
||||||
update_latest: 'You are up to date',
|
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
|
||||||
onboarding_welcome_title: 'Welcome to CowAgent',
|
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_welcome_desc: 'Your personal super AI assistant. A few quick steps and you are ready to chat.',
|
||||||
@@ -382,10 +514,16 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
welcome_subtitle: 'I can help you answer questions, manage your computer, create and execute skills,\nand keep growing through long-term memory.',
|
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_title: 'System',
|
||||||
example_sys_text: 'Show me the files in the workspace',
|
example_sys_text: 'Show me the files in the workspace',
|
||||||
example_task_title: 'Skills',
|
example_task_title: 'Scheduled Task',
|
||||||
example_task_text: 'Show current tools and skills',
|
example_task_text: 'Remind me to check the server in 1 minute',
|
||||||
example_code_title: 'Coding',
|
example_code_title: 'Coding',
|
||||||
example_code_text: 'Write a Python web scraper script',
|
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...',
|
input_placeholder: 'Type a message...',
|
||||||
config_title: 'Configuration',
|
config_title: 'Configuration',
|
||||||
config_desc: 'Manage model and agent settings',
|
config_desc: 'Manage model and agent settings',
|
||||||
@@ -513,6 +651,9 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
channels_title: 'Channels',
|
channels_title: 'Channels',
|
||||||
channels_desc: 'View and manage messaging channels',
|
channels_desc: 'View and manage messaging channels',
|
||||||
channels_add: 'Add channel',
|
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_connected: 'Connected',
|
||||||
channels_disconnected: 'Disconnected',
|
channels_disconnected: 'Disconnected',
|
||||||
channels_connect: 'Connect',
|
channels_connect: 'Connect',
|
||||||
@@ -590,6 +731,23 @@ const translations: Record<string, Record<string, string>> = {
|
|||||||
status_error: 'Initialization Failed',
|
status_error: 'Initialization Failed',
|
||||||
status_error_desc: 'Failed to initialize the client, please retry',
|
status_error_desc: 'Failed to initialize the client, please retry',
|
||||||
status_retry: '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',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,6 @@
|
|||||||
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
|
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
/* Chat-specific tokens (AI-Native UI) */
|
/* Chat-specific tokens (AI-Native UI) */
|
||||||
--user-bubble-bg: var(--accent-soft);
|
|
||||||
--ai-bubble-bg: transparent;
|
--ai-bubble-bg: transparent;
|
||||||
--message-gap: 16px;
|
--message-gap: 16px;
|
||||||
|
|
||||||
@@ -80,8 +79,6 @@
|
|||||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4), 0 4px 16px 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);
|
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
--user-bubble-bg: rgba(74, 190, 110, 0.16);
|
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { useState, useRef, useEffect } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation, useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -13,13 +13,45 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
MoreHorizontal,
|
||||||
|
Languages,
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
|
Globe,
|
||||||
|
FileText,
|
||||||
|
Store,
|
||||||
|
MessageSquareWarning,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } 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 { t, getLang, setLang, Lang } from '../i18n'
|
||||||
import { useUIStore } from '../store/uiStore'
|
import { useUIStore } from '../store/uiStore'
|
||||||
import { useTheme } from '../hooks/useTheme'
|
import { useTheme } from '../hooks/useTheme'
|
||||||
|
import { usePlatform } from '../hooks/usePlatform'
|
||||||
|
import { useUpdateStore, hasPendingUpdate, hasAvailableUpdate } from '../store/updateStore'
|
||||||
import UpdateBanner from '../components/UpdateBanner'
|
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 {
|
interface NavItem {
|
||||||
path: string
|
path: string
|
||||||
labelKey: string
|
labelKey: string
|
||||||
@@ -45,21 +77,112 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { navCollapsed, toggleNav } = useUIStore()
|
const { navCollapsed, toggleNav } = useUIStore()
|
||||||
const { theme, toggleTheme } = useTheme()
|
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 collapsed = navCollapsed
|
||||||
const width = collapsed ? 'w-[56px]' : 'w-[208px]'
|
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 toggleLanguage = () => {
|
||||||
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
|
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
|
||||||
setLang(next)
|
setLang(next)
|
||||||
onLangChange()
|
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 (
|
return (
|
||||||
<aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}>
|
<aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}>
|
||||||
{/* Top: full-width drag strip; reserve space for macOS traffic lights.
|
{/* Top: full-width drag strip; bottom border continues the header divider
|
||||||
No right border here so the divider doesn't cut across the traffic lights. */}
|
across the whole window. No right border so it doesn't cut the lights.
|
||||||
<div className="titlebar-drag h-[44px] flex-shrink-0" />
|
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 */}
|
{/* Content area carries the right divider, starting below the titlebar */}
|
||||||
<div className="flex-1 flex flex-col min-h-0 border-r border-default">
|
<div className="flex-1 flex flex-col min-h-0 border-r border-default">
|
||||||
@@ -93,32 +216,74 @@ const NavRail: React.FC<NavRailProps> = ({ onLangChange }) => {
|
|||||||
{!collapsed && <UpdateBanner />}
|
{!collapsed && <UpdateBanner />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer actions */}
|
{/* Footer actions: a single "more" entry (with version + update dot) that
|
||||||
<div className={`flex-shrink-0 px-2 py-2 border-t border-subtle ${collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}`}>
|
opens an upward popover, plus the always-visible collapse toggle. */}
|
||||||
<FooterBtn
|
<div className="flex-shrink-0 px-2 py-2 border-t border-subtle relative" ref={menuRef}>
|
||||||
collapsed={collapsed}
|
{menuOpen && (
|
||||||
onClick={() => navigate('/logs')}
|
<FooterMenu
|
||||||
title={t('menu_logs')}
|
theme={theme}
|
||||||
active={location.pathname === '/logs'}
|
checking={checking}
|
||||||
>
|
pendingUpdate={availableUpdate}
|
||||||
<ScrollText size={17} />
|
upToDate={checkedManually && updateStatusState === 'not-available' && !availableUpdate}
|
||||||
</FooterBtn>
|
onLogs={() => {
|
||||||
<FooterBtn collapsed={collapsed} onClick={toggleTheme} title={theme === 'dark' ? 'Light' : 'Dark'}>
|
setMenuOpen(false)
|
||||||
{theme === 'dark' ? <Sun size={17} /> : <Moon size={17} />}
|
navigate('/logs')
|
||||||
</FooterBtn>
|
}}
|
||||||
<FooterBtn collapsed={collapsed} onClick={toggleLanguage} title="Language">
|
onTheme={toggleTheme}
|
||||||
<span className="text-[13px] font-medium w-[18px] text-center">{getLang() === 'zh' ? 'EN' : '中'}</span>
|
onLanguage={toggleLanguage}
|
||||||
</FooterBtn>
|
onCheckUpdate={checkUpdate}
|
||||||
<div className={collapsed ? '' : 'flex-1'} />
|
onOpenLink={(url) => {
|
||||||
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
|
setMenuOpen(false)
|
||||||
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
|
openExternal(url)
|
||||||
</FooterBtn>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</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<{
|
const FooterBtn: React.FC<{
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
@@ -139,4 +304,83 @@ const FooterBtn: React.FC<{
|
|||||||
</button>
|
</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
|
export default NavRail
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { Plus, MessageSquare, Pencil, Trash2, Check, X, PanelLeftClose } from 'lucide-react'
|
import { Plus, MessageSquare, Pencil, Trash2, Check, X, History } from 'lucide-react'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
import { useSessionStore } from '../store/sessionStore'
|
import { useSessionStore } from '../store/sessionStore'
|
||||||
import { useUIStore } from '../store/uiStore'
|
import { useUIStore } from '../store/uiStore'
|
||||||
|
import { usePlatform } from '../hooks/usePlatform'
|
||||||
import type { SessionItem } from '../types'
|
import type { SessionItem } from '../types'
|
||||||
|
|
||||||
function groupByTime(sessions: SessionItem[]): { label: string; items: SessionItem[] }[] {
|
function groupByTime(sessions: SessionItem[]): { label: string; items: SessionItem[] }[] {
|
||||||
@@ -32,6 +33,14 @@ const SessionList: React.FC = () => {
|
|||||||
const { sessions, activeId, loading, loadSessions, loadMore, hasMore, setActive, newSession, rename, remove } =
|
const { sessions, activeId, loading, loadSessions, loadMore, hasMore, setActive, newSession, rename, remove } =
|
||||||
useSessionStore()
|
useSessionStore()
|
||||||
const toggleSessions = useUIStore((s) => s.toggleSessions)
|
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 [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [editValue, setEditValue] = useState('')
|
const [editValue, setEditValue] = useState('')
|
||||||
|
|
||||||
@@ -56,18 +65,18 @@ const SessionList: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="w-[240px] flex-shrink-0 flex flex-col h-full bg-surface border-r border-default">
|
<div className="w-[240px] flex-shrink-0 flex flex-col h-full bg-surface border-r border-default">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-2 h-[44px] flex-shrink-0 titlebar-drag">
|
<div className="flex items-center justify-between px-2 h-[44px] flex-shrink-0 titlebar-drag border-b border-default">
|
||||||
<button
|
<button
|
||||||
onClick={toggleSessions}
|
onClick={toggleSessions}
|
||||||
title={t('nav_collapse')}
|
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"
|
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}`}
|
||||||
>
|
>
|
||||||
<PanelLeftClose size={16} />
|
<History size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => newSession()}
|
onClick={() => newSession()}
|
||||||
title={t('session_new')}
|
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"
|
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} />
|
<Plus size={15} />
|
||||||
{t('session_new')}
|
{t('session_new')}
|
||||||
|
|||||||
@@ -1,14 +1,52 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Loader2, Plug, QrCode } from 'lucide-react'
|
import {
|
||||||
|
Loader2,
|
||||||
|
Plug,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
ChevronDown,
|
||||||
|
Check,
|
||||||
|
MessageCircle,
|
||||||
|
MessageSquare,
|
||||||
|
Bot,
|
||||||
|
Building2,
|
||||||
|
Headset,
|
||||||
|
Hash,
|
||||||
|
AtSign,
|
||||||
|
} from 'lucide-react'
|
||||||
import { t, localizedLabel } from '../i18n'
|
import { t, localizedLabel } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
import type { ChannelInfo, ChannelField } from '../types'
|
import type { ChannelInfo, ChannelField } from '../types'
|
||||||
import { Toggle, Btn } from './settings/primitives'
|
import { Toggle, Btn } from './settings/primitives'
|
||||||
import QrLoginModal from '../components/QrLoginModal'
|
import QrLoginModal from '../components/QrLoginModal'
|
||||||
|
import { PaperPlaneIcon } from '../components/icons'
|
||||||
|
|
||||||
// Channels that connect via QR scanning rather than credential fields.
|
// Channels that connect via QR scanning rather than credential fields.
|
||||||
const QR_PROVIDERS: Record<string, 'weixin' | 'feishu'> = { weixin: 'weixin', feishu: 'feishu' }
|
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 {
|
interface ChannelsPageProps {
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
}
|
}
|
||||||
@@ -19,6 +57,12 @@ const MASK_RE = /\*{2,}/
|
|||||||
const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
||||||
const [channels, setChannels] = useState<ChannelInfo[]>([])
|
const [channels, setChannels] = useState<ChannelInfo[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
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 () => {
|
const loadChannels = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -45,14 +89,55 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
|||||||
return { connected, available }
|
return { connected, available }
|
||||||
}, [channels])
|
}, [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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="px-6 pt-5 pb-3 flex-shrink-0">
|
<div className="px-6 pt-5 pb-3 flex-shrink-0 flex items-start justify-between gap-4">
|
||||||
<h2 className="text-xl font-bold text-content">{t('channels_title')}</h2>
|
<div>
|
||||||
<p className="text-xs text-content-tertiary mt-1">{t('channels_desc')}</p>
|
<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>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto border-t border-default">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto border-t border-default">
|
||||||
<div className="max-w-3xl mx-auto px-6 py-5">
|
<div className="max-w-3xl mx-auto px-6 py-5">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-20 text-content-tertiary">
|
<div className="flex items-center justify-center py-20 text-content-tertiary">
|
||||||
@@ -60,21 +145,37 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
|||||||
{t('channels_loading')}
|
{t('channels_loading')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-3">
|
||||||
<Section title={t('channels_connected_section')}>
|
{connected.length === 0 && !addOpen ? (
|
||||||
{connected.length === 0 ? (
|
<p className="text-sm text-content-tertiary py-2">{t('channels_empty_connected')}</p>
|
||||||
<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} />)
|
||||||
connected.map((ch) => <ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />)
|
)}
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{available.length > 0 && (
|
{/* Add-channel panel lives at the bottom of the list: pick a
|
||||||
<Section title={t('channels_available_section')}>
|
channel from the dropdown, then configure/connect it inline. */}
|
||||||
{available.map((ch) => (
|
{addOpen && available.length > 0 && (
|
||||||
<ChannelCard key={ch.name} channel={ch} onChanged={loadChannels} />
|
<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">
|
||||||
</Section>
|
<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>
|
||||||
)}
|
)}
|
||||||
@@ -84,20 +185,101 @@ const ChannelsPage: React.FC<ChannelsPageProps> = ({ baseUrl }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Section: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
// Custom dropdown styled like the web console's `.cfg-dropdown` (rounded,
|
||||||
<div>
|
// green focus ring, hover/active states) instead of a native <select>.
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-content-tertiary mb-2">{title}</h3>
|
const ChannelDropdown: React.FC<{
|
||||||
<div className="space-y-3">{children}</div>
|
channels: ChannelInfo[]
|
||||||
</div>
|
value: string
|
||||||
)
|
onChange: (name: string) => void
|
||||||
|
placeholder: string
|
||||||
|
}> = ({ channels, value, onChange, placeholder }) => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = ({ channel, onChanged }) => {
|
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).
|
// Channels with no fields connect purely via QR (e.g. weixin).
|
||||||
const isQrLogin = channel.fields.length === 0
|
const isQrLogin = channel.fields.length === 0
|
||||||
// QR provider supported by the desktop scan modal (weixin / feishu).
|
// QR provider supported by the desktop scan modal (weixin / feishu).
|
||||||
const qrProvider = QR_PROVIDERS[channel.name]
|
const qrProvider = QR_PROVIDERS[channel.name]
|
||||||
const [showQr, setShowQr] = useState(false)
|
const [showQr, setShowQr] = useState(false)
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||||
Object.fromEntries(channel.fields.map((f) => [f.key, f.value != null ? String(f.value) : '']))
|
Object.fromEntries(channel.fields.map((f) => [f.key, f.value != null ? String(f.value) : '']))
|
||||||
)
|
)
|
||||||
@@ -148,15 +330,14 @@ const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-card border border-default bg-surface p-4">
|
<div className={defaultExpanded ? '' : 'rounded-card border border-default bg-surface p-4'}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-lg bg-inset flex items-center justify-center flex-shrink-0">
|
<ChannelIcon name={channel.name} size={40} />
|
||||||
{isQrLogin ? <QrCode size={16} className="text-content-secondary" /> : <Plug size={16} className="text-content-secondary" />}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-sm text-content">{localizedLabel(channel.label)}</span>
|
<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'}`} />
|
<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>
|
</div>
|
||||||
<p className="text-xs text-content-tertiary font-mono mt-0.5">{channel.name}</p>
|
<p className="text-xs text-content-tertiary font-mono mt-0.5">{channel.name}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,7 +350,7 @@ const ChannelCard: React.FC<{ channel: ChannelInfo; onChanged: () => void }> = (
|
|||||||
<Btn variant="primary" onClick={() => setShowQr(true)}>
|
<Btn variant="primary" onClick={() => setShowQr(true)}>
|
||||||
{qrProvider === 'weixin' ? t('channels_scan_login') : t('channels_scan_register')}
|
{qrProvider === 'weixin' ? t('channels_scan_login') : t('channels_scan_register')}
|
||||||
</Btn>
|
</Btn>
|
||||||
) : isQrLogin ? null : (
|
) : isQrLogin || defaultExpanded ? null : (
|
||||||
<Btn variant="ghost" onClick={() => setExpanded((v) => !v)}>
|
<Btn variant="ghost" onClick={() => setExpanded((v) => !v)}>
|
||||||
{t('channels_add')}
|
{t('channels_add')}
|
||||||
</Btn>
|
</Btn>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import React, { useEffect, useRef, useCallback, useState } from 'react'
|
import React, { useEffect, useRef, useCallback, useState } from 'react'
|
||||||
import { ChevronUp, Loader2 } from 'lucide-react'
|
import {
|
||||||
|
ChevronUp,
|
||||||
|
Loader2,
|
||||||
|
FolderOpen,
|
||||||
|
Clock,
|
||||||
|
Code2,
|
||||||
|
BookOpen,
|
||||||
|
Puzzle,
|
||||||
|
Terminal,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
import MessageBubble from '../components/MessageBubble'
|
import MessageBubble from '../components/MessageBubble'
|
||||||
import ChatInput, { type ChatInputHandle } from '../components/ChatInput'
|
import ChatInput, { type ChatInputHandle } from '../components/ChatInput'
|
||||||
import { t } from '../i18n'
|
import { t } from '../i18n'
|
||||||
@@ -12,7 +22,24 @@ interface ChatPageProps {
|
|||||||
baseUrl: string
|
baseUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUGGESTIONS = ['example_sys', 'example_task', 'example_code'] as const
|
// 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 ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
||||||
const activeId = useSessionStore((s) => s.activeId)
|
const activeId = useSessionStore((s) => s.activeId)
|
||||||
@@ -51,14 +78,19 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
}, [activeId, ensureSession, loadHistory])
|
}, [activeId, ensureSession, loadHistory])
|
||||||
|
|
||||||
const scrollToBottom = useCallback((smooth = true) => {
|
const scrollToBottom = useCallback((smooth = true) => {
|
||||||
const el = scrollRef.current
|
// Defer to the next frame so we read the height *after* the new content has
|
||||||
if (!el) return
|
// been laid out (markdown/streaming renders a frame later than the effect).
|
||||||
// Jump straight to the bottom; instant for session switches, smooth for streaming.
|
requestAnimationFrame(() => {
|
||||||
if (smooth) {
|
const el = scrollRef.current
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
if (!el) return
|
||||||
} else {
|
// Smooth animations get interrupted by high-frequency streaming updates
|
||||||
el.scrollTop = el.scrollHeight
|
// 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).
|
// Snap to the bottom instantly when switching sessions (no top-to-bottom animation).
|
||||||
@@ -66,6 +98,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const lastSessionRef = useRef('')
|
const lastSessionRef = useRef('')
|
||||||
const lastLenRef = useRef(0)
|
const lastLenRef = useRef(0)
|
||||||
const pendingSnapRef = useRef(false)
|
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(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@@ -74,12 +113,13 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
lastSessionRef.current = activeId
|
lastSessionRef.current = activeId
|
||||||
lastLenRef.current = messages.length
|
lastLenRef.current = messages.length
|
||||||
pendingSnapRef.current = true
|
pendingSnapRef.current = true
|
||||||
|
followBottomRef.current = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingSnapRef.current) {
|
if (pendingSnapRef.current) {
|
||||||
// Instant snap on switch and on the first content that lands afterwards.
|
// Instant snap on switch and on the first content that lands afterwards.
|
||||||
lastLenRef.current = messages.length
|
lastLenRef.current = messages.length
|
||||||
requestAnimationFrame(() => scrollToBottom(false))
|
scrollToBottom(false)
|
||||||
if (messages.length > 0) pendingSnapRef.current = false
|
if (messages.length > 0) pendingSnapRef.current = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -87,8 +127,24 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 160
|
||||||
const grew = messages.length !== lastLenRef.current
|
const grew = messages.length !== lastLenRef.current
|
||||||
lastLenRef.current = messages.length
|
lastLenRef.current = messages.length
|
||||||
if (nearBottom || grew) scrollToBottom(true)
|
// Follow the bottom when: a new message arrived, the user is already near
|
||||||
}, [messages, activeId, scrollToBottom])
|
// 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(
|
const handleSend = useCallback(
|
||||||
async (text: string, attachments: Attachment[]) => {
|
async (text: string, attachments: Attachment[]) => {
|
||||||
@@ -143,9 +199,19 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
[deleteMessage, activeId]
|
[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(
|
const handleScroll = useCallback(
|
||||||
async (e: React.UIEvent<HTMLDivElement>) => {
|
async (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
const el = e.currentTarget
|
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]
|
const s = useChatStore.getState().sessions[activeId]
|
||||||
if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) {
|
if (el.scrollTop < 40 && s?.historyHasMore && !loadingMore && !isStreaming) {
|
||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
@@ -178,15 +244,26 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
<h1 className="text-xl font-semibold text-content mb-2">{t('chat_welcome')}</h1>
|
||||||
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
|
<p className="text-content-tertiary text-sm text-center max-w-md mb-8">{t('chat_empty_hint')}</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full max-w-2xl">
|
||||||
{SUGGESTIONS.map((key) => (
|
{SUGGESTIONS.map(({ key, send, icon: Icon, iconClass, bgClass }) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => handleSend(t(`${key}_text` as Parameters<typeof t>[0]), [])}
|
onClick={() => {
|
||||||
className="text-left bg-surface border border-default rounded-xl p-3.5 cursor-pointer hover:border-accent hover:shadow-sm transition-all"
|
// 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="font-medium text-sm text-content mb-1">
|
<div className="flex items-center gap-2 mb-1.5">
|
||||||
{t(`${key}_title` as Parameters<typeof t>[0])}
|
<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>
|
</div>
|
||||||
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">
|
<p className="text-xs text-content-tertiary leading-relaxed line-clamp-2">
|
||||||
{t(`${key}_text` as Parameters<typeof t>[0])}
|
{t(`${key}_text` as Parameters<typeof t>[0])}
|
||||||
@@ -204,6 +281,7 @@ const ChatPage: React.FC<ChatPageProps> = ({ baseUrl }) => {
|
|||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onMediaLoad={handleMediaLoad}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Search,
|
Search,
|
||||||
@@ -8,12 +8,21 @@ import {
|
|||||||
MessageSquarePlus,
|
MessageSquarePlus,
|
||||||
Network,
|
Network,
|
||||||
Files,
|
Files,
|
||||||
|
Plus,
|
||||||
|
FolderPlus,
|
||||||
|
FilePlus2,
|
||||||
|
Upload,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { t } from '../i18n'
|
import { t, getLang } from '../i18n'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
import type { KnowledgeDir, KnowledgeFile, KnowledgeList, KnowledgeGraph as KnowledgeGraphData } from '../types'
|
import type {
|
||||||
|
KnowledgeDir,
|
||||||
|
KnowledgeFile,
|
||||||
|
KnowledgeList,
|
||||||
|
KnowledgeGraph as KnowledgeGraphData,
|
||||||
|
} from '../types'
|
||||||
import Markdown from '../components/Markdown'
|
import Markdown from '../components/Markdown'
|
||||||
import KnowledgeGraph from '../components/KnowledgeGraph'
|
import KnowledgeGraph from '../components/KnowledgeGraph'
|
||||||
|
|
||||||
@@ -23,12 +32,58 @@ interface KnowledgePageProps {
|
|||||||
|
|
||||||
type Tab = 'docs' | 'graph'
|
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 => {
|
const formatSize = (bytes: number): string => {
|
||||||
if (bytes < 1024) return bytes + ' B'
|
if (bytes < 1024) return bytes + ' B'
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
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).
|
// Find the first document (root files first, then a DFS over the tree).
|
||||||
function firstFile(list: KnowledgeList): { path: string; title: string } | null {
|
function firstFile(list: KnowledgeList): { path: string; title: string } | null {
|
||||||
const root = list.root_files?.[0]
|
const root = list.root_files?.[0]
|
||||||
@@ -50,6 +105,30 @@ function firstFile(list: KnowledgeList): { path: string; title: string } | null
|
|||||||
return null
|
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 KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [tab, setTab] = useState<Tab>('docs')
|
const [tab, setTab] = useState<Tab>('docs')
|
||||||
@@ -65,6 +144,22 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
const [graph, setGraph] = useState<KnowledgeGraphData | null>(null)
|
const [graph, setGraph] = useState<KnowledgeGraphData | null>(null)
|
||||||
const [graphLoading, setGraphLoading] = useState(false)
|
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) => {
|
const openDoc = useCallback(async (path: string, title: string) => {
|
||||||
setActivePath(path)
|
setActivePath(path)
|
||||||
setDocTitle(title)
|
setDocTitle(title)
|
||||||
@@ -80,16 +175,37 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
apiClient.setBaseUrl(baseUrl)
|
apiClient.setBaseUrl(baseUrl)
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
|
||||||
const fresh = await apiClient.getKnowledgeList()
|
const fresh = await apiClient.getKnowledgeList()
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setData(fresh)
|
setData(fresh)
|
||||||
// Auto-open the first document so the viewer isn't empty on entry.
|
|
||||||
const first = firstFile(fresh)
|
const first = firstFile(fresh)
|
||||||
if (first) void openDoc(first.path, first.title)
|
if (first) void openDoc(first.path, first.title)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -101,7 +217,9 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [baseUrl, openDoc])
|
// Only run on baseUrl change (initial mount). refresh() handles later reloads.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [baseUrl])
|
||||||
|
|
||||||
const loadGraph = useCallback(async () => {
|
const loadGraph = useCallback(async () => {
|
||||||
if (graph) return
|
if (graph) return
|
||||||
@@ -130,6 +248,115 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
[openDoc]
|
[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 totalPages = data?.stats?.pages ?? 0
|
||||||
const statsLabel = useMemo(() => {
|
const statsLabel = useMemo(() => {
|
||||||
if (!data) return ''
|
if (!data) return ''
|
||||||
@@ -178,19 +405,81 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
<h2 className="text-xl font-bold text-content">{t('knowledge_title')}</h2>
|
<h2 className="text-xl font-bold text-content">{t('knowledge_title')}</h2>
|
||||||
<p className="text-xs text-content-tertiary mt-1">{statsLabel}</p>
|
<p className="text-xs text-content-tertiary mt-1">{statsLabel}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 bg-inset rounded-btn p-0.5">
|
<div className="flex items-center gap-2">
|
||||||
<TabBtn icon={Files} label={t('knowledge_tab_docs')} active={tab === 'docs'} onClick={() => switchTab('docs')} />
|
{status && (
|
||||||
<TabBtn
|
<span
|
||||||
icon={Network}
|
className={`text-xs max-w-[260px] truncate ${
|
||||||
label={t('knowledge_tab_graph')}
|
status.error ? 'text-danger' : 'text-content-tertiary'
|
||||||
active={tab === 'graph'}
|
}`}
|
||||||
onClick={() => switchTab('graph')}
|
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>
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".md,.txt,text/markdown,text/plain"
|
||||||
|
className="hidden"
|
||||||
|
onChange={onFilesPicked}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === 'docs' ? (
|
{tab === 'docs' ? (
|
||||||
<div className="flex-1 flex min-h-0 border-t border-default">
|
<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 */}
|
{/* Tree sidebar */}
|
||||||
<div className="w-72 flex-shrink-0 flex flex-col border-r border-default min-h-0">
|
<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="p-3 flex-shrink-0">
|
||||||
@@ -251,6 +540,313 @@ const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
|
|||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,12 +170,16 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
|||||||
return !!config?.api_keys?.[f]
|
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 providerIds = config?.providers ? Object.keys(config.providers) : []
|
||||||
const providerOptions = providerIds.map((id) => ({
|
const providerOptions = providerIds
|
||||||
value: id,
|
.filter((id) => isConfigured(id) || id === provider)
|
||||||
label: localizedLabel(providerMeta(id)?.label) || id,
|
.map((id) => ({
|
||||||
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
|
value: id,
|
||||||
}))
|
label: localizedLabel(providerMeta(id)?.label) || id,
|
||||||
|
}))
|
||||||
const currentMeta = providerMeta(provider)
|
const currentMeta = providerMeta(provider)
|
||||||
const currentUnconfigured = !!provider && !isConfigured(provider)
|
const currentUnconfigured = !!provider && !isConfigured(provider)
|
||||||
const modelOptions = [
|
const modelOptions = [
|
||||||
|
|||||||
@@ -46,24 +46,26 @@ const CapabilityCard: React.FC<CapabilityCardProps> = ({
|
|||||||
const [customModel, setCustomModel] = useState('')
|
const [customModel, setCustomModel] = useState('')
|
||||||
const [showCustom, setShowCustom] = useState(false)
|
const [showCustom, setShowCustom] = useState(false)
|
||||||
|
|
||||||
// A provider is configured when it has credentials (custom providers always
|
// A provider is configured when it has credentials (a custom provider counts
|
||||||
// carry their own). Unconfigured ones stay selectable but are flagged so the
|
// only once it actually carries a name/key, not as an empty placeholder).
|
||||||
// user is guided to set up the API key.
|
|
||||||
const isConfigured = (id: string): boolean => {
|
const isConfigured = (id: string): boolean => {
|
||||||
const p = data?.providers?.find((x) => x.id === id)
|
const p = data?.providers?.find((x) => x.id === id)
|
||||||
if (!p) return true
|
if (!p) return true
|
||||||
return p.configured || (p.is_custom && !!p.custom_name)
|
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 providerOptions: DropdownOption[] = useMemo(() => {
|
||||||
const opts = (state.providers || []).map((id) => ({
|
const opts = (state.providers || [])
|
||||||
value: id,
|
.filter((id) => isConfigured(id) || id === provider)
|
||||||
label: providerLabel(data, id),
|
.map((id) => ({ value: id, label: providerLabel(data, id) }))
|
||||||
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
|
|
||||||
}))
|
|
||||||
if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts]
|
if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts]
|
||||||
return opts
|
return opts
|
||||||
}, [state.providers, data, allowAuto, autoLabel])
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [state.providers, data, allowAuto, autoLabel, provider])
|
||||||
|
|
||||||
const currentUnconfigured = !!provider && !isConfigured(provider)
|
const currentUnconfigured = !!provider && !isConfigured(provider)
|
||||||
|
|
||||||
|
|||||||
@@ -294,8 +294,11 @@ const VendorModal: React.FC<{
|
|||||||
const open = !!provider || addMode
|
const open = !!provider || addMode
|
||||||
|
|
||||||
// In add-mode the user first picks a built-in provider; that selection
|
// In add-mode the user first picks a built-in provider; that selection
|
||||||
// becomes the effective provider whose key/base fields we edit.
|
// becomes the effective provider whose key/base fields we edit. Exclude ALL
|
||||||
const builtins = useMemo(() => data.providers.filter((p) => !(p.is_custom && p.custom_name)), [data.providers])
|
// 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 firstUnconfigured = builtins.find((p) => !p.configured) || builtins[0]
|
||||||
const [pickId, setPickId] = useState('')
|
const [pickId, setPickId] = useState('')
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, useLayoutEffect } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { ChevronDown } from 'lucide-react'
|
import { ChevronDown } from 'lucide-react'
|
||||||
import { t } from '../../i18n'
|
import { t } from '../../i18n'
|
||||||
|
|
||||||
@@ -50,13 +51,42 @@ export const Dropdown: React.FC<{
|
|||||||
}> = ({ value, display, placeholder, options, disabled, onChange }) => {
|
}> = ({ value, display, placeholder, options, disabled, onChange }) => {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef<HTMLDivElement>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
const h = (e: MouseEvent) => {
|
if (!open) return
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
const onDown = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node
|
||||||
|
if (ref.current?.contains(target) || menuRef.current?.contains(target)) return
|
||||||
|
setOpen(false)
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', h)
|
// Keep the fixed menu anchored to the trigger on scroll/resize by
|
||||||
return () => document.removeEventListener('mousedown', h)
|
// 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 ?? ''
|
const current = display ?? options.find((o) => o.value === value)?.label ?? ''
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative">
|
||||||
@@ -73,28 +103,35 @@ export const Dropdown: React.FC<{
|
|||||||
<span className={`truncate ${current ? '' : 'text-content-tertiary'}`}>{current || placeholder || '--'}</span>
|
<span className={`truncate ${current ? '' : 'text-content-tertiary'}`}>{current || placeholder || '--'}</span>
|
||||||
<ChevronDown size={15} className={`text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
|
<ChevronDown size={15} className={`text-content-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open &&
|
||||||
<div className="absolute z-30 mt-1 w-full max-h-64 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg py-1">
|
rect &&
|
||||||
{options.length === 0 && (
|
createPortal(
|
||||||
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
|
<div
|
||||||
)}
|
ref={menuRef}
|
||||||
{options.map((o) => (
|
style={{ position: 'fixed', left: rect.left, top: rect.top, width: rect.width }}
|
||||||
<div
|
className="z-[100] max-h-64 overflow-y-auto rounded-btn border border-default bg-elevated shadow-lg py-1"
|
||||||
key={o.value}
|
>
|
||||||
onClick={() => {
|
{options.length === 0 && (
|
||||||
onChange(o.value)
|
<div className="px-3 py-2 text-sm text-content-tertiary">{t('models_no_options')}</div>
|
||||||
setOpen(false)
|
)}
|
||||||
}}
|
{options.map((o) => (
|
||||||
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
|
<div
|
||||||
o.value === value ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
|
key={o.value}
|
||||||
}`}
|
onClick={() => {
|
||||||
>
|
onChange(o.value)
|
||||||
<div className="truncate">{o.label}</div>
|
setOpen(false)
|
||||||
{o.hint && <div className="text-xs text-content-tertiary mt-0.5 truncate">{o.hint}</div>}
|
}}
|
||||||
</div>
|
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>
|
}`}
|
||||||
)}
|
>
|
||||||
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,47 @@ function stripCancelMarker(text: string): string {
|
|||||||
.trim()
|
.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. */
|
/** Convert a backend history message into a UI ChatMessage. */
|
||||||
function historyToMessage(m: HistoryMessage): ChatMessage {
|
function historyToMessage(m: HistoryMessage): ChatMessage {
|
||||||
if (m.role === 'user') {
|
if (m.role === 'user') {
|
||||||
@@ -89,6 +130,7 @@ function historyToMessage(m: HistoryMessage): ChatMessage {
|
|||||||
.filter((_, i) => i !== lastContentIdx)
|
.filter((_, i) => i !== lastContentIdx)
|
||||||
.map((s) => ({ ...s }))
|
.map((s) => ({ ...s }))
|
||||||
const finalContent = m.content || (lastContentIdx >= 0 ? raw[lastContentIdx].content || '' : '')
|
const finalContent = m.content || (lastContentIdx >= 0 ? raw[lastContentIdx].content || '' : '')
|
||||||
|
const attachments = attachmentsFromSteps(raw)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: uid('assistant'),
|
id: uid('assistant'),
|
||||||
@@ -100,6 +142,7 @@ function historyToMessage(m: HistoryMessage): ChatMessage {
|
|||||||
kind: m.kind,
|
kind: m.kind,
|
||||||
extras: m.extras,
|
extras: m.extras,
|
||||||
botSeq: m._seq,
|
botSeq: m._seq,
|
||||||
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +261,31 @@ export const useChatStore = create<ChatState>((set, get) => {
|
|||||||
}))
|
}))
|
||||||
break
|
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':
|
case 'cancelled':
|
||||||
updateMsg(sid, botId, (m) => ({ ...m, isCancelled: true }))
|
updateMsg(sid, botId, (m) => ({ ...m, isCancelled: true }))
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import type { UpdateStatus } from '../types'
|
import type { UpdateStatus } from '../types'
|
||||||
|
import { getLang } from '../i18n'
|
||||||
|
|
||||||
interface UpdateState {
|
interface UpdateState {
|
||||||
status: UpdateStatus | null
|
status: UpdateStatus | null
|
||||||
@@ -7,11 +8,33 @@ interface UpdateState {
|
|||||||
version: string | null
|
version: string | null
|
||||||
/** Download progress 0-100 while state === 'downloading'. */
|
/** Download progress 0-100 while state === 'downloading'. */
|
||||||
percent: number
|
percent: number
|
||||||
|
/** User clicked "download" but no progress event has arrived yet. Gives the
|
||||||
|
* button an instant busy state so it can't be clicked again during the 1-2s
|
||||||
|
* lead-up to the first progress event. Cleared on the first 'downloading'. */
|
||||||
|
preparing: boolean
|
||||||
|
/** The download progress already reached ~100% once. macOS (Squirrel.Mac)
|
||||||
|
* emits a SECOND progress pass (verify / block-map) after the first, which
|
||||||
|
* used to make the bar visibly restart from 0. Once peaked we render an
|
||||||
|
* indeterminate "verifying" state instead of a confusing second bar. */
|
||||||
|
progressPeaked: boolean
|
||||||
|
/** User clicked "restart to install"; show a full-screen "installing…"
|
||||||
|
* overlay for the brief window before the app quits to swap the bundle. */
|
||||||
|
installing: boolean
|
||||||
/** User dismissed the badge for this version (don't nag again until next). */
|
/** User dismissed the badge for this version (don't nag again until next). */
|
||||||
dismissedVersion: string | null
|
dismissedVersion: string | null
|
||||||
|
/** Whether the update panel is currently shown. Lifted here so the "check
|
||||||
|
* for update" menu item can re-open it on demand. */
|
||||||
|
panelOpen: boolean
|
||||||
|
|
||||||
setStatus: (s: UpdateStatus) => void
|
setStatus: (s: UpdateStatus) => void
|
||||||
|
/** Dismiss the floating badge/panel for the current version (footer dot goes
|
||||||
|
* away), but keep the update itself known so the menu can still surface it. */
|
||||||
dismiss: () => void
|
dismiss: () => void
|
||||||
|
openPanel: () => void
|
||||||
|
closePanel: () => void
|
||||||
|
/** User explicitly clicked "check for update": ask main to re-check, and if
|
||||||
|
* an update is already known, re-open the panel immediately (undismiss). */
|
||||||
|
recheck: () => void
|
||||||
|
|
||||||
// Actions proxied to the main process via the preload bridge.
|
// Actions proxied to the main process via the preload bridge.
|
||||||
download: () => void
|
download: () => void
|
||||||
@@ -22,20 +45,58 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
|||||||
status: null,
|
status: null,
|
||||||
version: null,
|
version: null,
|
||||||
percent: 0,
|
percent: 0,
|
||||||
|
preparing: false,
|
||||||
|
progressPeaked: false,
|
||||||
|
installing: false,
|
||||||
dismissedVersion: null,
|
dismissedVersion: null,
|
||||||
|
panelOpen: false,
|
||||||
|
|
||||||
setStatus: (s) =>
|
setStatus: (s) =>
|
||||||
set(() => {
|
set((st) => {
|
||||||
if (s.state === 'available') return { status: s, version: s.version, percent: 0 }
|
// A newly detected version auto-opens the panel.
|
||||||
if (s.state === 'downloading') return { status: s, percent: s.percent }
|
if (s.state === 'available')
|
||||||
if (s.state === 'downloaded') return { status: s, version: s.version, percent: 100 }
|
return { status: s, version: s.version, percent: 0, preparing: false, progressPeaked: false, panelOpen: true }
|
||||||
|
if (s.state === 'downloading') {
|
||||||
|
// First real progress event clears the "preparing" busy state. Track
|
||||||
|
// when we've hit ~100% so the Squirrel.Mac second pass renders as an
|
||||||
|
// indeterminate "verifying" state instead of a bar restarting from 0.
|
||||||
|
const peaked = st.progressPeaked || s.percent >= 99
|
||||||
|
return { status: s, percent: s.percent, preparing: false, progressPeaked: peaked }
|
||||||
|
}
|
||||||
|
if (s.state === 'downloaded')
|
||||||
|
return { status: s, version: s.version, percent: 100, preparing: false }
|
||||||
|
if (s.state === 'error') return { status: s, preparing: false, installing: false }
|
||||||
return { status: s }
|
return { status: s }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
dismiss: () => set((st) => ({ dismissedVersion: st.version })),
|
dismiss: () => set((st) => ({ dismissedVersion: st.version, panelOpen: false })),
|
||||||
|
openPanel: () => set({ panelOpen: true }),
|
||||||
|
closePanel: () => set({ panelOpen: false }),
|
||||||
|
|
||||||
download: () => window.electronAPI?.downloadUpdate?.(),
|
recheck: () => {
|
||||||
install: () => window.electronAPI?.installUpdate?.(),
|
// Clear any dismiss so a known update surfaces again. Only re-open the panel
|
||||||
|
// when an update actually exists — if we're already up to date, opening the
|
||||||
|
// panel would just flash it (the banner renders nothing for not-available)
|
||||||
|
// and the "up to date" feedback belongs in the menu, not a panel. The menu
|
||||||
|
// (NavRail) decides whether to close itself + show the panel based on this.
|
||||||
|
const known = hasAvailableUpdate(get())
|
||||||
|
set({ dismissedVersion: null, panelOpen: known })
|
||||||
|
// Always kick a fresh check too (picks up newer versions / recovers errors).
|
||||||
|
// Pass the UI language so downloads route to the China CDN / R2 accordingly.
|
||||||
|
window.electronAPI?.checkForUpdate?.(getLang())
|
||||||
|
},
|
||||||
|
|
||||||
|
download: () => {
|
||||||
|
// Enter a busy state immediately so the button can't be clicked twice while
|
||||||
|
// we wait (1-2s) for the first download-progress event to arrive.
|
||||||
|
set({ preparing: true, progressPeaked: false })
|
||||||
|
window.electronAPI?.downloadUpdate?.(getLang())
|
||||||
|
},
|
||||||
|
install: () => {
|
||||||
|
// Show the "installing…" overlay before the app quits to swap the bundle.
|
||||||
|
set({ installing: true })
|
||||||
|
window.electronAPI?.installUpdate?.()
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Subscribe to main-process update events. Returns an unsubscribe fn.
|
// Subscribe to main-process update events. Returns an unsubscribe fn.
|
||||||
@@ -45,11 +106,18 @@ export function initUpdateListener(): (() => void) | undefined {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether a new version should be surfaced (available/downloading/downloaded
|
// Whether an update exists at all (available/downloading/downloaded),
|
||||||
// and not dismissed for that version).
|
// regardless of dismiss. Drives the "check for update" menu item's dot, which
|
||||||
export function hasPendingUpdate(state: UpdateState): boolean {
|
// should persist as long as an update is actually available.
|
||||||
|
export function hasAvailableUpdate(state: UpdateState): boolean {
|
||||||
const s = state.status
|
const s = state.status
|
||||||
if (!s) return false
|
if (!s) return false
|
||||||
const active = s.state === 'available' || s.state === 'downloading' || s.state === 'downloaded'
|
return s.state === 'available' || s.state === 'downloading' || s.state === 'downloaded'
|
||||||
return active && state.dismissedVersion !== state.version
|
}
|
||||||
|
|
||||||
|
// Whether a new version should be surfaced in the floating footer badge:
|
||||||
|
// available and not dismissed for that version. Dismissing hides only this,
|
||||||
|
// not the menu dot (hasAvailableUpdate).
|
||||||
|
export function hasPendingUpdate(state: UpdateState): boolean {
|
||||||
|
return hasAvailableUpdate(state) && state.dismissedVersion !== state.version
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface ElectronAPI {
|
|||||||
restartBackend: () => Promise<boolean>
|
restartBackend: () => Promise<boolean>
|
||||||
selectDirectory: () => Promise<string | null>
|
selectDirectory: () => Promise<string | null>
|
||||||
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>
|
selectFile: (filters?: { name: string; extensions: string[] }[]) => Promise<string | null>
|
||||||
|
/** Open a local file with the OS default app. Resolves to '' on success. */
|
||||||
|
openPath: (targetPath: string) => Promise<string>
|
||||||
// Listener registrars return an unsubscribe fn for cleanup.
|
// Listener registrars return an unsubscribe fn for cleanup.
|
||||||
onBackendStatus: (callback: (data: BackendStatusEvent) => void) => () => void
|
onBackendStatus: (callback: (data: BackendStatusEvent) => void) => () => void
|
||||||
onBackendLog: (callback: (line: string) => void) => () => void
|
onBackendLog: (callback: (line: string) => void) => () => void
|
||||||
@@ -17,9 +19,11 @@ export interface ElectronAPI {
|
|||||||
windowIsMaximized: () => Promise<boolean>
|
windowIsMaximized: () => Promise<boolean>
|
||||||
onMaximizeChange: (callback: (maximized: boolean) => void) => () => void
|
onMaximizeChange: (callback: (maximized: boolean) => void) => () => void
|
||||||
onMenuAction?: (callback: (action: string) => void) => () => void
|
onMenuAction?: (callback: (action: string) => void) => () => void
|
||||||
// Auto-update
|
// Current app version string (e.g. "0.0.5").
|
||||||
checkForUpdate?: () => Promise<void>
|
getAppVersion?: () => Promise<string>
|
||||||
downloadUpdate?: () => Promise<void>
|
// Auto-update. lang (e.g. "zh") routes installer downloads to the China CDN.
|
||||||
|
checkForUpdate?: (lang?: string) => Promise<void>
|
||||||
|
downloadUpdate?: (lang?: string) => Promise<void>
|
||||||
installUpdate?: () => Promise<void>
|
installUpdate?: () => Promise<void>
|
||||||
onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void
|
onUpdateStatus?: (callback: (status: UpdateStatus) => void) => () => void
|
||||||
platform: string
|
platform: string
|
||||||
@@ -92,6 +96,9 @@ export interface Attachment {
|
|||||||
file_name: string
|
file_name: string
|
||||||
file_type: 'image' | 'video' | 'file' | 'directory'
|
file_type: 'image' | 'video' | 'file' | 'directory'
|
||||||
preview_url?: string
|
preview_url?: string
|
||||||
|
/** Local absolute path (set for files sent via the `send` tool) so the
|
||||||
|
* desktop client can open them directly with the OS default app. */
|
||||||
|
abs_path?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live tool event during SSE streaming. */
|
/** Live tool event during SSE streaming. */
|
||||||
@@ -135,6 +142,7 @@ export interface StreamEvent {
|
|||||||
execution_time?: number
|
execution_time?: number
|
||||||
has_tool_calls?: boolean
|
has_tool_calls?: boolean
|
||||||
path?: string
|
path?: string
|
||||||
|
abs_path?: string
|
||||||
file_name?: string
|
file_name?: string
|
||||||
file_type?: string
|
file_type?: string
|
||||||
web_url?: string
|
web_url?: string
|
||||||
@@ -422,11 +430,27 @@ export interface KnowledgeGraph {
|
|||||||
|
|
||||||
export type KnowledgeAction =
|
export type KnowledgeAction =
|
||||||
| { action: 'create_category'; payload: { path: string } }
|
| { action: 'create_category'; payload: { path: string } }
|
||||||
|
| { action: 'create_document'; payload: { path: string; content: string; overwrite?: boolean } }
|
||||||
| { action: 'rename_category'; payload: { path: string; new_path: string } }
|
| { action: 'rename_category'; payload: { path: string; new_path: string } }
|
||||||
| { action: 'delete_category'; payload: { path: string; confirm?: boolean } }
|
| { action: 'delete_category'; payload: { path: string; confirm?: boolean } }
|
||||||
| { action: 'delete_documents'; payload: { paths: string[] } }
|
| { action: 'delete_documents'; payload: { paths: string[] } }
|
||||||
| { action: 'move_documents'; payload: { paths: string[]; target_category: string } }
|
| { action: 'move_documents'; payload: { paths: string[]; target_category: string } }
|
||||||
|
|
||||||
|
// Result row from a bulk import (one per uploaded file).
|
||||||
|
export interface KnowledgeImportResult {
|
||||||
|
status: 'imported' | 'skipped' | 'failed'
|
||||||
|
path?: string
|
||||||
|
name?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeImportPayload {
|
||||||
|
imported: number
|
||||||
|
skipped: number
|
||||||
|
failed: number
|
||||||
|
results: KnowledgeImportResult[]
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Scheduler
|
// Scheduler
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
"label": "官网",
|
"label": "官网",
|
||||||
"href": "https://cowagent.ai/"
|
"href": "https://cowagent.ai/"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "下载",
|
||||||
|
"href": "https://cowagent.ai/download/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "博客",
|
"label": "博客",
|
||||||
"href": "https://cowagent.ai/zh/blog/"
|
"href": "https://cowagent.ai/zh/blog/"
|
||||||
@@ -55,6 +59,10 @@
|
|||||||
"label": "Website",
|
"label": "Website",
|
||||||
"href": "https://cowagent.ai/"
|
"href": "https://cowagent.ai/"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "Download",
|
||||||
|
"href": "https://cowagent.ai/download/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "Blog",
|
"label": "Blog",
|
||||||
"href": "https://cowagent.ai/blog/"
|
"href": "https://cowagent.ai/blog/"
|
||||||
@@ -86,6 +94,7 @@
|
|||||||
"group": "Installation",
|
"group": "Installation",
|
||||||
"pages": [
|
"pages": [
|
||||||
"guide/quick-start",
|
"guide/quick-start",
|
||||||
|
"guide/desktop",
|
||||||
"guide/manual-install",
|
"guide/manual-install",
|
||||||
"guide/upgrade"
|
"guide/upgrade"
|
||||||
]
|
]
|
||||||
@@ -249,6 +258,7 @@
|
|||||||
"group": "Release Notes",
|
"group": "Release Notes",
|
||||||
"pages": [
|
"pages": [
|
||||||
"releases/overview",
|
"releases/overview",
|
||||||
|
"releases/v2.1.3",
|
||||||
"releases/v2.1.2",
|
"releases/v2.1.2",
|
||||||
"releases/v2.1.1",
|
"releases/v2.1.1",
|
||||||
"releases/v2.1.0",
|
"releases/v2.1.0",
|
||||||
@@ -276,6 +286,10 @@
|
|||||||
"label": "官网",
|
"label": "官网",
|
||||||
"href": "https://cowagent.ai/?lang=zh"
|
"href": "https://cowagent.ai/?lang=zh"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "下载",
|
||||||
|
"href": "https://cowagent.ai/zh/download/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "博客",
|
"label": "博客",
|
||||||
"href": "https://cowagent.ai/zh/blog/"
|
"href": "https://cowagent.ai/zh/blog/"
|
||||||
@@ -307,6 +321,7 @@
|
|||||||
"group": "安装部署",
|
"group": "安装部署",
|
||||||
"pages": [
|
"pages": [
|
||||||
"zh/guide/quick-start",
|
"zh/guide/quick-start",
|
||||||
|
"zh/guide/desktop",
|
||||||
"zh/guide/manual-install",
|
"zh/guide/manual-install",
|
||||||
"zh/guide/upgrade"
|
"zh/guide/upgrade"
|
||||||
]
|
]
|
||||||
@@ -470,6 +485,7 @@
|
|||||||
"group": "发布记录",
|
"group": "发布记录",
|
||||||
"pages": [
|
"pages": [
|
||||||
"zh/releases/overview",
|
"zh/releases/overview",
|
||||||
|
"zh/releases/v2.1.3",
|
||||||
"zh/releases/v2.1.2",
|
"zh/releases/v2.1.2",
|
||||||
"zh/releases/v2.1.1",
|
"zh/releases/v2.1.1",
|
||||||
"zh/releases/v2.1.0",
|
"zh/releases/v2.1.0",
|
||||||
@@ -497,6 +513,10 @@
|
|||||||
"label": "ウェブサイト",
|
"label": "ウェブサイト",
|
||||||
"href": "https://cowagent.ai/"
|
"href": "https://cowagent.ai/"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label": "ダウンロード",
|
||||||
|
"href": "https://cowagent.ai/download/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "ブログ",
|
"label": "ブログ",
|
||||||
"href": "https://cowagent.ai/blog/"
|
"href": "https://cowagent.ai/blog/"
|
||||||
@@ -528,6 +548,7 @@
|
|||||||
"group": "インストール",
|
"group": "インストール",
|
||||||
"pages": [
|
"pages": [
|
||||||
"ja/guide/quick-start",
|
"ja/guide/quick-start",
|
||||||
|
"ja/guide/desktop",
|
||||||
"ja/guide/manual-install",
|
"ja/guide/manual-install",
|
||||||
"ja/guide/upgrade"
|
"ja/guide/upgrade"
|
||||||
]
|
]
|
||||||
@@ -691,6 +712,7 @@
|
|||||||
"group": "リリースノート",
|
"group": "リリースノート",
|
||||||
"pages": [
|
"pages": [
|
||||||
"ja/releases/overview",
|
"ja/releases/overview",
|
||||||
|
"ja/releases/v2.1.3",
|
||||||
"ja/releases/v2.1.2",
|
"ja/releases/v2.1.2",
|
||||||
"ja/releases/v2.1.1",
|
"ja/releases/v2.1.1",
|
||||||
"ja/releases/v2.1.0",
|
"ja/releases/v2.1.0",
|
||||||
|
|||||||
36
docs/guide/desktop.mdx
Normal file
36
docs/guide/desktop.mdx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: Desktop Client
|
||||||
|
description: Download and use the CowAgent desktop client (macOS / Windows)
|
||||||
|
---
|
||||||
|
|
||||||
|
CowAgent ships a ready-to-use desktop client with the Agent runtime bundled in — **no need to install Python or dependencies manually**. Just download, install, and run your local super AI assistant.
|
||||||
|
|
||||||
|
## Download & Install
|
||||||
|
|
||||||
|
<Card title="Go to the download page" icon="download" href="https://cowagent.ai/download/">
|
||||||
|
Download the macOS / Windows installer
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="macOS">
|
||||||
|
1. Open the [download page](https://cowagent.ai/download/) and pick the build for your chip:
|
||||||
|
- Apple Silicon (M1/M2/M3/M4): download the `arm64` build
|
||||||
|
- Intel: download the `x64` build
|
||||||
|
2. Open the downloaded `.dmg` and drag CowAgent into your Applications folder.
|
||||||
|
3. Launch CowAgent from Launchpad or Applications.
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Windows">
|
||||||
|
1. Open the [download page](https://cowagent.ai/download/) and download the Windows installer (`.exe`).
|
||||||
|
2. Run the installer and follow the prompts (you can choose the install directory).
|
||||||
|
3. Launch CowAgent from the desktop or Start menu.
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
## Auto Update
|
||||||
|
|
||||||
|
The desktop client has built-in auto-update. When a new version is available it is detected automatically; you can also manually "Check for updates" from the menu in the bottom-left corner and upgrade with one click.
|
||||||
|
|
||||||
|
## Desktop vs. Command-line Deployment
|
||||||
|
|
||||||
|
- **Desktop client**: best for personal use on your own computer — works out of the box, GUI-based, auto-updating.
|
||||||
|
- **Command-line deployment**: best for developers or long-running servers with more customization. See [Quick Start](/guide/quick-start).
|
||||||
@@ -24,6 +24,7 @@ CowAgent は軽量でデプロイしやすく、拡張性に優れています
|
|||||||
<a href="https://docs.cowagent.ai/ja/intro/index">📖 ドキュメント</a> ·
|
<a href="https://docs.cowagent.ai/ja/intro/index">📖 ドキュメント</a> ·
|
||||||
<a href="https://docs.cowagent.ai/ja/guide/quick-start">🚀 クイックスタート</a> ·
|
<a href="https://docs.cowagent.ai/ja/guide/quick-start">🚀 クイックスタート</a> ·
|
||||||
<a href="https://skills.cowagent.ai/">🧩 Skill Hub</a> ·
|
<a href="https://skills.cowagent.ai/">🧩 Skill Hub</a> ·
|
||||||
|
<a href="https://cowagent.ai/download/">💻 ダウンロード</a> ·
|
||||||
<a href="https://link-ai.tech/cowagent/create">☁️ オンラインで試す</a>
|
<a href="https://link-ai.tech/cowagent/create">☁️ オンラインで試す</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -95,6 +96,8 @@ cow skill install <名前> # Skill のインストール
|
|||||||
cow install-browser # ブラウザツールのインストール
|
cow install-browser # ブラウザツールのインストール
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 💻 デスクトップクライアント:**[CowAgent デスクトップクライアント](https://cowagent.ai/download/)**(macOS / Windows)はバックエンドを内蔵し、ダウンロードしてすぐに使えます。
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
## 🤖 モデル
|
## 🤖 モデル
|
||||||
@@ -103,13 +106,13 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
| プロバイダー | 代表的なモデル | チャット | 画像認識 | 画像生成 | ASR | TTS | Embedding |
|
||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/ja/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/ja/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/ja/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/ja/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](https://docs.cowagent.ai/ja/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](https://docs.cowagent.ai/ja/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](https://docs.cowagent.ai/ja/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/ja/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/ja/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](https://docs.cowagent.ai/ja/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
@@ -199,6 +202,8 @@ CowAgent は主要な LLM プロバイダーすべてに対応しています。
|
|||||||
|
|
||||||
## 🏷 更新履歴
|
## 🏷 更新履歴
|
||||||
|
|
||||||
|
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [デスクトップクライアント](https://cowagent.ai/download/)(macOS / Windows)、ナレッジベースのドキュメント管理、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加。
|
||||||
|
|
||||||
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web コンソールの強化(定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー)、自己進化の改善、新モデル(kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善。
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web コンソールの強化(定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー)、自己進化の改善、新モデル(kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善。
|
||||||
|
|
||||||
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル(MiniMax-M3、qwen3.7-plus)、Python 3.13 対応。
|
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル(MiniMax-M3、qwen3.7-plus)、Python 3.13 対応。
|
||||||
|
|||||||
36
docs/ja/guide/desktop.mdx
Normal file
36
docs/ja/guide/desktop.mdx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
title: デスクトップクライアント
|
||||||
|
description: CowAgent デスクトップクライアント(macOS / Windows)のダウンロードと使い方
|
||||||
|
---
|
||||||
|
|
||||||
|
CowAgent は、Agent の実行環境を内蔵したすぐに使えるデスクトップクライアントを提供しています。**Python や依存関係を手動でインストールする必要はありません**。ダウンロードしてインストールするだけで、ローカルでスーパー AI アシスタントを実行できます。
|
||||||
|
|
||||||
|
## ダウンロードとインストール
|
||||||
|
|
||||||
|
<Card title="ダウンロードページへ" icon="download" href="https://cowagent.ai/download/">
|
||||||
|
macOS / Windows 用インストーラーをダウンロード
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="macOS">
|
||||||
|
1. [ダウンロードページ](https://cowagent.ai/download/) を開き、チップに合わせて選択します:
|
||||||
|
- Apple Silicon(M1/M2/M3/M4):`arm64` 版をダウンロード
|
||||||
|
- Intel:`x64` 版をダウンロード
|
||||||
|
2. ダウンロードした `.dmg` を開き、CowAgent を「アプリケーション」フォルダにドラッグします。
|
||||||
|
3. Launchpad またはアプリケーションから CowAgent を起動します。
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Windows">
|
||||||
|
1. [ダウンロードページ](https://cowagent.ai/download/) を開き、Windows インストーラー(`.exe`)をダウンロードします。
|
||||||
|
2. インストーラーを実行し、画面の指示に従います(インストール先を選択できます)。
|
||||||
|
3. デスクトップまたはスタートメニューから CowAgent を起動します。
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
## 自動アップデート
|
||||||
|
|
||||||
|
デスクトップクライアントには自動アップデート機能が組み込まれています。新しいバージョンがあると自動的に検出・通知され、左下のメニューから手動で「アップデートを確認」してワンクリックでアップグレードすることもできます。
|
||||||
|
|
||||||
|
## デスクトップとコマンドラインの違い
|
||||||
|
|
||||||
|
- **デスクトップクライアント**:自分の PC での個人利用に最適。すぐに使え、GUI 操作で自動アップデート対応。
|
||||||
|
- **コマンドラインデプロイ**:開発者やサーバーでの長期運用に最適で、カスタマイズ性が高い。詳しくは [クイックスタート](/ja/guide/quick-start) を参照。
|
||||||
@@ -79,6 +79,22 @@ Deep Dream は以下の整理ルールに従います:
|
|||||||
初回デプロイ後は `/memory dream 30` を一度実行して、すべての履歴日次記憶を MEMORY.md に蒸留することをお勧めします。
|
初回デプロイ後は `/memory dream 30` を一度実行して、すべての履歴日次記憶を MEMORY.md に蒸留することをお勧めします。
|
||||||
</Tip>
|
</Tip>
|
||||||
|
|
||||||
|
## 設定スイッチ
|
||||||
|
|
||||||
|
`config.json` の `deep_dream_enabled` で毎晩の自動蒸留を制御します:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deep_dream_enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 設定項目 | 説明 | デフォルト |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `deep_dream_enabled` | 毎日の定期蒸留の有効化 | `true` |
|
||||||
|
|
||||||
|
デフォルトで有効になっており、既存の動作と互換性があります。無効にすると毎晩の自動蒸留が実行されなくなります。MEMORY.md を手動管理したい場合や LLM 呼び出しを 1 回節約したい場合に適しています(日次記憶の要約には影響しません)。手動コマンド `/memory dream` もこのスイッチの影響を受けず、いつでもトリガー可能です。
|
||||||
|
|
||||||
## 安全メカニズム
|
## 安全メカニズム
|
||||||
|
|
||||||
| メカニズム | 説明 |
|
| メカニズム | 説明 |
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "claude-fable-5",
|
"model": "claude-sonnet-5",
|
||||||
"claude_api_key": "YOUR_API_KEY"
|
"claude_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
|
| `model` | `claude-sonnet-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` などをサポート。詳細は [公式モデル一覧](https://docs.anthropic.com/en/docs/about-claude/models/overview) を参照 |
|
||||||
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
|
| `claude_api_key` | [Claude コンソール](https://console.anthropic.com/settings/keys) で作成 |
|
||||||
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
| `claude_api_base` | 任意。デフォルトは `https://api.anthropic.com/v1`。サードパーティのプロキシに変更可能 |
|
||||||
|
|
||||||
@@ -28,8 +28,8 @@ Claude は Anthropic が提供するモデルで、テキスト対話と画像
|
|||||||
|
|
||||||
| モデル | 用途 |
|
| モデル | 用途 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-fable-5` | 最新フラッグシップ。複雑な推論や長いタスクチェーンに最適。価格はやや高め |
|
| `claude-sonnet-5` | 最新フラッグシップ。デフォルト推奨モデルで、推論性能とコストのバランスが最も良い |
|
||||||
| `claude-opus-4-8` | 前世代フラッグシップ。性能とコストのバランスが良い |
|
| `claude-opus-4-8` | 前世代フラッグシップ。推論性能が最も高いが、価格は高め |
|
||||||
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
|
| `claude-opus-4-7` | より以前の Opus フラッグシップ |
|
||||||
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
| `claude-sonnet-4-6` | コストパフォーマンスと速度のバランスが良く、コストも低い |
|
||||||
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | より以前のフラッグシップ。価格はより安い |
|
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | より以前のフラッグシップ。価格はより安い |
|
||||||
@@ -44,7 +44,7 @@ Vision モデルを手動で指定したい場合は、設定ファイルで明
|
|||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"vision": {
|
"vision": {
|
||||||
"model": "claude-sonnet-4-6"
|
"model": "claude-sonnet-5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ Doubao(火山方舟)はテキスト対話、画像理解、画像生成(Se
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "doubao-seed-2-0-pro-260215",
|
"model": "doubao-seed-2-1-pro-260628",
|
||||||
"ark_api_key": "YOUR_API_KEY"
|
"ark_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| パラメータ | 説明 |
|
| パラメータ | 説明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | `doubao-seed-2-0-pro-260215`、`doubao-seed-2-0-code-preview-260215`、`doubao-seed-2-0-lite-260215` などを指定可能 |
|
| `model` | `doubao-seed-2-1-pro-260628`、`doubao-seed-2-1-turbo-260628`、`doubao-seed-2-0-pro-260215`、`doubao-seed-2-0-code-preview-260215` などを指定可能 |
|
||||||
| `ark_api_key` | [火山方舟コンソール](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) で作成 |
|
| `ark_api_key` | [火山方舟コンソール](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) で作成 |
|
||||||
| `ark_base_url` | 任意。デフォルトは `https://ark.cn-beijing.volces.com/api/v3` |
|
| `ark_base_url` | 任意。デフォルトは `https://ark.cn-beijing.volces.com/api/v3` |
|
||||||
|
|
||||||
## 画像理解
|
## 画像理解
|
||||||
|
|
||||||
`ark_api_key` を設定すると、Agent の Vision ツールは自動的に `doubao-seed-2-0-pro-260215` を使用して画像を認識します。追加設定は不要です。
|
`ark_api_key` を設定し、メインモデルが Doubao 系の場合、Agent の Vision ツールは現在のメインモデルを自動的に使用して画像を認識します。追加設定は不要です。
|
||||||
|
|
||||||
Vision モデルを手動で指定したい場合は:
|
Vision モデルを手動で指定したい場合は:
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Vision モデルを手動で指定したい場合は:
|
|||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"vision": {
|
"vision": {
|
||||||
"model": "doubao-seed-2-0-pro-260215"
|
"model": "doubao-seed-2-1-pro-260628"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: CowAgent がサポートするモデルベンダーと機能マト
|
|||||||
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
CowAgent は国内外の主要ベンダーの大規模言語モデルをサポートしており、モデル接続の実装はプロジェクトの `models/` ディレクトリにあります。テキスト対話に加えて、一部のベンダーは画像理解、画像生成、音声認識、音声合成、ベクトルなどの機能も提供しており、Agent フローの中で必要に応じて呼び出すことができます。
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-4-6、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k2.7-code、ernie-5.1。
|
Agent モードでは、効果とコストのバランスを考慮して以下のモデルの利用を推奨します:deepseek-v4-flash、MiniMax-M3、claude-sonnet-5、gemini-3.5-flash、glm-5.2、qwen3.7-plus、kimi-k2.7-code、ernie-5.1。
|
||||||
|
|
||||||
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
同時に [LinkAI](https://link-ai.tech) プラットフォームの API もサポートしており、1 つの Key で複数ベンダーを柔軟に切り替えられ、ナレッジベース、ワークフロー、プラグインなどの機能も付属しています。
|
||||||
</Note>
|
</Note>
|
||||||
@@ -20,12 +20,12 @@ CowAgent は国内外の主要ベンダーの大規模言語モデルをサポ
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
| [Claude](/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/models/openai) | gpt-5.5、o シリーズ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [Zhipu GLM](/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Tongyi Qianwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.0 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](/models/doubao) | doubao-seed-2.1 シリーズ | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [Baidu Qianfan](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [LinkAI](/models/linkai) | 複数ベンダー 100+ モデルを統一接続 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ description: LinkAI プラットフォーム経由でテキスト、ビジョン
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
選択可能なモデル:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` など。
|
選択可能なモデル:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-1-pro-260628`、`kimi-k2.6`、`claude-sonnet-5`、`gemini-3.1-flash-lite-preview` など。
|
||||||
|
|
||||||
## 画像生成
|
## 画像生成
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description: CowAgent バージョン更新履歴
|
|||||||
|
|
||||||
| バージョン | 日付 | 説明 |
|
| バージョン | 日付 | 説明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| [2.1.3](/ja/releases/v2.1.3) | 2026.07.08 | デスクトップクライアント正式リリース(macOS / Windows)、ナレッジベースのドキュメント管理強化、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデル追加(claude-sonnet-5、doubao-seed-2.1 など)、セキュリティ強化と改善 |
|
||||||
| [2.1.2](/ja/releases/v2.1.2) | 2026.06.18 | Web コンソールの強化(定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー)、自己進化の改善、新モデル追加(kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善 |
|
| [2.1.2](/ja/releases/v2.1.2) | 2026.06.18 | Web コンソールの強化(定期タスク管理、ナレッジベースのカテゴリ、複数のカスタムモデルプロバイダー)、自己進化の改善、新モデル追加(kimi-k2.7-code、glm-5.2)、セキュリティ強化と改善 |
|
||||||
| [2.1.1](/ja/releases/v2.1.1) | 2026.06.09 | 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル追加(MiniMax-M3、qwen3.7-plus など)、各種改善 |
|
| [2.1.1](/ja/releases/v2.1.1) | 2026.06.09 | 自己進化、Web コンソールの強化(メッセージ管理、マルチセッション並行)、クロスプラットフォーム対応の MCP 強化と並行呼び出し、新モデル追加(MiniMax-M3、qwen3.7-plus など)、各種改善 |
|
||||||
| [2.1.0](/ja/releases/v2.1.0) | 2026.06.01 | 国際化対応、Telegram / Discord / Slack / WeChat カスタマーサービスチャネルの追加、CLI インタラクション強化(ストリーミング出力、コマンドあいまいマッチング、タスクキャンセル)、MCP Streamable HTTP、新モデル追加 |
|
| [2.1.0](/ja/releases/v2.1.0) | 2026.06.01 | 国際化対応、Telegram / Discord / Slack / WeChat カスタマーサービスチャネルの追加、CLI インタラクション強化(ストリーミング出力、コマンドあいまいマッチング、タスクキャンセル)、MCP Streamable HTTP、新モデル追加 |
|
||||||
|
|||||||
73
docs/ja/releases/v2.1.3.mdx
Normal file
73
docs/ja/releases/v2.1.3.mdx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.3
|
||||||
|
description: "CowAgent 2.1.3:デスクトップクライアント(macOS / Windows)を正式リリース。ナレッジベースのドキュメント管理を強化し、MCP ツールのオンデマンド検索、繁体字中国語対応、新モデルに加え、セキュリティと使い勝手の各種改善"
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.3) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.3)
|
||||||
|
|
||||||
|
## 🖥 デスクトップクライアント
|
||||||
|
|
||||||
|
**CowAgent デスクトップクライアント**を正式にリリースしました。**macOS と Windows** に対応し、ローカルのスーパー AI アシスタントをそのまますぐに使えます。
|
||||||
|
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/desktop-chat-demo-en.png" alt="CowAgent デスクトップクライアント" />
|
||||||
|
|
||||||
|
主な特長:
|
||||||
|
|
||||||
|
- **すぐに使える**:Agent の実行環境を内蔵しており、インストール後すぐに起動できます。Python などの依存関係を用意する必要はありません
|
||||||
|
- **充実したチャット体験**:ストリーミング応答、セッション管理、ツール呼び出しのステップ表示、Markdown レンダリングに加え、画像 / 動画 / ファイルの送信とプレビューに対応
|
||||||
|
- **ビジュアル管理**:設定、モデル、ナレッジベース、定期タスク、スキル、メモリなどの画面が Web コンソールと同等で、ネイティブ UI 上で管理できます
|
||||||
|
- **チャネル接続**:アプリ内で QR コードをスキャンして各種メッセージチャネルに接続
|
||||||
|
- **自動アップデート**:バージョンの自動チェックとワンクリック更新に対応し、地域ごとにダウンロード速度を最適化
|
||||||
|
- **ネイティブ体験**:初回起動ガイド、システム言語への追従、プラットフォームに適応したウィンドウ操作
|
||||||
|
|
||||||
|
ダウンロード:[CowAgent デスクトップ](https://cowagent.ai/download/)
|
||||||
|
|
||||||
|
## 📚 ナレッジベース
|
||||||
|
|
||||||
|
- **ドキュメントの作成とインポート**:UI から直接ドキュメントを新規作成したり、外部ドキュメントをインポートできます
|
||||||
|
- **インデックスの自動メンテナンス**:実際のディレクトリ構成からインデックスを自動的に再構築し、インデックスのずれやドキュメントの欠落を防ぎます
|
||||||
|
- **ベクトル化の修正**:インデックスが統一された埋め込みプロバイダーを再利用するようになり、キーワード検索へのフォールバックではなく本来のセマンティックベクトルを生成します
|
||||||
|
|
||||||
|
Thanks @yangziyu-hhh
|
||||||
|
|
||||||
|
ドキュメント:[ナレッジベース](https://docs.cowagent.ai/ja/memory/knowledge)
|
||||||
|
|
||||||
|
## 🔌 MCP ツールのオンデマンド検索
|
||||||
|
|
||||||
|
多数の MCP ツールを接続した際のコンテキスト肥大化に対応するため、**オンデマンドのツール検索**を追加しました。現在のタスクに関連する MCP ツールを RAG ベクトル検索で必要に応じて読み込み、無関係なツールがコンテキストを占有するのを抑えます。Thanks @fengyl07
|
||||||
|
|
||||||
|
ドキュメント:[MCP ツール](https://docs.cowagent.ai/ja/tools/mcp)
|
||||||
|
|
||||||
|
## 🌏 繁体字中国語対応
|
||||||
|
|
||||||
|
Web コンソール、ログ、ドキュメントが **繁体字中国語(zh-Hant)** に対応しました。インターフェース言語はシステムに追従するか、手動で切り替えられます。Thanks @anomixer (#2935)
|
||||||
|
|
||||||
|
## 🤖 新モデル追加
|
||||||
|
|
||||||
|
- **claude-sonnet-5**、**claude-fable-5** に対応
|
||||||
|
- **doubao-seed-2-1-pro**、**doubao-seed-2-1-turbo** に対応
|
||||||
|
|
||||||
|
ドキュメント:[モデル](https://docs.cowagent.ai/ja/models)
|
||||||
|
|
||||||
|
## 🔒 セキュリティ強化
|
||||||
|
|
||||||
|
- **機密ファイル読み取り防護**:認証情報などの機密ファイルへのアクセスを強化し、迂回による読み取りを防止します。Thanks @fengyl07 (#2913)
|
||||||
|
- **ブラウザアクセス防護**:ブラウザによる内部ネットワークやクラウドサーバーの内部エンドポイントへのリクエストをブロックし、内部サービスへ誘導されるリスクを低減します。Thanks @christop
|
||||||
|
- **設定解析の安全化**:設定内容をより安全な方法で解析し、潜在的なコード実行リスクを回避します。Thanks @shunfeng8421
|
||||||
|
|
||||||
|
## 🛠 改善と修正
|
||||||
|
|
||||||
|
- **カスタムプロバイダー対応**:埋め込みモデルとビジョンモデルでカスタムプロバイダーを利用可能に。あわせて Windows でのメモリ取得の問題を修正しました。Thanks @HnBigVolibear
|
||||||
|
- **ファイル編集の安定性向上**:元のインデントをより適切に保持し、あいまい一致の際に無関係な内容を変更しないようにしました。Thanks @xiaweiwei67-stack (#2942)
|
||||||
|
- **コマンド出力の文字化け修正**:コマンドが大量の出力を生成した際に発生し得る中国語の文字化けを修正しました。Thanks @xiaweiwei67-stack (#2941)
|
||||||
|
- **Azure OpenAI の修正**:Azure OpenAI のストリーミング出力および関連する設定の問題を修正しました。Thanks @Eric L
|
||||||
|
- **企業向け WeChat スマートボット**:webhook(コールバック)モードの接続ドキュメントを追加しました。Thanks @6vision
|
||||||
|
- **ディープドリームの切り替え**:`deep_dream_enabled` の専用スイッチを追加し、ディープドリームの蒸留を個別に有効・無効化できます。
|
||||||
|
- **安定性**:Web サービスの接続回収を改善し、自己進化に関するいくつかの問題を修正しました (#2924, #2904)
|
||||||
|
|
||||||
|
## 📦 アップグレード方法
|
||||||
|
|
||||||
|
- **デスクトップクライアント**:[ダウンロードページ](https://cowagent.ai/download/) から最新バージョンを入手してください。
|
||||||
|
- **ソースデプロイ**:`cow update` でワンクリックアップグレード、または最新コードを取得して再起動します。詳しくは [アップグレードガイド](https://docs.cowagent.ai/ja/guide/upgrade) を参照してください。
|
||||||
|
|
||||||
|
**リリース日**:2026.07.08 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.2...2.1.3)
|
||||||
@@ -22,7 +22,7 @@ Vision ツールは多段階の自動選択 + 自動フォールバック戦略
|
|||||||
| 通義千問 (DashScope) | メインモデルを使用 | 例:qwen3.7-plus など |
|
| 通義千問 (DashScope) | メインモデルを使用 | 例:qwen3.7-plus など |
|
||||||
| Claude | メインモデルを使用 | Anthropic ネイティブ画像形式 |
|
| Claude | メインモデルを使用 | Anthropic ネイティブ画像形式 |
|
||||||
| Gemini | メインモデルを使用 | inlineData 形式 |
|
| Gemini | メインモデルを使用 | inlineData 形式 |
|
||||||
| 豆包 (Doubao) | メインモデルを使用 | doubao-seed-2-0 シリーズがネイティブ対応 |
|
| 豆包 (Doubao) | メインモデルを使用 | doubao-seed-2-1 シリーズがネイティブ対応 |
|
||||||
| Kimi (Moonshot) | メインモデルを使用 | kimi-k2.6、kimi-k2.5 がネイティブ対応 |
|
| Kimi (Moonshot) | メインモデルを使用 | kimi-k2.6、kimi-k2.5 がネイティブ対応 |
|
||||||
| 百度 Qianfan | メインモデルを使用 | デフォルトでマルチモーダルメインモデル(`ernie-5.1` など)を使用。メインモデルが非対応の場合は `ernie-4.5-turbo-vl` にフォールバック |
|
| 百度 Qianfan | メインモデルを使用 | デフォルトでマルチモーダルメインモデル(`ernie-5.1` など)を使用。メインモデルが非対応の場合は `ernie-4.5-turbo-vl` にフォールバック |
|
||||||
| 智谱 AI | glm-5v-turbo | 常にビジョン専用モデルを使用 |
|
| 智谱 AI | glm-5v-turbo | 常にビジョン専用モデルを使用 |
|
||||||
|
|||||||
@@ -79,6 +79,22 @@ In addition to the automatic daily run, you can manually trigger distillation in
|
|||||||
After first deployment, it's recommended to run `/memory dream 30` once to distill all historical daily memories into MEMORY.md.
|
After first deployment, it's recommended to run `/memory dream 30` once to distill all historical daily memories into MEMORY.md.
|
||||||
</Tip>
|
</Tip>
|
||||||
|
|
||||||
|
## Config Toggle
|
||||||
|
|
||||||
|
Control the nightly automatic distillation via `deep_dream_enabled` in `config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deep_dream_enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key | Description | Default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `deep_dream_enabled` | Enable the daily scheduled distillation | `true` |
|
||||||
|
|
||||||
|
Enabled by default to keep existing behavior. When disabled, the nightly distillation no longer runs — useful if you prefer to maintain MEMORY.md manually or want to save an LLM call (daily memory summarization is not affected). The manual `/memory dream` command is also unaffected and can still be triggered anytime.
|
||||||
|
|
||||||
## Safety Mechanisms
|
## Safety Mechanisms
|
||||||
|
|
||||||
| Mechanism | Description |
|
| Mechanism | Description |
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "claude-fable-5",
|
"model": "claude-sonnet-5",
|
||||||
"claude_api_key": "YOUR_API_KEY"
|
"claude_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Supports `claude-fable-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
| `model` | Supports `claude-sonnet-5`, `claude-opus-4-8`, `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-opus-4-6`, `claude-sonnet-4-5`, `claude-sonnet-4-0`, `claude-3-5-sonnet-latest`, etc. See [official models](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
||||||
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
|
| `claude_api_key` | Create one in the [Claude Console](https://console.anthropic.com/settings/keys) |
|
||||||
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
|
| `claude_api_base` | Optional, defaults to `https://api.anthropic.com/v1`. Can be changed to a third-party proxy |
|
||||||
|
|
||||||
@@ -28,8 +28,8 @@ Claude is provided by Anthropic and supports both text chat and image understand
|
|||||||
|
|
||||||
| Model | Use Case |
|
| Model | Use Case |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-fable-5` | Latest flagship; best for complex reasoning and long-running tasks, at a higher price |
|
| `claude-sonnet-5` | Latest flagship; default recommendation, best balance of reasoning quality and cost |
|
||||||
| `claude-opus-4-8` | Previous flagship with balanced quality and cost |
|
| `claude-opus-4-8` | Previous flagship with the strongest reasoning, at a higher price |
|
||||||
| `claude-opus-4-7` | Earlier Opus flagship |
|
| `claude-opus-4-7` | Earlier Opus flagship |
|
||||||
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
| `claude-sonnet-4-6` | Balanced cost and speed, lower cost |
|
||||||
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | Earlier flagships at a lower price |
|
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | Earlier flagships at a lower price |
|
||||||
@@ -44,7 +44,7 @@ To manually specify a Vision model, set it explicitly in the configuration file:
|
|||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"vision": {
|
"vision": {
|
||||||
"model": "claude-sonnet-4-6"
|
"model": "claude-sonnet-5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ Doubao (Volcengine Ark) supports text chat, image understanding, image generatio
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "doubao-seed-2-0-pro-260215",
|
"model": "doubao-seed-2-1-pro-260628",
|
||||||
"ark_api_key": "YOUR_API_KEY"
|
"ark_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Parameter | Description |
|
| Parameter | Description |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | Can be `doubao-seed-2-0-pro-260215`, `doubao-seed-2-0-code-preview-260215`, `doubao-seed-2-0-lite-260215`, etc. |
|
| `model` | Can be `doubao-seed-2-1-pro-260628`, `doubao-seed-2-1-turbo-260628`, `doubao-seed-2-0-pro-260215`, `doubao-seed-2-0-code-preview-260215`, etc. |
|
||||||
| `ark_api_key` | Create one in the [Volcengine Ark Console](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) |
|
| `ark_api_key` | Create one in the [Volcengine Ark Console](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) |
|
||||||
| `ark_base_url` | Optional, defaults to `https://ark.cn-beijing.volces.com/api/v3` |
|
| `ark_base_url` | Optional, defaults to `https://ark.cn-beijing.volces.com/api/v3` |
|
||||||
|
|
||||||
## Image Understanding
|
## Image Understanding
|
||||||
|
|
||||||
Once `ark_api_key` is configured, the Agent's Vision tool automatically uses `doubao-seed-2-0-pro-260215` to recognize images, with no extra setup required.
|
Once `ark_api_key` is configured and the main model is a Doubao model, the Agent's Vision tool automatically uses the current main model to recognize images, with no extra setup required.
|
||||||
|
|
||||||
To manually specify a Vision model:
|
To manually specify a Vision model:
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ To manually specify a Vision model:
|
|||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"vision": {
|
"vision": {
|
||||||
"model": "doubao-seed-2-0-pro-260215"
|
"model": "doubao-seed-2-1-pro-260628"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ A snapshot of each provider's capabilities. "Text" refers to the main chat model
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
| [Claude](/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [GLM](/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [Qwen](/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [Doubao](/models/doubao) | doubao-seed-2.0 series | ✅ | ✅ | ✅ | | | ✅ |
|
| [Doubao](/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [ERNIE](/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [MiMo](/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ Once configured, the Agent's Vision tool automatically calls multimodal models v
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.7-plus`, `doubao-seed-2-0-pro-260215`, `kimi-k2.6`, `claude-sonnet-4-6`, `gemini-3.1-flash-lite-preview`, etc.
|
Available models: `gpt-4.1-mini`, `gpt-5.4-mini`, `qwen3.7-plus`, `doubao-seed-2-1-pro-260628`, `kimi-k2.6`, `claude-sonnet-5`, `gemini-3.1-flash-lite-preview`, etc.
|
||||||
|
|
||||||
## Image Generation
|
## Image Generation
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description: CowAgent version history
|
|||||||
|
|
||||||
| Version | Date | Description |
|
| Version | Date | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| [2.1.3](/releases/v2.1.3) | 2026.07.08 | Desktop client released (macOS / Windows), knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models (claude-sonnet-5, doubao-seed-2.1, etc.), security hardening and refinements |
|
||||||
| [2.1.2](/releases/v2.1.2) | 2026.06.18 | 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 |
|
| [2.1.2](/releases/v2.1.2) | 2026.06.18 | 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 |
|
||||||
| [2.1.1](/releases/v2.1.1) | 2026.06.09 | Self-Evolution, Web Console message management and parallel sessions, cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus, etc.), various improvements |
|
| [2.1.1](/releases/v2.1.1) | 2026.06.09 | Self-Evolution, Web Console message management and parallel sessions, cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus, etc.), various improvements |
|
||||||
| [2.1.0](/releases/v2.1.0) | 2026.06.01 | Internationalization, new Telegram / Discord / Slack / WeChat Customer Service channels, CLI interaction upgrades (streaming output, fuzzy command matching, task cancellation), MCP Streamable HTTP, new models |
|
| [2.1.0](/releases/v2.1.0) | 2026.06.01 | Internationalization, new Telegram / Discord / Slack / WeChat Customer Service channels, CLI interaction upgrades (streaming output, fuzzy command matching, task cancellation), MCP Streamable HTTP, new models |
|
||||||
|
|||||||
74
docs/releases/v2.1.3.mdx
Normal file
74
docs/releases/v2.1.3.mdx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.3
|
||||||
|
description: "CowAgent 2.1.3: Desktop client for macOS / Windows, enhanced knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models, plus security and experience improvements"
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.3) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.3)
|
||||||
|
|
||||||
|
## 🖥 Desktop Client
|
||||||
|
|
||||||
|
Introducing the **CowAgent Desktop client** for **macOS and Windows** — your local super AI assistant, truly ready to use out of the box.
|
||||||
|
|
||||||
|
Download: [CowAgent Desktop](https://cowagent.ai/download/)
|
||||||
|
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/en/desktop-chat-demo-en.png" alt="CowAgent Desktop client" />
|
||||||
|
|
||||||
|
Highlights:
|
||||||
|
|
||||||
|
- **Out of the box**: the full Agent runtime is bundled — launch right after install, no need to set up Python or other dependencies
|
||||||
|
- **Full chat experience**: streaming replies, session management, tool-call step display, Markdown rendering, plus sending and previewing images / videos / files
|
||||||
|
- **Visual management**: configuration, models, knowledge base, scheduled tasks, skills, and memory pages mirror the Web console, all manageable in the native UI
|
||||||
|
- **Channel onboarding**: connect messaging channels by scanning a QR code right inside the app
|
||||||
|
- **Auto update**: automatic version checks and one-click updates, with download speed optimized across regions
|
||||||
|
- **Native experience**: first-run onboarding, follows the system language, and platform-adaptive window interactions
|
||||||
|
|
||||||
|
|
||||||
|
## 📚 Knowledge Base
|
||||||
|
|
||||||
|
- **Create & import documents**: create new documents or import external ones directly from the UI
|
||||||
|
- **Automatic index maintenance**: the knowledge base index is rebuilt automatically from the actual directory tree, preventing index drift or lost documents
|
||||||
|
- **Vectorization fix**: the index now reuses the unified embedding provider, ensuring real semantic vectors instead of falling back to keyword search
|
||||||
|
|
||||||
|
Thanks @yangziyu-hhh
|
||||||
|
|
||||||
|
Docs: [Knowledge Base](https://docs.cowagent.ai/memory/knowledge)
|
||||||
|
|
||||||
|
## 🔌 On-demand MCP Tool Retrieval
|
||||||
|
|
||||||
|
To address context bloat when many MCP tools are connected, we added **on-demand tool retrieval**: relevant MCP tools are loaded on demand via RAG vector search based on the current task, reducing the context taken up by irrelevant tools. Thanks @fengyl07
|
||||||
|
|
||||||
|
Docs: [MCP Tools](https://docs.cowagent.ai/tools/mcp)
|
||||||
|
|
||||||
|
## 🌏 Traditional Chinese Support
|
||||||
|
|
||||||
|
The Web console, logs, and documentation now support **Traditional Chinese (zh-Hant)**; the interface language can follow the system or be switched manually. Thanks @anomixer (#2935)
|
||||||
|
|
||||||
|
## 🤖 New Models
|
||||||
|
|
||||||
|
- Added support for **claude-sonnet-5** and **claude-fable-5**
|
||||||
|
- Added support for **doubao-seed-2-1-pro** and **doubao-seed-2-1-turbo**
|
||||||
|
|
||||||
|
Docs: [Models](https://docs.cowagent.ai/models)
|
||||||
|
|
||||||
|
## 🔒 Security Hardening
|
||||||
|
|
||||||
|
- **Sensitive file read protection**: hardened access to credential and other sensitive files to prevent bypass reads. Thanks @fengyl07 (#2913)
|
||||||
|
- **Browser access protection**: blocks browser requests targeting internal network and cloud server internal endpoints, reducing the risk of being tricked into reaching internal services. Thanks @christop
|
||||||
|
- **Safer config parsing**: config content is parsed in a safer way to avoid potential code execution risks. Thanks @shunfeng8421
|
||||||
|
|
||||||
|
## 🛠 Improvements & Fixes
|
||||||
|
|
||||||
|
- **Custom provider support**: embedding and vision models can now use custom providers; also fixed a memory query issue on Windows. Thanks @HnBigVolibear
|
||||||
|
- **More reliable file editing**: better preserves original indentation, and fuzzy matching no longer touches unrelated content. Thanks @xiaweiwei67-stack (#2942)
|
||||||
|
- **Command output encoding fix**: fixed garbled Chinese characters when a command produces large output. Thanks @xiaweiwei67-stack (#2941)
|
||||||
|
- **Azure OpenAI fixes**: fixed streaming output and related configuration issues for Azure OpenAI. Thanks @Eric L
|
||||||
|
- **WeCom Smart Bot**: added channel docs for the webhook (callback) mode. Thanks @6vision
|
||||||
|
- **Deep Dream toggle**: added a dedicated `deep_dream_enabled` switch to enable or disable Deep Dream distillation independently.
|
||||||
|
- **Stability**: improved connection recycling in the Web service and fixed several Self-Evolution issues (#2924, #2904)
|
||||||
|
|
||||||
|
## 📦 How to Upgrade
|
||||||
|
|
||||||
|
- **Desktop client**: get the latest version from the [download page](https://cowagent.ai/download/).
|
||||||
|
- **Source deployment**: run `cow update` for a one-click upgrade, or pull the latest code and restart. See the [upgrade guide](https://docs.cowagent.ai/guide/upgrade).
|
||||||
|
|
||||||
|
**Release date**: 2026.07.08 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.2...2.1.3)
|
||||||
@@ -22,7 +22,7 @@ If the current provider fails, the tool automatically tries the next one until i
|
|||||||
| Qwen (DashScope) | Main model | e.g. qwen3.7-plus, etc. |
|
| Qwen (DashScope) | Main model | e.g. qwen3.7-plus, etc. |
|
||||||
| Claude | Main model | Anthropic native image format |
|
| Claude | Main model | Anthropic native image format |
|
||||||
| Gemini | Main model | inlineData format |
|
| Gemini | Main model | inlineData format |
|
||||||
| Doubao | Main model | doubao-seed-2-0 series natively supported |
|
| Doubao | Main model | doubao-seed-2-1 series natively supported |
|
||||||
| Kimi (Moonshot) | Main model | kimi-k2.6, kimi-k2.5 natively supported |
|
| Kimi (Moonshot) | Main model | kimi-k2.6, kimi-k2.5 natively supported |
|
||||||
| ERNIE | Main model | Defaults to the multimodal main model (e.g. `ernie-5.1`); falls back to `ernie-4.5-turbo-vl` when the main model is not multimodal |
|
| ERNIE | Main model | Defaults to the multimodal main model (e.g. `ernie-5.1`); falls back to `ernie-4.5-turbo-vl` when the main model is not multimodal |
|
||||||
| ZhipuAI | glm-5v-turbo | Always uses the dedicated vision model |
|
| ZhipuAI | glm-5v-turbo | Always uses the dedicated vision model |
|
||||||
|
|||||||
291
docs/zh/README-Hant.md
Normal file
291
docs/zh/README-Hant.md
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<p align="center"><img src= "https://github.com/user-attachments/assets/eca9a9ec-8534-4615-9e0f-96c5ac1d10a3" alt="CowAgent" width="420" /></p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/zhayujie/CowAgent/releases/latest"><img src="https://img.shields.io/github/v/release/zhayujie/CowAgent?cacheSeconds=3600" alt="Latest release"></a>
|
||||||
|
<a href="https://github.com/zhayujie/CowAgent/blob/master/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green.svg" alt="License: MIT"></a>
|
||||||
|
<a href="https://github.com/zhayujie/CowAgent"><img src="https://img.shields.io/github/stars/zhayujie/CowAgent?style=flat-square&cacheSeconds=3600" alt="Stars"></a>
|
||||||
|
<a href="https://docs.cowagent.ai/zh"><img src="https://img.shields.io/badge/%E6%96%87%E6%A1%A3-cowagent.ai-blue?style=flat&logo=readthedocs&logoColor=white" alt="文件"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://trendshift.io/repositories/25763" target="_blank"><img src="https://trendshift.io/api/badge/repositories/25763" alt="zhayujie%2FCowAgent | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
[<a href="../../README.md">English</a>] | [<a href="README.md">中文</a>] | [繁體中文] | [<a href="../ja/README.md">日本語</a>]
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**CowAgent** 是一個開源的超級 AI 助理,能夠主動思考和規劃任務、操作電腦和外部資源、創造和執行 Skills、構建知識庫與長期記憶、透過自主進化與你一同成長,是 Agent Harness 工程的最佳實踐之一。
|
||||||
|
|
||||||
|
CowAgent 輕量、易部署、可擴充,自由接入主流大模型,覆蓋微信、飛書、釘釘、企微、QQ、Telegram、Slack、網頁等多渠道,7×24 執行於個人電腦或伺服器中。
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://cowagent.ai/?lang=zh">🌐 官網</a> ·
|
||||||
|
<a href="https://docs.cowagent.ai/zh/">📖 文件中心</a> ·
|
||||||
|
<a href="https://docs.cowagent.ai/zh/guide/quick-start">🚀 快速開始</a> ·
|
||||||
|
<a href="https://skills.cowagent.ai/">🧩 技能廣場</a> ·
|
||||||
|
<a href="https://cowagent.ai/zh/download/">💻 下載客戶端</a> ·
|
||||||
|
<a href="https://link-ai.tech/cowagent/create">☁️ 線上體驗</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## 🌟 核心能力
|
||||||
|
|
||||||
|
| 能力 | 說明 |
|
||||||
|
| :--- | :--- |
|
||||||
|
| [任務規劃](https://docs.cowagent.ai/zh/intro/architecture) | 理解複雜任務並自主分解執行,迴圈呼叫工具直到完成目標 |
|
||||||
|
| [長期記憶](https://docs.cowagent.ai/zh/memory) | 三層記憶架構(上下文 → 天級 → 核心),夢境蒸餾自動整理,支援關鍵詞與向量混合檢索 |
|
||||||
|
| [知識庫](https://docs.cowagent.ai/zh/knowledge) | 自動整理結構化知識為 Markdown Wiki,構建持續增長的知識圖譜,視覺化瀏覽 |
|
||||||
|
| [自主進化](https://docs.cowagent.ai/zh/memory/self-evolution) | 自動覆盤對話,最佳化技能、處理未完成事項、沉澱記憶與知識,在使用中持續成長 |
|
||||||
|
| [技能](https://docs.cowagent.ai/zh/skills) | 從 [Skill Hub](https://skills.cowagent.ai/)、GitHub、ClawHub 等一鍵安裝;也可透過對話創造自定義技能 |
|
||||||
|
| [工具](https://docs.cowagent.ai/zh/tools) | 內建檔案讀寫、終端、瀏覽器、定時任務、記憶檢索、聯網搜尋等 10+ 工具,支援 MCP 協議 |
|
||||||
|
| [通道](https://docs.cowagent.ai/zh/channels) | 一個 Agent 同時接入 Web、微信、飛書、釘釘、企微、QQ、公眾號、Telegram、Slack 等多個渠道 |
|
||||||
|
| 多模態 | 文字、圖片、語音、檔案全訊息型別支援,覆蓋識別、生成、收發 |
|
||||||
|
| [模型](https://docs.cowagent.ai/zh/models) | DeepSeek、Claude、Gemini、GPT、GLM、Qwen、Kimi、MiniMax、Doubao 等主流廠商,設定一行切換 |
|
||||||
|
| [部署](https://docs.cowagent.ai/zh/guide/quick-start) | 一鍵指令碼安裝,Web 控制台統一管理;本地、Docker、伺服器多種部署方式 |
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## 🏗️ 架構總覽
|
||||||
|
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/architecture/zh/architecture.jpg" alt="CowAgent Architecture" width="750"/>
|
||||||
|
|
||||||
|
CowAgent 是一個完整的 **Agent Harness**:訊息從各類**通道**進入,**Agent Core** 結合記憶、知識庫與可用工具/技能進行任務規劃與決策,呼叫**模型**生成結果,再回傳至原通道。各模組解耦清晰,按需擴充。
|
||||||
|
|
||||||
|
詳見 [專案架構](https://docs.cowagent.ai/zh/intro/architecture)。
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## 🚀 快速開始
|
||||||
|
|
||||||
|
專案提供一鍵安裝指令碼,自動完成依賴安裝、設定和啟動:
|
||||||
|
|
||||||
|
**Linux / macOS:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows(PowerShell):**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
啟動成功後開啟 `http://localhost:9899` 進入 **Web 控制台**,在控制台內即可完成模型設定、渠道接入、技能安裝等全部操作。
|
||||||
|
|
||||||
|
> 伺服器部署且需要從外部網路存取控制台時,請在 `config.json` 中將 `web_host` 設為 `0.0.0.0`(同時強烈建議設定 `web_password` 啟用身分驗證),然後訪問 `http://<server-ip>:9899`,並確保防火牆/安全組放行 `9899` 埠。
|
||||||
|
|
||||||
|
> 📖 詳細安裝指南:[快速開始](https://docs.cowagent.ai/zh/guide/quick-start) · [原始碼安裝](https://docs.cowagent.ai/zh/guide/manual-install) · [升級](https://docs.cowagent.ai/zh/guide/upgrade)
|
||||||
|
|
||||||
|
安裝後可使用 `cow` [CLI 命令](https://docs.cowagent.ai/zh/cli) 管理服務:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cow start | stop | restart # 服務管理
|
||||||
|
cow status | logs # 狀態和日誌
|
||||||
|
cow update # 拉取最新程式碼並重啟
|
||||||
|
cow skill install <名稱> # 安裝技能
|
||||||
|
cow install-browser # 安裝瀏覽器工具
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💻 桌面客戶端:前往 **[下載 CowAgent 桌面客戶端](https://cowagent.ai/zh/download/)**(macOS / Windows),內建 Agent 執行環境,開箱即用。
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## 🤖 模型支援
|
||||||
|
|
||||||
|
CowAgent 支援國內外主流廠商的大語言模型。**文字對話、影像理解、影像生成、語音識別/合成、向量** 等能力均可獨立設定廠商。
|
||||||
|
|
||||||
|
| 廠商 | 代表模型 | 文字 | 影像理解 | 影像生成 | 語音識別 | 語音合成 | 向量 |
|
||||||
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
|
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
|
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
|
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
||||||
|
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
|
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| [智譜 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
|
| [通義千問](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
|
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
|
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
|
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
|
| [LinkAI](https://docs.cowagent.ai/zh/models/linkai) | 一個 Key 接入 100+ 模型 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| [自定義](https://docs.cowagent.ai/zh/models/custom) | 本地模型 / 三方代理 | ✅ | | | | | |
|
||||||
|
|
||||||
|
> 推薦透過 Web 控制台線上設定,無需手動編輯檔案。手動設定請參考各廠商文件,詳見 [模型概覽](https://docs.cowagent.ai/zh/models)。
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## 💬 通道接入
|
||||||
|
|
||||||
|
一個 Agent 例項可同時接入多個渠道,啟動時透過 `channel_type` 切換或並行執行。
|
||||||
|
|
||||||
|
| 通道 | 文字 | 圖片 | 檔案 | 語音 | 群聊 |
|
||||||
|
| --- | :-: | :-: | :-: | :-: | :-: |
|
||||||
|
| [Web 控制台](https://docs.cowagent.ai/zh/channels/web)(預設) | ✅ | ✅ | ✅ | ✅ | |
|
||||||
|
| [微信](https://docs.cowagent.ai/zh/channels/weixin) | ✅ | ✅ | ✅ | ✅ | |
|
||||||
|
| [飛書](https://docs.cowagent.ai/zh/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| [釘釘](https://docs.cowagent.ai/zh/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| [企微智慧機器人](https://docs.cowagent.ai/zh/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| [QQ](https://docs.cowagent.ai/zh/channels/qq) | ✅ | ✅ | ✅ | | ✅ |
|
||||||
|
| [企業微信應用](https://docs.cowagent.ai/zh/channels/wecom) | ✅ | ✅ | ✅ | ✅ | |
|
||||||
|
| [微信客服](https://docs.cowagent.ai/zh/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | |
|
||||||
|
| [微信公眾號](https://docs.cowagent.ai/zh/channels/wechatmp) | ✅ | ✅ | | ✅ | |
|
||||||
|
| [Telegram](https://docs.cowagent.ai/zh/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| [Slack](https://docs.cowagent.ai/zh/channels/slack) | ✅ | ✅ | ✅ | | ✅ |
|
||||||
|
| [Discord](https://docs.cowagent.ai/zh/channels/discord) | ✅ | ✅ | ✅ | | ✅ |
|
||||||
|
|
||||||
|
> 飛書、企微智慧機器人支援在 Web 控制台內**掃碼一鍵接入**,無需公有 IP。詳見 [通道概覽](https://docs.cowagent.ai/zh/channels)。
|
||||||
|
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-chat.png" alt="CowAgent Web 控制台" width="800"/>
|
||||||
|
|
||||||
|
*Web 控制台是預設通道,也是統一的 Agent 設定和管理入口*
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## 🧠 記憶與知識庫
|
||||||
|
|
||||||
|
**長期記憶**採用三層架構:對話上下文(短期)→ 天級記憶(中期)→ MEMORY.md(長期)。每日自動執行**夢境蒸餾(Deep Dream)**,將分散記憶整合為精煉的長期記憶並生成敘事日記。詳見 [長期記憶](https://docs.cowagent.ai/zh/memory) · [夢境蒸餾](https://docs.cowagent.ai/zh/memory/deep-dream)。
|
||||||
|
|
||||||
|
**個人知識庫** 與按時間記錄的記憶不同,以**主題為維度**組織結構化知識。Agent 在對話中自動整理有價值資訊,維護交叉引用與索引,Web 控制台可視覺化瀏覽知識圖譜。詳見 [個人知識庫](https://docs.cowagent.ai/zh/knowledge)。
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="50%">
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-memory.png" alt="長期記憶" />
|
||||||
|
<p align="center"><em>長期記憶 · 三層記憶 + 夢境蒸餾</em></p>
|
||||||
|
</td>
|
||||||
|
<td width="50%">
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/web-console-knowledge.png" alt="個人知識庫" />
|
||||||
|
<p align="center"><em>個人知識庫 · 自動整理的 Markdown Wiki</em></p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
|
||||||
|
## 🔧 工具與技能
|
||||||
|
|
||||||
|
**工具(Tools)** 是 Agent 作業系統資源的原子能力,**技能(Skills)** 是基於說明檔案的高階工作流,可組合多個工具完成複雜任務。
|
||||||
|
|
||||||
|
### 工具系統
|
||||||
|
|
||||||
|
**內建工具** 涵蓋檔案讀寫(`read` / `write` / `edit` / `ls`)、終端(`bash`)、檔案傳送(`send`)、記憶檢索(`memory`)、環境變數(`env_config`)、網頁獲取(`web_fetch`)、定時任務(`scheduler`)、聯網搜尋(`web_search`)、影像識別(`vision`)、瀏覽器自動化(`browser`)等常用能力。
|
||||||
|
|
||||||
|
**MCP 協議** 透過 [Model Context Protocol](https://modelcontextprotocol.io) 接入開放生態中的各種 MCP 服務,設定一次 `mcp.json` 即用即得,支援 stdio / SSE 協議、熱更新、零程式碼接入。
|
||||||
|
|
||||||
|
詳見 [工具概覽](https://docs.cowagent.ai/zh/tools) · [MCP 整合](https://docs.cowagent.ai/zh/tools/mcp)。
|
||||||
|
|
||||||
|
### 技能系統
|
||||||
|
|
||||||
|
- **[Skill Hub](https://skills.cowagent.ai/)** — 開源的技能廣場,瀏覽、搜尋、一鍵安裝
|
||||||
|
- **GitHub / ClawHub / URL 等** — 任意來源一鍵安裝
|
||||||
|
- **對話創造** — 透過 `skill-creator` 用對話快速生成自定義技能,可將工作流程或第三方介面直接固化為技能
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/skill list # 檢視當前技能
|
||||||
|
/skill search <關鍵詞> # 在技能廣場搜尋
|
||||||
|
/skill install <名稱> # 一鍵安裝
|
||||||
|
```
|
||||||
|
|
||||||
|
詳見 [技能概覽](https://docs.cowagent.ai/zh/skills) · [建立技能](https://docs.cowagent.ai/zh/skills/create)。
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## 🏷 更新日誌
|
||||||
|
|
||||||
|
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客戶端](https://cowagent.ai/zh/download/)正式發布(macOS / Windows)、知識庫文件管理增強、MCP 工具智能檢索、繁體中文支援、新模型接入
|
||||||
|
|
||||||
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升級(定時任務管理、知識庫分類、多模型自定義廠商)、自主進化最佳化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和體驗最佳化
|
||||||
|
|
||||||
|
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自進化能力、Web 控制台升級(訊息管理、多會話並行)、新模型接入(MiniMax-M3、qwen3.7-plus)、Python 3.13 支援
|
||||||
|
|
||||||
|
> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — 國際化支援、新增通道(Telegram、Discord、Slack、微信客服)、命令列互動升級、一鍵安裝指令碼最佳化、MCP Streamable HTTP 支援、新模型接入(claude-opus-4-8、MiMo)
|
||||||
|
|
||||||
|
> **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — 模型管理、MCP 協議支援、瀏覽器登入態持久化、新模型接入(gpt-5.5、gemini-3.5-flash、qwen3.7-max)、部署安全加固
|
||||||
|
|
||||||
|
> **2026.05.06:** [v2.0.8](https://github.com/zhayujie/CowAgent/releases/tag/2.0.8) — 飛書渠道全面升級(語音、流式輸出、掃碼接入)、新模型支援(DeepSeek V4、百度千帆)、定時任務工具增強
|
||||||
|
|
||||||
|
> **2026.04.22:** [v2.0.7](https://github.com/zhayujie/CowAgent/releases/tag/2.0.7) — 影像生成內建技能(GPT Image 2、Nano Banana)、新模型支援(Kimi K2.6、Claude Opus 4.7、GLM 5.1)、知識庫和記憶增強
|
||||||
|
|
||||||
|
> **2026.04.14:** [v2.0.6](https://github.com/zhayujie/CowAgent/releases/tag/2.0.6) — 知識庫系統、夢境記憶模組、上下文智慧壓縮、Web 控制台多會話
|
||||||
|
|
||||||
|
> **2026.04.01:** [v2.0.5](https://github.com/zhayujie/CowAgent/releases/tag/2.0.5) — Cow CLI 命令系統、Skill Hub 開源、瀏覽器工具、企微掃碼建立
|
||||||
|
|
||||||
|
> **2026.03.22:** [v2.0.4](https://github.com/zhayujie/CowAgent/releases/tag/2.0.4) — 新增個人微信通道,支援文字/圖片/檔案/語音訊息
|
||||||
|
|
||||||
|
> **2026.02.03:** [v2.0.0](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) — 正式升級為超級 Agent 助理,支援多輪任務決策、長期記憶、Skills 框架
|
||||||
|
|
||||||
|
完整更新歷史:[Release Notes](https://docs.cowagent.ai/zh/releases)
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## 🤝 社群與支援
|
||||||
|
|
||||||
|
掃碼加入微信開源交流群:
|
||||||
|
|
||||||
|
<img width="130" src="https://img-1317903499.cos.ap-guangzhou.myqcloud.com/docs/open-community.png">
|
||||||
|
|
||||||
|
也可透過以下方式獲取支援:
|
||||||
|
|
||||||
|
- 🐛 [提交 Issue](https://github.com/zhayujie/CowAgent/issues)
|
||||||
|
- 🤖 線上 AI 助手:[專案小助手](https://link-ai.tech/app/Kv2fXJcH)(基於專案知識庫)
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## 🔗 相關專案
|
||||||
|
|
||||||
|
- **[Cow Skill Hub](https://github.com/zhayujie/cow-skill-hub)** — 開源的 AI Agent 技能廣場,支援 CowAgent、OpenClaw、Claude Code 等多種 Agent
|
||||||
|
- **[bot-on-anything](https://github.com/zhayujie/bot-on-anything)** — 輕量大模型應用框架,支援 Slack、Telegram、Discord、Gmail 等海外平臺
|
||||||
|
- **[AgentMesh](https://github.com/MinimalFuture/AgentMesh)** — 開源多智慧體(Multi-Agent)框架,透過團隊協同解決複雜問題
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## 🏢 企業服務
|
||||||
|
|
||||||
|
<a href="https://link-ai.tech" target="_blank"><img width="650" src="https://cdn.link-ai.tech/image/link-ai-intro.jpg"></a>
|
||||||
|
|
||||||
|
> [LinkAI](https://link-ai.tech/) 是面向企業和個人的一站式 AI 智慧體平臺,為 CowAgent 提供雲端託管和企業級支援:
|
||||||
|
>
|
||||||
|
> - **🚀 免部署線上執行**:無需伺服器即可建立 [CowAgent 線上助理](https://link-ai.tech/cowagent/create),1 分鐘擁有專屬 Agent
|
||||||
|
> - **🧠 Agent 基礎設施**:聚合主流大模型、知識庫、資料庫、技能、工作流,提供開箱即用的 Agent 能力擴充
|
||||||
|
> - **🏢 企業級協作**:提供團隊協作、許可權分級、審計日誌、私有化部署等能力,讓 Agent 安全落地企業場景
|
||||||
|
|
||||||
|
**產品諮詢和企業服務** 可聯絡產品客服:
|
||||||
|
|
||||||
|
<img width="130" src="https://cdn.link-ai.tech/portal/linkai-customer-service.png">
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## 🛠️ 開發與貢獻
|
||||||
|
|
||||||
|
歡迎各種形式的貢獻:新功能、Bug 修復、效能最佳化、文件完善,或向 [Skill Hub](https://skills.cowagent.ai/submit) 分享你的技能。請先閱讀 [CONTRIBUTING.md](/CONTRIBUTING.md) 瞭解如何開始,然後提交 Issue 討論或直接發起 PR。
|
||||||
|
|
||||||
|
歡迎 ⭐ Star 支援專案,並透過 Watch → Custom → Releases 訂閱新版本通知。也歡迎提交 PR、Issue 進行反饋。
|
||||||
|
|
||||||
|
## 🌟 貢獻者
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## ⚠️ 宣告
|
||||||
|
|
||||||
|
1. 本專案遵循 [MIT 開源協議](/LICENSE),主要用於技術研究和學習。使用時請遵守所在地法律法規及相關政策,因使用本專案所產生的一切後果由使用者自行承擔。
|
||||||
|
2. **成本與安全:** Agent 模式 Token 消耗顯著高於普通對話,請根據效果與成本權衡選擇模型;Agent 具備訪問本地作業系統的能力,請謹慎選擇部署環境。
|
||||||
|
3. CowAgent 專案專注於開源技術開發,不會參與、授權或發行任何加密貨幣。
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
## 📌 專案更名說明
|
||||||
|
|
||||||
|
本專案原名 `chatgpt-on-wechat`,於 2026.04.13 正式更名為 **CowAgent**。原 GitHub 地址已自動重定向,老使用者可選擇執行 `git remote set-url origin https://github.com/zhayujie/CowAgent.git` 更新本地遠端地址。
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
[<a href="../../README.md">English</a>] | [中文] | [<a href="../ja/README.md">日本語</a>]
|
[<a href="../../README.md">English</a>] | [中文] | [<a href="README-Hant.md">繁體中文</a>] | [<a href="../ja/README.md">日本語</a>]
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆、通过自主进化与你一同成长,是 Agent Harness 工程的最佳实践之一。
|
**CowAgent** 是一个开源的超级 AI 助理,能够主动思考和规划任务、操作计算机和外部资源、创造和执行 Skills、构建知识库与长期记忆、通过自主进化与你一同成长,是 Agent Harness 工程的最佳实践之一。
|
||||||
@@ -24,6 +24,7 @@ CowAgent 轻量、易部署、可扩展,自由接入主流大模型,覆盖
|
|||||||
<a href="https://docs.cowagent.ai/zh/">📖 文档中心</a> ·
|
<a href="https://docs.cowagent.ai/zh/">📖 文档中心</a> ·
|
||||||
<a href="https://docs.cowagent.ai/zh/guide/quick-start">🚀 快速开始</a> ·
|
<a href="https://docs.cowagent.ai/zh/guide/quick-start">🚀 快速开始</a> ·
|
||||||
<a href="https://skills.cowagent.ai/">🧩 技能广场</a> ·
|
<a href="https://skills.cowagent.ai/">🧩 技能广场</a> ·
|
||||||
|
<a href="https://cowagent.ai/zh/download/">💻 下载客户端</a> ·
|
||||||
<a href="https://link-ai.tech/cowagent/create">☁️ 在线体验</a>
|
<a href="https://link-ai.tech/cowagent/create">☁️ 在线体验</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -95,6 +96,8 @@ cow skill install <名称> # 安装技能
|
|||||||
cow install-browser # 安装浏览器工具
|
cow install-browser # 安装浏览器工具
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 💻 桌面客户端:前往 **[下载 CowAgent 桌面客户端](https://cowagent.ai/zh/download/)**(macOS / Windows),内置 Agent 运行环境,开箱即用。
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
|
|
||||||
## 🤖 模型支持
|
## 🤖 模型支持
|
||||||
@@ -105,12 +108,12 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](https://docs.cowagent.ai/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](https://docs.cowagent.ai/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
| [Claude](https://docs.cowagent.ai/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](https://docs.cowagent.ai/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](https://docs.cowagent.ai/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 GLM](https://docs.cowagent.ai/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通义千问](https://docs.cowagent.ai/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](https://docs.cowagent.ai/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](https://docs.cowagent.ai/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [百度ERNIE](https://docs.cowagent.ai/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](https://docs.cowagent.ai/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
@@ -200,6 +203,8 @@ CowAgent 支持国内外主流厂商的大语言模型。**文本对话、图像
|
|||||||
|
|
||||||
## 🏷 更新日志
|
## 🏷 更新日志
|
||||||
|
|
||||||
|
> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [桌面客户端](https://cowagent.ai/zh/download/)正式发布(macOS / Windows)、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入
|
||||||
|
|
||||||
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升级(定时任务管理、知识库分类、多模型自定义厂商)、自主进化优化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和体验优化
|
> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web 控制台升级(定时任务管理、知识库分类、多模型自定义厂商)、自主进化优化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和体验优化
|
||||||
|
|
||||||
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自进化能力、Web 控制台升级(消息管理、多会话并行)、新模型接入(MiniMax-M3、qwen3.7-plus)、Python 3.13 支持
|
> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — 自进化能力、Web 控制台升级(消息管理、多会话并行)、新模型接入(MiniMax-M3、qwen3.7-plus)、Python 3.13 支持
|
||||||
|
|||||||
35
docs/zh/guide/desktop.mdx
Normal file
35
docs/zh/guide/desktop.mdx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
title: 桌面客户端
|
||||||
|
description: 下载并使用 CowAgent 桌面客户端(macOS / Windows)
|
||||||
|
---
|
||||||
|
|
||||||
|
CowAgent 提供开箱即用的桌面客户端,内置 Agent 运行环境,**无需手动安装 Python 或依赖**,下载安装后即可在本地运行你的超级 AI 助理。
|
||||||
|
|
||||||
|
## 下载安装
|
||||||
|
|
||||||
|
<Card title="前往下载页" icon="download" href="https://cowagent.ai/zh/download/">
|
||||||
|
下载 macOS / Windows 客户端安装包
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab title="macOS">
|
||||||
|
1. 打开 [下载页](https://cowagent.ai/zh/download/),根据芯片选择安装包:
|
||||||
|
- Apple Silicon(M1/M2/M3/M4):下载 `arm64` 版本
|
||||||
|
- Intel 芯片:下载 `x64` 版本
|
||||||
|
2. 打开下载的 `.dmg`,将 CowAgent 拖入「应用程序」文件夹。
|
||||||
|
3. 从启动台或应用程序中打开 CowAgent 即可。
|
||||||
|
</Tab>
|
||||||
|
<Tab title="Windows">
|
||||||
|
1. 打开 [下载页](https://cowagent.ai/zh/download/),下载 Windows 安装包(`.exe`)。
|
||||||
|
2. 双击运行安装程序,按提示完成安装(可自定义安装目录)。
|
||||||
|
3. 从桌面或开始菜单打开 CowAgent 即可。
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
## 自动更新
|
||||||
|
|
||||||
|
桌面客户端内置自动更新能力。有新版本时会自动检测并提示,你也可以在应用左下角菜单中手动「检查更新」,一键下载并重启完成升级。
|
||||||
|
|
||||||
|
## 与命令行部署的区别
|
||||||
|
|
||||||
|
- **桌面客户端**:适合个人在本地电脑使用,开箱即用,图形界面操作,自动更新。
|
||||||
|
- **命令行部署**:适合开发者或服务器长期运行,可定制性更强,详见 [一键安装](/zh/guide/quick-start)。
|
||||||
@@ -71,6 +71,8 @@ CowAgent 支持灵活切换多种模型,能处理文本、语音、图片、
|
|||||||
|
|
||||||
运行后默认会启动 Web 控制台,通过访问 `http://localhost:9899` 可以在网页端进行对话、配置、应用通道接入等操作。
|
运行后默认会启动 Web 控制台,通过访问 `http://localhost:9899` 可以在网页端进行对话、配置、应用通道接入等操作。
|
||||||
|
|
||||||
|
也可以使用桌面客户端(macOS / Windows),内置 Agent 运行环境,[下载安装](https://cowagent.ai/zh/download/)后开箱即用。
|
||||||
|
|
||||||
<CardGroup cols={2}>
|
<CardGroup cols={2}>
|
||||||
<Card title="快速开始" icon="rocket" href="/zh/guide/quick-start">
|
<Card title="快速开始" icon="rocket" href="/zh/guide/quick-start">
|
||||||
查看完整的安装和运行指南
|
查看完整的安装和运行指南
|
||||||
|
|||||||
@@ -83,6 +83,22 @@ Deep Dream 遵循以下整理规则:
|
|||||||
首次部署后可以手动执行一次 `/memory dream 30`,将历史天级记忆全量蒸馏到 MEMORY.md。
|
首次部署后可以手动执行一次 `/memory dream 30`,将历史天级记忆全量蒸馏到 MEMORY.md。
|
||||||
</Tip>
|
</Tip>
|
||||||
|
|
||||||
|
## 配置开关
|
||||||
|
|
||||||
|
通过 `config.json` 中的 `deep_dream_enabled` 控制每晚的自动蒸馏:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deep_dream_enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 配置项 | 说明 | 默认值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `deep_dream_enabled` | 是否启用每日定时蒸馏 | `true` |
|
||||||
|
|
||||||
|
默认开启,保持与现有行为一致。关闭后每晚不再自动执行蒸馏,适合希望手动维护 MEMORY.md 或减少一次 LLM 调用的场景(每日记忆总结不受影响)。手动命令 `/memory dream` 也不受此开关影响,仍可随时触发。
|
||||||
|
|
||||||
## 安全机制
|
## 安全机制
|
||||||
|
|
||||||
| 机制 | 说明 |
|
| 机制 | 说明 |
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "claude-fable-5",
|
"model": "claude-sonnet-5",
|
||||||
"claude_api_key": "YOUR_API_KEY"
|
"claude_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | 支持 `claude-fable-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
| `model` | 支持 `claude-sonnet-5`、`claude-opus-4-8`、`claude-opus-4-7`、`claude-sonnet-4-6`、`claude-opus-4-6`、`claude-sonnet-4-5`、`claude-sonnet-4-0`、`claude-3-5-sonnet-latest` 等,参考 [官方模型](https://docs.anthropic.com/en/docs/about-claude/models/overview) |
|
||||||
| `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 |
|
| `claude_api_key` | 在 [Claude 控制台](https://console.anthropic.com/settings/keys) 创建 |
|
||||||
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
|
| `claude_api_base` | 可选,默认为 `https://api.anthropic.com/v1`,可改为第三方代理 |
|
||||||
|
|
||||||
@@ -28,8 +28,8 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
|
|||||||
|
|
||||||
| 模型 | 适用场景 |
|
| 模型 | 适用场景 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `claude-fable-5` | 最新旗舰,复杂推理与长链路任务效果最佳,价格较高 |
|
| `claude-sonnet-5` | 最新旗舰,默认推荐模型,推理效果与成本均衡最佳 |
|
||||||
| `claude-opus-4-8` | 上一代 Opus 旗舰,综合表现与成本均衡 |
|
| `claude-opus-4-8` | 上一代 Opus 旗舰,推理能力最强,价格较高 |
|
||||||
| `claude-opus-4-7` | 更早的 Opus 旗舰 |
|
| `claude-opus-4-7` | 更早的 Opus 旗舰 |
|
||||||
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |
|
| `claude-sonnet-4-6` | 性价比与速度平衡,成本更低 |
|
||||||
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | 更早的旗舰,价格更低 |
|
| `claude-opus-4-6` / `claude-sonnet-4-5` / `claude-sonnet-4-0` | 更早的旗舰,价格更低 |
|
||||||
@@ -44,7 +44,7 @@ Claude 由 Anthropic 提供,支持文本对话与图像理解,主流 Sonnet
|
|||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"vision": {
|
"vision": {
|
||||||
"model": "claude-sonnet-4-6"
|
"model": "claude-sonnet-5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,20 +13,20 @@ description: 豆包(火山方舟)模型配置(文本 / 图像理解 / 图
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model": "doubao-seed-2-0-pro-260215",
|
"model": "doubao-seed-2-1-pro-260628",
|
||||||
"ark_api_key": "YOUR_API_KEY"
|
"ark_api_key": "YOUR_API_KEY"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| 参数 | 说明 |
|
| 参数 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `model` | 可填 `doubao-seed-2-0-pro-260215`、`doubao-seed-2-0-code-preview-260215`、`doubao-seed-2-0-lite-260215` 等 |
|
| `model` | 可填 `doubao-seed-2-1-pro-260628`、`doubao-seed-2-1-turbo-260628`、`doubao-seed-2-0-pro-260215`、`doubao-seed-2-0-code-preview-260215` 等 |
|
||||||
| `ark_api_key` | 在 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) 创建 |
|
| `ark_api_key` | 在 [火山方舟控制台](https://console.volcengine.com/ark/region:ark+cn-beijing/apikey) 创建 |
|
||||||
| `ark_base_url` | 可选,默认为 `https://ark.cn-beijing.volces.com/api/v3` |
|
| `ark_base_url` | 可选,默认为 `https://ark.cn-beijing.volces.com/api/v3` |
|
||||||
|
|
||||||
## 图像理解
|
## 图像理解
|
||||||
|
|
||||||
配置 `ark_api_key` 后 Agent 的 Vision 工具会自动使用 `doubao-seed-2-0-pro-260215` 识别图像,无需额外配置。
|
配置 `ark_api_key` 后,若主模型为豆包系列,Agent 的 Vision 工具会自动使用当前主模型识别图像,无需额外配置。
|
||||||
|
|
||||||
如需手动指定 Vision 模型:
|
如需手动指定 Vision 模型:
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ description: 豆包(火山方舟)模型配置(文本 / 图像理解 / 图
|
|||||||
{
|
{
|
||||||
"tools": {
|
"tools": {
|
||||||
"vision": {
|
"vision": {
|
||||||
"model": "doubao-seed-2-0-pro-260215"
|
"model": "doubao-seed-2-1-pro-260628"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,12 +14,12 @@ CowAgent 支持国内外主流厂商的大语言模型,模型接口实现在
|
|||||||
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: |
|
||||||
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
| [DeepSeek](/zh/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | |
|
||||||
| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
| [MiniMax](/zh/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | |
|
||||||
| [Claude](/zh/models/claude) | claude-fable-5 | ✅ | ✅ | | | | |
|
| [Claude](/zh/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | |
|
||||||
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
| [Gemini](/zh/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | |
|
||||||
| [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [OpenAI](/zh/models/openai) | gpt-5.5、o 系列 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
| [智谱 GLM](/zh/models/glm) | glm-5.2、glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ |
|
||||||
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| [通义千问](/zh/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.0 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
| [豆包 Doubao](/zh/models/doubao) | doubao-seed-2.1 系列 | ✅ | ✅ | ✅ | | | ✅ |
|
||||||
| [Kimi](/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
| [Kimi](/zh/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | |
|
||||||
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
| [百度千帆](/zh/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | |
|
||||||
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
| [小米 MiMo](/zh/models/mimo) | mimo-v2.5-pro / v2.5 | ✅ | ✅ | | | ✅ | |
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ description: 通过 LinkAI 平台统一接入文本、视觉、图像、语音
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-0-pro-260215`、`kimi-k2.6`、`claude-sonnet-4-6`、`gemini-3.1-flash-lite-preview` 等。
|
可选模型:`gpt-4.1-mini`、`gpt-5.4-mini`、`qwen3.7-plus`、`doubao-seed-2-1-pro-260628`、`kimi-k2.6`、`claude-sonnet-5`、`gemini-3.1-flash-lite-preview` 等。
|
||||||
|
|
||||||
## 图像生成
|
## 图像生成
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description: CowAgent 版本更新历史
|
|||||||
|
|
||||||
| 版本 | 日期 | 说明 |
|
| 版本 | 日期 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| [2.1.3](/zh/releases/v2.1.3) | 2026.07.08 | 桌面客户端正式发布(macOS / Windows)、知识库文档管理增强、MCP 工具智能检索、繁体中文支持、新模型接入(claude-sonnet-5、doubao-seed-2.1 等)、安全加固与体验优化 |
|
||||||
| [2.1.2](/zh/releases/v2.1.2) | 2026.06.18 | Web 控制台升级(定时任务管理、知识库分类、多模型自定义厂商)、自主进化优化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和体验优化 |
|
| [2.1.2](/zh/releases/v2.1.2) | 2026.06.18 | Web 控制台升级(定时任务管理、知识库分类、多模型自定义厂商)、自主进化优化、新模型接入(kimi-k2.7-code、glm-5.2)、安全加固和体验优化 |
|
||||||
| [2.1.1](/zh/releases/v2.1.1) | 2026.06.09 | 自主进化能力、Web 控制台消息管理升级、新模型接入(MiniMax-M3、qwen3.7-plus 等)、其他多项优化和修复 |
|
| [2.1.1](/zh/releases/v2.1.1) | 2026.06.09 | 自主进化能力、Web 控制台消息管理升级、新模型接入(MiniMax-M3、qwen3.7-plus 等)、其他多项优化和修复 |
|
||||||
| [2.1.0](/zh/releases/v2.1.0) | 2026.06.01 | 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级(流式输出、命令模糊匹配、任务取消)、MCP Streamable HTTP、新模型接入 |
|
| [2.1.0](/zh/releases/v2.1.0) | 2026.06.01 | 国际化支持、新增 Telegram / Discord / Slack / 微信客服通道、命令行交互升级(流式输出、命令模糊匹配、任务取消)、MCP Streamable HTTP、新模型接入 |
|
||||||
|
|||||||
75
docs/zh/releases/v2.1.3.mdx
Normal file
75
docs/zh/releases/v2.1.3.mdx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
title: v2.1.3
|
||||||
|
description: CowAgent 2.1.3:正式推出桌面客户端(macOS / Windows),知识库文档管理增强,新增 MCP 工具按需检索,繁体中文支持,模型支持,并带来安全与体验的多项改进
|
||||||
|
---
|
||||||
|
|
||||||
|
🌐 [English](https://docs.cowagent.ai/releases/v2.1.3) | [中文](https://docs.cowagent.ai/zh/releases/v2.1.3)
|
||||||
|
|
||||||
|
## 🖥 桌面客户端
|
||||||
|
|
||||||
|
正式推出 **CowAgent 桌面客户端**,支持 **macOS 与 Windows**,让本地超级 AI 助理真正做到开箱即用。
|
||||||
|
|
||||||
|
下载地址:[CowAgent客户端](https://cowagent.ai/zh/download/)
|
||||||
|
|
||||||
|
<img src="https://cdn.jsdelivr.net/gh/zhayujie/cowagent-assets@main/screenshots/zh/desktop-chat-demo-zh.png" alt="CowAgent 桌面客户端" />
|
||||||
|
|
||||||
|
核心特性:
|
||||||
|
|
||||||
|
- **开箱即用**:内置完整的 Agent 运行环境,安装后直接启动,无需安装 Python 等依赖
|
||||||
|
- **完整对话体验**:流式对话、会话管理、工具调用步骤展示、Markdown 渲染,并支持发送与展示图片 / 视频 / 文件
|
||||||
|
- **可视化管理**:配置管理、模型配置、知识库、定时任务、技能系统、记忆等页面与 Web 控制台对齐,在原生界面即可管理
|
||||||
|
- **通道接入**:支持在客户端内扫码接入各类消息通道
|
||||||
|
- **自动更新**:支持版本自动检查与一键更新,优化不同地区的下载速度
|
||||||
|
- **原生体验**:首次启动引导、跟随系统语言、平台自适应的窗口交互
|
||||||
|
|
||||||
|
|
||||||
|
## 📚 知识库
|
||||||
|
|
||||||
|
- **文档创建与导入**:知识库支持在界面直接新建文档、导入外部文档。
|
||||||
|
- **索引自动维护**:根据真实目录树自动重建 `knowledge/index.md` 索引文件,避免索引漂移或丢失文档
|
||||||
|
- **向量模型修复**:知识库索引同步复用统一的嵌入厂商,确保生成向量而非退化为关键词检索
|
||||||
|
|
||||||
|
Thanks @yangziyu-hhh
|
||||||
|
|
||||||
|
相关文档:[知识库](https://docs.cowagent.ai/zh/memory/knowledge)
|
||||||
|
|
||||||
|
## 🔌 MCP 工具智能检索
|
||||||
|
|
||||||
|
针对接入大量 MCP 工具时上下文膨胀的问题,新增 **按需工具检索** 能力,通过 RAG 向量检索按需加载与当前任务相关的 MCP 工具,减少无关工具对上下文的占用。Thanks @fengyl07
|
||||||
|
|
||||||
|
相关文档:[MCP 工具](https://docs.cowagent.ai/zh/tools/mcp)
|
||||||
|
|
||||||
|
## 🌏 繁体中文支持
|
||||||
|
|
||||||
|
Web 控制台、日志与文档新增 **繁体中文(zh-Hant)** 支持,界面语言可跟随系统或手动切换。Thanks @anomixer (#2935)
|
||||||
|
|
||||||
|
## 🤖 模型新增
|
||||||
|
|
||||||
|
- 新增支持 **claude-sonnet-5**、**claude-fable-5**
|
||||||
|
- 新增支持 **doubao-seed-2-1-pro**、**doubao-seed-2-1-turbo**
|
||||||
|
|
||||||
|
相关文档:[模型概览](https://docs.cowagent.ai/zh/models)
|
||||||
|
|
||||||
|
|
||||||
|
## 🔒 安全加固
|
||||||
|
|
||||||
|
- **敏感文件读取防护**:加固对凭证等敏感文件的访问,防止绕过读取。Thanks @fengyl07 (#2913)
|
||||||
|
- **浏览器访问防护**:浏览器访问网页时拦截指向内网及云服务器内部地址的请求,降低被诱导访问内部服务的风险。Thanks @christop
|
||||||
|
- **配置解析加固**:使用更安全的方式解析配置内容,避免潜在的代码执行风险。Thanks @shunfeng8421
|
||||||
|
|
||||||
|
## 🛠 体验优化与修复
|
||||||
|
|
||||||
|
- **自定义供应商扩展**:嵌入与视觉模型支持配置自定义供应商;同时修复记忆查询在 Windows 下的问题。Thanks @HnBigVolibear
|
||||||
|
- **文件编辑更稳**:编辑文件时更好地保留原有缩进,且模糊匹配时不改动未涉及的内容。Thanks @xiaweiwei67-stack (#2942)
|
||||||
|
- **命令输出乱码修复**:修复执行命令产生大量输出时可能出现的中文乱码。Thanks @xiaweiwei67-stack (#2941)
|
||||||
|
- **Azure OpenAI 修复**:修复 Azure OpenAI 的流式输出与相关配置问题。Thanks @Eric L
|
||||||
|
- **企业微信智能机器人**:补充 webhook(回调)模式的接入文档。Thanks @6vision
|
||||||
|
- **深度梦境开关**:新增 `deep_dream_enabled` 配置开关,可按需开启或关闭深度梦境。
|
||||||
|
- **稳定性提升**:优化 Web 服务的连接回收,并修复自主进化过程中的若干问题 (#2924, #2904)
|
||||||
|
|
||||||
|
## 📦 升级方式
|
||||||
|
|
||||||
|
- **桌面客户端**:前往 [下载页](https://cowagent.ai/zh/download/) 获取最新版本。
|
||||||
|
- **源码部署**:执行 `cow update` 一键升级,或手动拉取代码后重启。详见 [更新升级文档](https://docs.cowagent.ai/zh/guide/upgrade)。
|
||||||
|
|
||||||
|
**发布日期**:2026.07.08 | [Full Changelog](https://github.com/zhayujie/CowAgent/compare/2.1.2...2.1.3)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user