diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d78e26..d61eecc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,23 +112,37 @@ jobs: run: >- node scripts/prepare-portable.mjs --binary "src-tauri/target/release/cpa-gui${{ matrix.os == 'windows' && '.exe' || '' }}" - --output "release/cpa-gui-${{ matrix.os }}-${{ matrix.arch }}" + --output "release/Easy_CLIProxyAPI-${{ matrix.os }}-${{ matrix.arch }}" --os "${{ matrix.os }}" --arch "${{ matrix.arch }}" --download true - --clean-core true shell: bash + - name: Verify archive-only core payload + shell: bash + env: + CORE_DIR: release/Easy_CLIProxyAPI-${{ matrix.os }}-${{ matrix.arch }}/cpa-core + run: | + node - <<'NODE' + const { readdirSync } = require('node:fs'); + const entries = readdirSync(process.env.CORE_DIR, { withFileTypes: true }); + const valid = entries.length === 1 + && entries[0].isFile() + && /\.(zip|tar\.gz)$/.test(entries[0].name); + if (!valid) { + throw new Error(`cpa-core must contain exactly one archive; found: ${entries.map((entry) => entry.name).join(', ')}`); + } + NODE - name: Archive Unix release if: matrix.os != 'windows' - run: tar -czf "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.tar.gz" -C release "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}" + run: tar -czf "Easy_CLIProxyAPI-${{ matrix.os }}-${{ matrix.arch }}.tar.gz" -C release "Easy_CLIProxyAPI-${{ matrix.os }}-${{ matrix.arch }}" - name: Archive Windows release if: matrix.os == 'windows' shell: pwsh - run: Compress-Archive -Path "release/cpa-gui-${{ matrix.os }}-${{ matrix.arch }}" -DestinationPath "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.zip" + run: Compress-Archive -Path "release/Easy_CLIProxyAPI-${{ matrix.os }}-${{ matrix.arch }}" -DestinationPath "Easy_CLIProxyAPI-${{ matrix.os }}-${{ matrix.arch }}.zip" - uses: actions/upload-artifact@v7 with: - name: cpa-gui-${{ matrix.os }}-${{ matrix.arch }} - path: cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.${{ matrix.suffix }} + name: Easy_CLIProxyAPI-${{ matrix.os }}-${{ matrix.arch }} + path: Easy_CLIProxyAPI-${{ matrix.os }}-${{ matrix.arch }}.${{ matrix.suffix }} if-no-files-found: error retention-days: 14 @@ -159,7 +173,7 @@ jobs: fi prerelease="$MANUAL_PRERELEASE" - release_args=(--title "CPA GUI $RELEASE_TAG" --generate-notes) + release_args=(--title "Easy_CLIProxyAPI $RELEASE_TAG" --generate-notes) if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then release_args+=(--verify-tag) if [[ "$RELEASE_TAG" == *-* ]]; then diff --git a/build.ps1 b/build.ps1 index a8c777c..7f7226d 100644 --- a/build.ps1 +++ b/build.ps1 @@ -9,7 +9,7 @@ $ErrorActionPreference = 'Stop' $RootDir = $PSScriptRoot $AppBin = Join-Path $RootDir 'src-tauri\target\release\cpa-gui.exe' $BinDir = Join-Path $RootDir 'bin-work' -$BinOut = Join-Path $BinDir 'cpa-gui.exe' +$BinOut = Join-Path $BinDir 'Easy_CLIProxyAPI.exe' $PreparePortable = Join-Path $RootDir 'scripts\prepare-portable.mjs' Set-Location -LiteralPath $RootDir @@ -67,5 +67,16 @@ if (-not (Test-Path -LiteralPath $BinOut -PathType Leaf)) { throw "Portable preparation finished, but executable not found: $BinOut" } +$CoreOut = Join-Path $BinDir 'cpa-core' +$CoreEntries = @(Get-ChildItem -LiteralPath $CoreOut -Force) +if ( + $CoreEntries.Count -ne 1 -or + $CoreEntries[0].PSIsContainer -or + $CoreEntries[0].Name -notmatch '^CLIProxyAPI_.+_windows_.+\.zip$' +) { + throw "Portable core output must contain exactly one Windows core archive: $CoreOut" +} + Write-Host "Built: $AppBin" Write-Host "Copied: $BinOut" +Write-Host "Bundled core archive: $($CoreEntries[0].FullName)" diff --git a/build.sh b/build.sh index 5031145..3d8b668 100755 --- a/build.sh +++ b/build.sh @@ -7,7 +7,7 @@ cd "$ROOT_DIR" BUILD_JOBS="${CARGO_BUILD_JOBS:-20}" APP_BIN="$ROOT_DIR/src-tauri/target/release/cpa-gui" BIN_DIR="$ROOT_DIR/bin-work" -BIN_OUT="$BIN_DIR/cpa-gui" +BIN_OUT="$BIN_DIR/Easy_CLIProxyAPI" if ! command -v bun >/dev/null 2>&1; then echo "bun is not installed or not in PATH." diff --git a/index.html b/index.html index 38b3986..de6f754 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - CPA GUI + Easy_CLIProxyAPI
diff --git a/run.ps1 b/run.ps1 index 95e60e4..0f2a362 100644 --- a/run.ps1 +++ b/run.ps1 @@ -6,7 +6,7 @@ $ErrorActionPreference = 'Stop' $RootDir = $PSScriptRoot $BinDir = Join-Path $RootDir 'bin-work' -$AppBin = Join-Path $BinDir 'cpa-gui.exe' +$AppBin = Join-Path $BinDir 'Easy_CLIProxyAPI.exe' if (-not (Test-Path -LiteralPath $AppBin -PathType Leaf)) { Write-Host "Executable not found: $AppBin" diff --git a/run.sh b/run.sh index f59bc66..02fe33e 100755 --- a/run.sh +++ b/run.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_BIN="$ROOT_DIR/bin-work/cpa-gui" +APP_BIN="$ROOT_DIR/bin-work/Easy_CLIProxyAPI" if [ ! -x "$APP_BIN" ]; then echo "Executable not found: $APP_BIN" diff --git a/scripts/prepare-portable.mjs b/scripts/prepare-portable.mjs index 661b66f..1fcc2d5 100644 --- a/scripts/prepare-portable.mjs +++ b/scripts/prepare-portable.mjs @@ -1,8 +1,7 @@ import { createHash } from 'node:crypto'; import { existsSync } from 'node:fs'; -import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { basename, join, resolve } from 'node:path'; -import { spawnSync } from 'node:child_process'; const args = new Map(); for (let index = 2; index < process.argv.length; index += 2) { @@ -15,7 +14,6 @@ const binary = resolve(args.get('--binary') ?? join(root, 'src-tauri', 'target', const targetOS = args.get('--os') ?? ({ linux: 'linux', darwin: 'darwin', win32: 'windows' })[process.platform]; const targetArch = args.get('--arch') ?? ({ x64: 'amd64', arm64: 'aarch64' })[process.arch]; const shouldDownload = args.get('--download') === 'true'; -const cleanCore = args.get('--clean-core') === 'true'; if (!targetOS || !targetArch) throw new Error(`Unsupported target: ${process.platform}/${process.arch}`); if (!existsSync(binary)) throw new Error(`GUI binary not found: ${binary}`); @@ -32,13 +30,7 @@ const checksumsPath = join(sourceDir, 'checksums.txt'); await mkdir(sourceDir, { recursive: true }); if (!existsSync(sourceArchive)) { if (!shouldDownload) { - console.warn(`Built-in core archive not found; portable output keeps the existing core: ${sourceArchive}`); - await mkdir(output, { recursive: true }); - const outputBinary = join(output, targetOS === 'windows' ? 'cpa-gui.exe' : 'cpa-gui'); - await copyFile(binary, outputBinary); - if (targetOS !== 'windows') await chmod(outputBinary, 0o755); - await copyFile(join(root, 'core-version.txt'), join(output, 'core-version.txt')); - process.exit(0); + throw new Error(`Built-in core archive not found: ${sourceArchive}`); } const tag = `v${version}`; const releaseBase = `https://github.com/router-for-me/CLIProxyAPI/releases/download/${tag}`; @@ -61,35 +53,25 @@ if (existsSync(checksumsPath)) { } await mkdir(output, { recursive: true }); -const outputBinary = join(output, targetOS === 'windows' ? 'cpa-gui.exe' : 'cpa-gui'); +const outputBinary = join(output, targetOS === 'windows' ? 'Easy_CLIProxyAPI.exe' : 'Easy_CLIProxyAPI'); +const legacyOutputBinary = join(output, targetOS === 'windows' ? 'cpa-gui.exe' : 'cpa-gui'); +await rm(legacyOutputBinary, { force: true }); await copyFile(binary, outputBinary); if (targetOS !== 'windows') await chmod(outputBinary, 0o755); await copyFile(join(root, 'core-version.txt'), join(output, 'core-version.txt')); const coreOutput = join(output, 'cpa-core'); -if (cleanCore) await rm(coreOutput, { recursive: true, force: true }); +await rm(coreOutput, { recursive: true, force: true }); await mkdir(coreOutput, { recursive: true }); -let extraction; -if (extension === 'zip') { - if (process.platform === 'win32') { - const quotePowerShell = (value) => value.replaceAll("'", "''"); - const command = `Expand-Archive -LiteralPath '${quotePowerShell(sourceArchive)}' -DestinationPath '${quotePowerShell(coreOutput)}' -Force`; - extraction = spawnSync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], { stdio: 'inherit' }); - } else { - extraction = spawnSync('unzip', ['-q', sourceArchive, '-d', coreOutput], { stdio: 'inherit' }); - } -} else { - extraction = spawnSync('tar', ['-xf', sourceArchive, '-C', coreOutput], { stdio: 'inherit' }); -} -if (extraction.error) throw new Error(`Failed to start archive extractor for ${assetName}: ${extraction.error.message}`); -if (extraction.status !== 0) throw new Error(`Failed to extract ${assetName} (exit code ${extraction.status})`); await copyFile(sourceArchive, join(coreOutput, assetName)); -if (existsSync(checksumsPath)) await copyFile(checksumsPath, join(coreOutput, 'checksums.txt')); -await writeFile(join(coreOutput, 'cpa-gui-meta.json'), `${JSON.stringify({ - version: `v${version}`, - assetName, - installedAtUnix: Math.floor(Date.now() / 1000), -}, null, 2)}\n`); +const coreEntries = await readdir(coreOutput, { withFileTypes: true }); +if ( + coreEntries.length !== 1 + || !coreEntries[0].isFile() + || coreEntries[0].name !== assetName +) { + throw new Error(`Core output must contain only ${assetName}`); +} console.log(`Prepared portable directory: ${output}`); -console.log(`Bundled core: ${basename(sourceArchive)}`); +console.log(`Bundled core: ${basename(sourceArchive)} (archive only)`); diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 467363e..92f9b26 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -1,7 +1,7 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Default permissions for the main CPA GUI window.", + "description": "Default permissions for the main Easy_CLIProxyAPI window.", "windows": ["main"], "permissions": ["core:event:allow-listen", "core:event:allow-unlisten"] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 30b85b8..c2c6db0 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -808,7 +808,7 @@ struct GithubAsset { #[tauri::command] fn health_check() -> &'static str { - "CPA GUI Rust backend is ready" + "Easy_CLIProxyAPI Rust backend is ready" } #[tauri::command] @@ -5382,7 +5382,7 @@ fn configure_child_lifetime(command: &mut Command) { if libc::getppid() != parent_process_id { return Err(io::Error::other( - "CPA GUI exited before the core process started", + "Easy_CLIProxyAPI exited before the core process started", )); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 059bcb3..962f401 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "CPA GUI", + "productName": "Easy_CLIProxyAPI", "version": "0.1.0-pre.4", "identifier": "com.cpa.gui", "build": { @@ -13,7 +13,7 @@ "windows": [ { "label": "main", - "title": "CPA GUI", + "title": "Easy_CLIProxyAPI", "width": 1200, "height": 800, "minWidth": 900, diff --git a/src/App.tsx b/src/App.tsx index 5e61a52..4b7fcb2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,18 @@ import { useEffect, useState } from 'react'; -import { Bot, FileKey, Gauge, GitFork, History, LogIn, Network, ServerCog, Settings } from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; +import { + Bot, + ExternalLink, + FileKey, + Gauge, + GitFork, + History, + LogIn, + MessageCircle, + Network, + ServerCog, + Settings, +} from 'lucide-react'; import appLogo from './assets/logo.jpg'; import { CoreRuntimeProvider, useCoreRuntime } from './coreRuntime'; import { ConfigPanelPage } from './pages/ConfigPanel'; @@ -12,6 +25,8 @@ import { AgentsPage } from './pages/AgentsPage'; import { ThinkingAliasesPage } from './pages/ThinkingAliasesPage'; import { UsageRecordsPage } from './pages/UsageRecordsPage'; +const CONTACT_URL = 'https://qm.qq.com/q/3queDaIG'; + const pages = [ { id: 'kernel', @@ -99,6 +114,14 @@ function AppContent() { setActive(pageId); }; + const openContact = async () => { + try { + await invoke('open_external_url', { url: CONTACT_URL }); + } catch (error) { + console.error('打开联系我们链接失败', error); + } + }; + return (
diff --git a/src/AppErrorBoundary.tsx b/src/AppErrorBoundary.tsx index 0650ce9..0d6c5b3 100644 --- a/src/AppErrorBoundary.tsx +++ b/src/AppErrorBoundary.tsx @@ -11,7 +11,7 @@ export class AppErrorBoundary extends Component { } componentDidCatch(error: Error, info: ErrorInfo) { - console.error('CPA GUI 渲染异常', error, info.componentStack); + console.error('Easy_CLIProxyAPI 渲染异常', error, info.componentStack); } render() { diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 9ac13c2..a121cbd 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -215,6 +216,13 @@ type AgentModelPickerProps = { onRefresh: () => void; }; +type AgentModelDropdownLayout = { + top: number; + left: number; + width: number; + height: number; +}; + function AgentModelPicker({ models, value, @@ -227,6 +235,7 @@ function AgentModelPicker({ const [open, setOpen] = useState(false); const [search, setSearch] = useState(''); const [activeIndex, setActiveIndex] = useState(0); + const [dropdownLayout, setDropdownLayout] = useState(null); const rootRef = useRef(null); const searchRef = useRef(null); const visibleModels = useMemo(() => filterAgentModels(models, search), [models, search]); @@ -236,6 +245,51 @@ function AgentModelPicker({ ); const selectedAlias = agentModelAlias(models, value); + const updateDropdownLayout = useCallback(() => { + const root = rootRef.current; + if (!root) return; + + const rect = root.getBoundingClientRect(); + const edgeGap = 12; + const triggerGap = 6; + const preferredHeight = 282; + const minimumHeight = 150; + const spaceBelow = Math.max(0, window.innerHeight - rect.bottom - triggerGap - edgeGap); + const spaceAbove = Math.max(0, rect.top - triggerGap - edgeGap); + const placeAbove = spaceBelow < preferredHeight && spaceAbove > spaceBelow; + const availableHeight = placeAbove ? spaceAbove : spaceBelow; + const height = Math.min(preferredHeight, Math.max(minimumHeight, availableHeight)); + const width = Math.min(rect.width, window.innerWidth - edgeGap * 2); + const left = Math.min( + Math.max(edgeGap, rect.left), + Math.max(edgeGap, window.innerWidth - edgeGap - width), + ); + const desiredTop = placeAbove + ? rect.top - triggerGap - height + : rect.bottom + triggerGap; + const top = Math.min( + Math.max(edgeGap, desiredTop), + Math.max(edgeGap, window.innerHeight - edgeGap - height), + ); + + setDropdownLayout({ top, left, width, height }); + }, []); + + useLayoutEffect(() => { + if (!open) { + setDropdownLayout(null); + return undefined; + } + + updateDropdownLayout(); + window.addEventListener('resize', updateDropdownLayout); + window.addEventListener('scroll', updateDropdownLayout); + return () => { + window.removeEventListener('resize', updateDropdownLayout); + window.removeEventListener('scroll', updateDropdownLayout); + }; + }, [open, updateDropdownLayout]); + useEffect(() => { if (!open) return undefined; const close = (event: MouseEvent) => { @@ -311,7 +365,12 @@ function AgentModelPicker({ {open ? ( -
+
{ - const parse = (value: string) => value.replace(/^v/i, '').split('.').map((part) => Number(part) || 0); - const leftParts = parse(left); - const rightParts = parse(right); - for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index += 1) { - const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); - if (difference !== 0) return difference; - } - return 0; -}; - export function KernelPage() { const { status: coreStatus, @@ -491,13 +480,6 @@ export function KernelPage() { const installBundledCore = async () => { if (!bundledCore) return; - const comparison = currentVersion ? compareVersions(currentVersion, bundledCore.version) : 0; - const prompt = comparison > 0 - ? `当前内核 ${currentVersion} 高于内置版本 ${bundledCore.version},继续会降级。是否确认?` - : currentVersion === bundledCore.version - ? `将使用内置压缩包重新安装 ${bundledCore.version},是否继续?` - : `将安装内置内核 ${bundledCore.version},是否继续?`; - if (!window.confirm(prompt)) return; setInstalling(true); setMessage(`正在安装内置内核 ${bundledCore.version}`); diff --git a/src/styles.css b/src/styles.css index 495f7af..4d7b745 100644 --- a/src/styles.css +++ b/src/styles.css @@ -181,14 +181,52 @@ input:focus-visible { color: #2d2a26; } +.sidebar-bottom { + display: grid; + gap: 10px; + margin-top: auto; + padding-top: 18px; + border-top: 1px solid #e3e1db; +} + +.sidebar-contact { + display: grid; + grid-template-columns: 18px minmax(0, 1fr) 14px; + align-items: center; + gap: 8px; + width: 100%; + min-height: 38px; + padding: 0 10px; + border: 1px solid #d8d4cc; + border-radius: 7px; + background: #fffdf8; + color: #4f4a43; + text-align: left; +} + +.sidebar-contact:hover { + border-color: #c7c2b8; + background: #e8e5dc; + color: #2d2a26; +} + +.sidebar-contact svg { + color: #7a746c; +} + +.sidebar-contact span { + overflow: hidden; + font-size: 12px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + .sidebar-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; - margin-top: auto; - padding-top: 18px; - border-top: 1px solid #e3e1db; color: #7a746c; font-size: 12px; } @@ -3600,13 +3638,10 @@ input:focus-visible { } .agent-model-dropdown { - position: absolute; + position: fixed; z-index: 45; - top: calc(100% + 6px); - right: 0; - left: 0; display: grid; - grid-template-rows: 40px 260px 28px; + grid-template-rows: 40px minmax(0, 1fr) 28px; gap: 7px; padding: 9px; border: 1px solid #e3e1db; @@ -4880,6 +4915,12 @@ input:focus-visible { display: none; } + .sidebar-bottom { + margin-top: 0; + padding-top: 0; + border-top: 0; + } + .content { padding: 18px; } @@ -5132,11 +5173,6 @@ input:focus-visible { flex-direction: column; } - .agent-model-dropdown { - right: -4px; - left: -4px; - } - .agent-config-footer .primary-button { width: 100%; }