refactor(app): rename the desktop app and refine the interface

This commit is contained in:
lzt404 committed 2026-07-19 02:47:57 +08:00
1 parent 91032f3a06
commit ad02c2a8a6
15 files changed
+206 -87

No files matched your search

+21 -7
View File
@@ -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
+12 -1
View File
@@ -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)"
+1 -1
View File
@@ -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."
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CPA GUI</title>
<title>Easy_CLIProxyAPI</title>
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+15 -33
View File
@@ -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)`);
+1 -1
View File
@@ -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"]
}
+2 -2
View File
@@ -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",
));
}
+2 -2
View File
@@ -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,
+39 -4
View File
@@ -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 (
<div className="app-shell">
<aside className="sidebar">
@@ -132,9 +155,21 @@ function AppContent() {
})}
</nav>
<div className="sidebar-footer">
<span>CPA GUI</span>
<strong>0.1</strong>
<div className="sidebar-bottom">
<button
type="button"
className="sidebar-contact"
title="通过 QQ 联系我们"
onClick={() => void openContact()}
>
<MessageCircle size={16} aria-hidden="true" />
<span>联系我们</span>
<ExternalLink size={13} aria-hidden="true" />
</button>
<div className="sidebar-footer">
<span>Easy_CLIProxyAPI</span>
<strong>0.1</strong>
</div>
</div>
</aside>
+1 -1
View File
@@ -11,7 +11,7 @@ export class AppErrorBoundary extends Component<Props, State> {
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('CPA GUI 渲染异常', error, info.componentStack);
console.error('Easy_CLIProxyAPI 渲染异常', error, info.componentStack);
}
render() {
+60 -1
View File
@@ -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<AgentModelDropdownLayout | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(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({
</button>
{open ? (
<div className="agent-model-dropdown">
<div
className="agent-model-dropdown"
style={dropdownLayout
? dropdownLayout
: { top: 0, left: 0, width: 0, height: 0, visibility: 'hidden' }}
>
<div className="agent-model-search">
<Search size={15} aria-hidden />
<input
-18
View File
@@ -82,17 +82,6 @@ function requestLatestCore() {
return latestCheckPromise;
}
const compareVersions = (left: string, right: string) => {
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}`);
+49 -13
View File
@@ -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%;
}