feat(core): improve portable core lifecycle and desktop integration
This commit is contained in:
1 parent
a21d38c87b
commit
91032f3a06
13 files changed
+905
-170
No files matched your search
@@ -0,0 +1,71 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$BuildJobs
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$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'
|
||||
$PreparePortable = Join-Path $RootDir 'scripts\prepare-portable.mjs'
|
||||
|
||||
Set-Location -LiteralPath $RootDir
|
||||
|
||||
if (-not $PSBoundParameters.ContainsKey('BuildJobs')) {
|
||||
$BuildJobs = if ($env:CARGO_BUILD_JOBS) {
|
||||
[int]$env:CARGO_BUILD_JOBS
|
||||
}
|
||||
else {
|
||||
16
|
||||
}
|
||||
}
|
||||
if ($BuildJobs -lt 1 -or $BuildJobs -gt 256) {
|
||||
throw 'BuildJobs must be between 1 and 256.'
|
||||
}
|
||||
|
||||
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
|
||||
throw 'bun is not installed or not in PATH.'
|
||||
}
|
||||
|
||||
Write-Host "Cargo build jobs: $BuildJobs"
|
||||
|
||||
& bun install
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "bun install failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
$PreviousBuildJobs = $env:CARGO_BUILD_JOBS
|
||||
try {
|
||||
$env:CARGO_BUILD_JOBS = [string]$BuildJobs
|
||||
& bun tauri build --no-bundle
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Tauri build failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -eq $PreviousBuildJobs) {
|
||||
Remove-Item Env:CARGO_BUILD_JOBS -ErrorAction SilentlyContinue
|
||||
}
|
||||
else {
|
||||
$env:CARGO_BUILD_JOBS = $PreviousBuildJobs
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $AppBin -PathType Leaf)) {
|
||||
throw "Build finished, but executable not found: $AppBin"
|
||||
}
|
||||
|
||||
& bun $PreparePortable --binary $AppBin --output $BinDir
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Portable preparation failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BinOut -PathType Leaf)) {
|
||||
throw "Portable preparation finished, but executable not found: $BinOut"
|
||||
}
|
||||
|
||||
Write-Host "Built: $AppBin"
|
||||
Write-Host "Copied: $BinOut"
|
||||
@@ -0,0 +1,39 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$RootDir = [System.IO.Path]::GetFullPath($PSScriptRoot).TrimEnd(
|
||||
[System.IO.Path]::DirectorySeparatorChar,
|
||||
[System.IO.Path]::AltDirectorySeparatorChar
|
||||
)
|
||||
$RootPrefix = "$RootDir$([System.IO.Path]::DirectorySeparatorChar)"
|
||||
$BinDir = Join-Path $RootDir 'bin-work'
|
||||
|
||||
Set-Location -LiteralPath $RootDir
|
||||
|
||||
$CleanTargets = @(
|
||||
(Join-Path $RootDir 'dist'),
|
||||
(Join-Path $RootDir 'src-tauri\target'),
|
||||
(Join-Path $RootDir 'src-tauri\gen'),
|
||||
$BinDir
|
||||
)
|
||||
|
||||
foreach ($Target in $CleanTargets) {
|
||||
$ResolvedTarget = [System.IO.Path]::GetFullPath($Target)
|
||||
if (-not $ResolvedTarget.StartsWith(
|
||||
$RootPrefix,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "Refusing to clean a path outside the project: $ResolvedTarget"
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $ResolvedTarget) {
|
||||
Remove-Item -LiteralPath $ResolvedTarget -Recurse -Force
|
||||
}
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
|
||||
|
||||
Write-Host 'Cleaned build output.'
|
||||
+1
-1
@@ -1 +1 @@
|
||||
7.2.83
|
||||
7.2.88
|
||||
@@ -0,0 +1,19 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$RootDir = $PSScriptRoot
|
||||
$BinDir = Join-Path $RootDir 'bin-work'
|
||||
$AppBin = Join-Path $BinDir 'cpa-gui.exe'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $AppBin -PathType Leaf)) {
|
||||
Write-Host "Executable not found: $AppBin"
|
||||
Write-Host 'Run .\build.ps1 first.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
$Process = Start-Process -FilePath $AppBin -WorkingDirectory $BinDir -PassThru -Wait
|
||||
|
||||
exit $Process.ExitCode
|
||||
Generated
+1
@@ -431,6 +431,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
|
||||
name = "cpa-gui"
|
||||
version = "0.1.0-pre.4"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
|
||||
@@ -9,6 +9,7 @@ edition = "2021"
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
flate2 = "1"
|
||||
futures-util = "0.3"
|
||||
|
||||
+674
-118
File diff suppressed because it is too large.
Load diff
+31
-3
@@ -255,6 +255,18 @@ impl UsageCollectorState {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_total_records(&self, total_records: u64) {
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
inner.status.total_records = total_records;
|
||||
}
|
||||
}
|
||||
|
||||
fn increment_total_records(&self, added: usize) {
|
||||
if let Ok(mut inner) = self.inner.lock() {
|
||||
inner.status.total_records = inner.status.total_records.saturating_add(added as u64);
|
||||
}
|
||||
}
|
||||
|
||||
fn status(&self) -> Result<UsageCollectorStatus, String> {
|
||||
self.inner
|
||||
.lock()
|
||||
@@ -443,6 +455,9 @@ fn validate_legacy_schema(version: u8, path: &Path) -> Result<(), String> {
|
||||
|
||||
pub(crate) fn start_usage_collector(app: tauri::AppHandle) {
|
||||
let state = app.state::<UsageCollectorState>();
|
||||
if let Ok(total_records) = total_usage_records() {
|
||||
state.set_total_records(total_records);
|
||||
}
|
||||
let Some(token) = state.start() else {
|
||||
return;
|
||||
};
|
||||
@@ -498,6 +513,10 @@ async fn usage_collector_loop(app: tauri::AppHandle, token: CancellationToken) {
|
||||
Ok(items) => match persist_queue_items(&root, items, &config) {
|
||||
Ok(saved) => {
|
||||
let collected_at = Local::now().to_rfc3339();
|
||||
if saved > 0 {
|
||||
app.state::<UsageCollectorState>()
|
||||
.increment_total_records(saved);
|
||||
}
|
||||
set_collector_status(
|
||||
&app,
|
||||
"collecting",
|
||||
@@ -568,9 +587,9 @@ fn set_collector_status(
|
||||
) {
|
||||
let state = app.state::<UsageCollectorState>();
|
||||
let previous = state.status().ok();
|
||||
let total_records = total_usage_records()
|
||||
.ok()
|
||||
.or_else(|| previous.as_ref().map(|value| value.total_records))
|
||||
let total_records = previous
|
||||
.as_ref()
|
||||
.map(|value| value.total_records)
|
||||
.unwrap_or(0);
|
||||
state.set_status(UsageCollectorStatus {
|
||||
state: state_name.to_string(),
|
||||
@@ -1281,6 +1300,15 @@ fn mask_api_key(value: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn collector_total_records_are_cached_and_incremented() {
|
||||
let state = UsageCollectorState::default();
|
||||
state.set_total_records(40);
|
||||
state.increment_total_records(2);
|
||||
|
||||
assert_eq!(state.status().unwrap().total_records, 42);
|
||||
}
|
||||
|
||||
fn test_root(name: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"cpa-gui-usage-{name}-{}-{}",
|
||||
|
||||
+39
-5
@@ -1,5 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
|
||||
export type CoreStatus = {
|
||||
installed: boolean;
|
||||
@@ -15,6 +16,7 @@ export type CoreStatus = {
|
||||
type CoreRuntimeContextValue = {
|
||||
status: CoreStatus | null;
|
||||
statusError: string;
|
||||
refreshStatus: () => Promise<void>;
|
||||
publishStatus: (status: CoreStatus | null) => void;
|
||||
};
|
||||
|
||||
@@ -24,6 +26,13 @@ export function CoreRuntimeProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<CoreStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState('');
|
||||
|
||||
const publishStatus = useCallback((nextStatus: CoreStatus | null) => {
|
||||
setStatus(nextStatus);
|
||||
if (nextStatus) {
|
||||
setStatusError('');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const nextStatus = await invoke<CoreStatus>('get_core_status');
|
||||
@@ -36,17 +45,42 @@ export function CoreRuntimeProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
listen<CoreStatus>('core-status-changed', (event) => {
|
||||
if (!disposed) {
|
||||
publishStatus(event.payload);
|
||||
}
|
||||
}).then((stop) => {
|
||||
if (disposed) {
|
||||
stop();
|
||||
} else {
|
||||
unlisten = stop;
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (!disposed) {
|
||||
setStatusError(String(error));
|
||||
}
|
||||
});
|
||||
|
||||
void refreshStatus();
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden) {
|
||||
void refreshStatus();
|
||||
}, 1500);
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshStatus]);
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [publishStatus, refreshStatus]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ status, statusError, publishStatus: setStatus }),
|
||||
[status, statusError],
|
||||
() => ({ status, statusError, refreshStatus, publishStatus }),
|
||||
[publishStatus, refreshStatus, status, statusError],
|
||||
);
|
||||
|
||||
return <CoreRuntimeContext.Provider value={value}>{children}</CoreRuntimeContext.Provider>;
|
||||
|
||||
@@ -366,17 +366,6 @@ const emptyProviderDraft = (): ProviderDraft => ({
|
||||
cloakCacheUserId: false,
|
||||
});
|
||||
|
||||
const deepSeekDefaultModels = (): ModelOption[] => [
|
||||
{
|
||||
name: 'deepseek-chat',
|
||||
thinking: { levels: [...DEEPSEEK_THINKING_LEVELS] },
|
||||
},
|
||||
{
|
||||
name: 'deepseek-reasoner',
|
||||
thinking: { levels: [...DEEPSEEK_THINKING_LEVELS] },
|
||||
},
|
||||
];
|
||||
|
||||
export const createProviderDraft = (category: ProviderCategory): ProviderDraft => {
|
||||
const draft = emptyProviderDraft();
|
||||
if (category === 'openai-compatibility') return { ...draft, thinkingLevels: [] };
|
||||
@@ -385,7 +374,6 @@ export const createProviderDraft = (category: ProviderCategory): ProviderDraft =
|
||||
...draft,
|
||||
name: 'DeepSeek',
|
||||
baseUrl: DEEPSEEK_BASE_URL,
|
||||
models: deepSeekDefaultModels(),
|
||||
thinkingLevels: [...DEEPSEEK_THINKING_LEVELS],
|
||||
};
|
||||
};
|
||||
@@ -1155,7 +1143,7 @@ function ApiProviderDialog({
|
||||
<img src={deepseekIcon} alt="" className="provider-logo" />
|
||||
<div>
|
||||
<strong>OpenAI 兼容预设</strong>
|
||||
<span>已预填 DeepSeek 官方地址与常用模型</span>
|
||||
<span>已预填 DeepSeek 官方地址,保存时自动发现并开放全部模型</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
+13
-27
@@ -94,9 +94,12 @@ const compareVersions = (left: string, right: string) => {
|
||||
};
|
||||
|
||||
export function KernelPage() {
|
||||
const { publishStatus } = useCoreRuntime();
|
||||
const [coreStatus, setCoreStatus] = useState<CoreStatus | null>(null);
|
||||
const [statusError, setStatusError] = useState('');
|
||||
const {
|
||||
status: coreStatus,
|
||||
statusError,
|
||||
refreshStatus,
|
||||
publishStatus,
|
||||
} = useCoreRuntime();
|
||||
const [platform, setPlatform] = useState<CorePlatform | null>(null);
|
||||
const [platformError, setPlatformError] = useState('');
|
||||
const [latest, setLatest] = useState<CoreLatest | null>(cachedLatest);
|
||||
@@ -165,7 +168,7 @@ export function KernelPage() {
|
||||
if (task.result) {
|
||||
setMessage(task.message || `${task.result.version} 安装完成`);
|
||||
setMessageType('success');
|
||||
void loadCoreStatus();
|
||||
void refreshStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,7 +199,6 @@ export function KernelPage() {
|
||||
}
|
||||
});
|
||||
|
||||
loadCoreStatus();
|
||||
loadPlatform();
|
||||
loadBundledCore();
|
||||
loadInstallTask();
|
||||
@@ -275,19 +277,6 @@ export function KernelPage() {
|
||||
};
|
||||
}, [allowLanAccess]);
|
||||
|
||||
const loadCoreStatus = async () => {
|
||||
try {
|
||||
const result = await invoke<CoreStatus>('get_core_status');
|
||||
setCoreStatus(result);
|
||||
publishStatus(result);
|
||||
setStatusError('');
|
||||
} catch (error) {
|
||||
setCoreStatus(null);
|
||||
publishStatus(null);
|
||||
setStatusError(String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const runCoreProcessCommand = async (
|
||||
command: CoreProcessCommand,
|
||||
messages?: { success?: string; failure?: string },
|
||||
@@ -302,15 +291,12 @@ export function KernelPage() {
|
||||
|
||||
try {
|
||||
const result = await invoke<CoreStatus>(command);
|
||||
setCoreStatus(result);
|
||||
publishStatus(result);
|
||||
setStatusError('');
|
||||
showProcessNotice(messages?.success ?? `内核${actionLabel}成功`, 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
const errorMessage = String(error);
|
||||
await loadCoreStatus();
|
||||
setStatusError(errorMessage);
|
||||
await refreshStatus();
|
||||
showProcessNotice(
|
||||
`${messages?.failure ?? `内核${actionLabel}失败`}:${errorMessage}`,
|
||||
'error',
|
||||
@@ -483,7 +469,7 @@ export function KernelPage() {
|
||||
message: `${result.version} 安装完成`,
|
||||
result,
|
||||
});
|
||||
await Promise.all([loadCoreStatus(), loadBundledCore()]);
|
||||
await Promise.all([refreshStatus(), loadBundledCore()]);
|
||||
} catch (error) {
|
||||
const errorMessage = String(error);
|
||||
setMessage(errorMessage);
|
||||
@@ -532,7 +518,7 @@ export function KernelPage() {
|
||||
const result = await invoke<CoreInstallResult>('install_bundled_core');
|
||||
setMessage(`${result.version} 内置内核安装完成`);
|
||||
setMessageType('success');
|
||||
await Promise.all([loadCoreStatus(), loadBundledCore()]);
|
||||
await Promise.all([refreshStatus(), loadBundledCore()]);
|
||||
} catch (error) {
|
||||
const errorMessage = String(error);
|
||||
setMessage(errorMessage);
|
||||
@@ -820,7 +806,7 @@ export function KernelPage() {
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
disabled={processBusy || networkBusy}
|
||||
onClick={loadCoreStatus}
|
||||
onClick={() => void refreshStatus()}
|
||||
>
|
||||
刷新状态
|
||||
</button>
|
||||
@@ -876,7 +862,7 @@ export function KernelPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
className="secondary-button"
|
||||
title={latestVersion ? `安装 ${latestVersion}` : '安装最新版'}
|
||||
disabled={!latestVersion || installDisabled}
|
||||
onClick={() => installVersion(latestVersion)}
|
||||
@@ -894,7 +880,7 @@ export function KernelPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button"
|
||||
className="primary-button"
|
||||
title={(bundledCore?.assetName ?? bundledCoreError) || '当前发行包未包含内置内核'}
|
||||
disabled={!bundledCore || installDisabled}
|
||||
onClick={() => void installBundledCore()}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const thinkingAliasSourceKindLabel = (kind: string) => {
|
||||
};
|
||||
|
||||
const thinkingAliasProviderDetail = (kind: string, provider: string) => (
|
||||
provider === thinkingAliasSourceKindLabel(kind) ? '内核模型目录' : provider
|
||||
provider === thinkingAliasSourceKindLabel(kind) ? '内核当前可用模型' : provider
|
||||
);
|
||||
|
||||
const thinkingAliasSourceDetail = (source: ThinkingAliasSource) => (
|
||||
|
||||
@@ -26,15 +26,23 @@ describe('API 接入配置合并', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('DeepSeek 新增预设使用 OpenAI 兼容格式和内置思考等级', () => {
|
||||
it('DeepSeek 新增预设默认发现全部模型并应用内置思考等级', () => {
|
||||
const draft = createProviderDraft('deepseek');
|
||||
const result = buildProviderRecord('openai-compatibility', {
|
||||
const discovered = [
|
||||
{ name: 'deepseek-chat' },
|
||||
{ name: 'deepseek-reasoner' },
|
||||
{ name: 'deepseek-new-model' },
|
||||
];
|
||||
const prepared = applyProviderPreset('deepseek', {
|
||||
...draft,
|
||||
apiKey: 'deepseek-key',
|
||||
models: discovered,
|
||||
});
|
||||
const result = buildProviderRecord('openai-compatibility', prepared);
|
||||
|
||||
expect(draft.name).toBe('DeepSeek');
|
||||
expect(draft.baseUrl).toBe(DEEPSEEK_BASE_URL);
|
||||
expect(draft.models).toEqual([]);
|
||||
expect(result).toMatchObject({
|
||||
name: 'DeepSeek',
|
||||
'base-url': 'https://api.deepseek.com',
|
||||
@@ -48,6 +56,10 @@ describe('API 接入配置合并', () => {
|
||||
name: 'deepseek-reasoner',
|
||||
thinking: { levels: [...DEEPSEEK_THINKING_LEVELS] },
|
||||
},
|
||||
{
|
||||
name: 'deepseek-new-model',
|
||||
thinking: { levels: [...DEEPSEEK_THINKING_LEVELS] },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user