refactor: modularize backend architecture and test suite
- split Tauri business logic out of main.rs by domain - separate agent and core configuration submodules - organize Rust tests by feature with shared test support - split frontend locale resources
This commit is contained in:
1 parent
89cee94a52
commit
906f68f566
34 files changed
+24023
-23695
No files matched your search
@@ -0,0 +1,12 @@
|
||||
//! Agent client discovery, configuration generation, and reversible state management.
|
||||
|
||||
use super::*;
|
||||
|
||||
mod commands;
|
||||
mod configuration;
|
||||
mod discovery;
|
||||
mod state;
|
||||
pub(crate) use commands::*;
|
||||
pub(crate) use configuration::*;
|
||||
pub(crate) use discovery::*;
|
||||
pub(crate) use state::*;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,232 @@
|
||||
use super::*;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn health_check() -> &'static str {
|
||||
"EasyCLIProxyAPI Rust backend is ready"
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn detect_core_platform() -> Result<CorePlatform, String> {
|
||||
current_core_platform()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_core_status(
|
||||
process_state: tauri::State<'_, CoreProcessState>,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<CoreStatus, String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
current_core_status(Some(process_state.inner()), Some(config.port))
|
||||
}
|
||||
|
||||
pub(crate) fn emit_core_status(app: &tauri::AppHandle, status: &CoreStatus) {
|
||||
#[cfg(target_os = "windows")]
|
||||
update_windows_tray_status(app, status);
|
||||
let _ = app.emit(CORE_STATUS_EVENT, status);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_gui_settings(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<GuiSettings, String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
Ok(GuiSettings::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn resolve_api_access_remarks(
|
||||
queries: Vec<ApiAccessRemarkQuery>,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
queries
|
||||
.into_iter()
|
||||
.map(|query| {
|
||||
validate_api_access_provider_section(&query.provider_section)?;
|
||||
Ok(query
|
||||
.api_keys
|
||||
.iter()
|
||||
.filter_map(|key| api_access_key_hash(key))
|
||||
.find_map(|hash| {
|
||||
config.api_access_remarks.iter().find(|entry| {
|
||||
entry.provider_section == query.provider_section
|
||||
&& entry.api_key_hash == hash
|
||||
})
|
||||
})
|
||||
.map(|entry| entry.remark.clone())
|
||||
.unwrap_or_default())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn save_api_access_remark(
|
||||
update: ApiAccessRemarkUpdate,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<(), String> {
|
||||
validate_api_access_provider_section(&update.provider_section)?;
|
||||
let remark = update.remark.trim().to_string();
|
||||
validate_api_key_remark(&remark)?;
|
||||
let previous_hashes = update
|
||||
.previous_api_keys
|
||||
.iter()
|
||||
.filter_map(|key| api_access_key_hash(key))
|
||||
.collect::<HashSet<_>>();
|
||||
let next_hashes = update
|
||||
.api_keys
|
||||
.iter()
|
||||
.filter_map(|key| api_access_key_hash(key))
|
||||
.collect::<HashSet<_>>();
|
||||
let hashes_to_replace = previous_hashes
|
||||
.union(&next_hashes)
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>();
|
||||
let provider_section = update.provider_section;
|
||||
|
||||
gui_config_state.update(|config| {
|
||||
config.api_access_remarks.retain(|entry| {
|
||||
entry.provider_section != provider_section
|
||||
|| !hashes_to_replace.contains(&entry.api_key_hash)
|
||||
});
|
||||
if !remark.is_empty() {
|
||||
config
|
||||
.api_access_remarks
|
||||
.extend(next_hashes.iter().map(|hash| GuiApiAccessRemark {
|
||||
provider_section: provider_section.clone(),
|
||||
api_key_hash: hash.clone(),
|
||||
remark: remark.clone(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_app_locale(
|
||||
app: tauri::AppHandle,
|
||||
process_state: tauri::State<'_, CoreProcessState>,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
locale: String,
|
||||
) -> Result<String, String> {
|
||||
let config = gui_config_state.set_locale(locale)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Ok(status) = current_core_status(Some(process_state.inner()), Some(config.port)) {
|
||||
update_windows_tray_locale(&app, &config.locale, &status);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = (app, process_state);
|
||||
Ok(config.locale)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn resolve_windows_close_request(
|
||||
app: tauri::AppHandle,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
action: WindowsCloseAction,
|
||||
remember: Option<bool>,
|
||||
) -> Result<(), String> {
|
||||
if remember.unwrap_or(false) {
|
||||
let close_behavior = match action {
|
||||
WindowsCloseAction::Exit => WindowsCloseBehavior::Exit,
|
||||
WindowsCloseAction::MinimizeToTray => WindowsCloseBehavior::MinimizeToTray,
|
||||
};
|
||||
gui_config_state.set_close_behavior(close_behavior)?;
|
||||
}
|
||||
|
||||
match action {
|
||||
WindowsCloseAction::Exit => app.exit(0),
|
||||
WindowsCloseAction::MinimizeToTray => {
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "主窗口不存在".to_string())?;
|
||||
window
|
||||
.hide()
|
||||
.map_err(|error| format!("隐藏主窗口失败: {error}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn app_autostart_enabled(app: &tauri::AppHandle) -> Result<bool, String> {
|
||||
app.autolaunch()
|
||||
.is_enabled()
|
||||
.map_err(|error| format!("读取系统开机自启状态失败: {error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn set_app_autostart_enabled(
|
||||
app: &tauri::AppHandle,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let manager = app.autolaunch();
|
||||
if enabled {
|
||||
manager
|
||||
.enable()
|
||||
.map_err(|error| format!("启用开机自启失败: {error}"))
|
||||
} else {
|
||||
manager
|
||||
.disable()
|
||||
.map_err(|error| format!("关闭开机自启失败: {error}"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn software_settings(
|
||||
app: &tauri::AppHandle,
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<SoftwareSettings, String> {
|
||||
Ok(SoftwareSettings {
|
||||
close_behavior: config.close_behavior,
|
||||
autostart_enabled: app_autostart_enabled(app)?,
|
||||
silent_start_enabled: config.silent_start,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_software_settings(
|
||||
app: tauri::AppHandle,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<SoftwareSettings, String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
software_settings(&app, &config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn save_software_settings(
|
||||
app: tauri::AppHandle,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
settings: SoftwareSettingsInput,
|
||||
) -> Result<SoftwareSettings, String> {
|
||||
let previous_config = gui_config_state.snapshot()?;
|
||||
let previous_autostart_enabled = app_autostart_enabled(&app)?;
|
||||
let autostart_changed = previous_autostart_enabled != settings.autostart_enabled;
|
||||
|
||||
if autostart_changed {
|
||||
set_app_autostart_enabled(&app, settings.autostart_enabled)?;
|
||||
}
|
||||
|
||||
let config = if previous_config.close_behavior == settings.close_behavior
|
||||
&& previous_config.silent_start == settings.silent_start_enabled
|
||||
{
|
||||
previous_config
|
||||
} else {
|
||||
match gui_config_state
|
||||
.set_software_preferences(settings.close_behavior, settings.silent_start_enabled)
|
||||
{
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error = autostart_changed
|
||||
.then(|| set_app_autostart_enabled(&app, previous_autostart_enabled).err())
|
||||
.flatten();
|
||||
return Err(match rollback_error {
|
||||
Some(rollback_error) => {
|
||||
format!("{error}; 回滚开机自启设置也失败: {rollback_error}")
|
||||
}
|
||||
None => error,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
software_settings(&app, &config)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,323 @@
|
||||
use super::{
|
||||
agent_managed_paths, apply_gui_managed_settings, consume_software_write,
|
||||
core_config_settings_from_value, core_install_dir, gui_config_path, is_loopback_host,
|
||||
lock_core_config_file, normalized_config_path, path_to_string,
|
||||
refresh_agent_config_status_cache, validate_gui_config, write_yaml_if_changed, AgentClient,
|
||||
AgentConfigStatusCache, ConfigFilesChangedPayload, CoreConfigSettings, GuiConfigFile,
|
||||
GuiConfigState, CONFIG_FILES_CHANGED_EVENT, CORE_CONFIG_FILE,
|
||||
};
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
fn paths_refer_to_same_file(left: &Path, right: &Path) -> bool {
|
||||
left == right || normalized_config_path(left) == normalized_config_path(right)
|
||||
}
|
||||
|
||||
fn wait_for_config_file_stability(path: &Path) {
|
||||
if !path.is_file() {
|
||||
return;
|
||||
}
|
||||
let mut previous = None;
|
||||
let mut stable_samples = 0;
|
||||
for _ in 0..10 {
|
||||
let current = fs::metadata(path)
|
||||
.ok()
|
||||
.map(|metadata| (metadata.len(), metadata.modified().ok()));
|
||||
if current == previous {
|
||||
stable_samples += 1;
|
||||
if stable_samples >= 2 {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
stable_samples = 0;
|
||||
previous = current;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn patch_core_from_gui_config_if_valid(config: &GuiConfigFile) -> Result<(), String> {
|
||||
let _guard = lock_core_config_file()?;
|
||||
let path = core_install_dir()?.join(CORE_CONFIG_FILE);
|
||||
if !path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
let content = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("读取内核配置失败 {}: {error}", path_to_string(&path)))?;
|
||||
let updated = apply_gui_managed_settings(&content, config)?;
|
||||
write_yaml_if_changed(&path, &updated).map(|_| ())
|
||||
}
|
||||
|
||||
fn tracked_configuration_paths(app: &tauri::AppHandle) -> Result<Vec<PathBuf>, String> {
|
||||
let home = app
|
||||
.path()
|
||||
.home_dir()
|
||||
.map_err(|error| format!("无法获取用户目录: {error}"))?;
|
||||
let mut paths = vec![
|
||||
gui_config_path()?,
|
||||
core_install_dir()?.join(CORE_CONFIG_FILE),
|
||||
];
|
||||
for client in [
|
||||
AgentClient::ClaudeCode,
|
||||
AgentClient::ClaudeDesktop,
|
||||
AgentClient::Codex,
|
||||
AgentClient::OpenCode,
|
||||
AgentClient::OpenClaw,
|
||||
AgentClient::Hermes,
|
||||
] {
|
||||
paths.extend(agent_managed_paths(client, &home));
|
||||
}
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn handle_configuration_file_changes(
|
||||
app: &tauri::AppHandle,
|
||||
changed_paths: Vec<PathBuf>,
|
||||
tracked_paths: &[PathBuf],
|
||||
) {
|
||||
let gui_path = gui_config_path().ok();
|
||||
let core_path = core_install_dir()
|
||||
.ok()
|
||||
.map(|path| path.join(CORE_CONFIG_FILE));
|
||||
let gui_state = app.state::<GuiConfigState>();
|
||||
let cache = app.state::<AgentConfigStatusCache>();
|
||||
let mut payload_paths = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let mut refresh_agents = false;
|
||||
let mut tracked_changes: Vec<PathBuf> = Vec::new();
|
||||
|
||||
for path in changed_paths {
|
||||
let Some(tracked_path) = tracked_paths
|
||||
.iter()
|
||||
.find(|tracked| paths_refer_to_same_file(&path, tracked))
|
||||
.cloned()
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if consume_software_write(&tracked_path) {
|
||||
continue;
|
||||
}
|
||||
wait_for_config_file_stability(&tracked_path);
|
||||
tracked_changes.retain(|existing| !paths_refer_to_same_file(existing, &tracked_path));
|
||||
tracked_changes.push(tracked_path);
|
||||
}
|
||||
|
||||
payload_paths.extend(tracked_changes.iter().map(|path| path_to_string(path)));
|
||||
let is_gui_path = |path: &Path| {
|
||||
gui_path
|
||||
.as_deref()
|
||||
.is_some_and(|gui_path| paths_refer_to_same_file(path, gui_path))
|
||||
};
|
||||
let is_core_path = |path: &Path| {
|
||||
core_path
|
||||
.as_deref()
|
||||
.is_some_and(|core_path| paths_refer_to_same_file(path, core_path))
|
||||
};
|
||||
|
||||
if tracked_changes
|
||||
.iter()
|
||||
.any(|path| is_gui_path(path) || is_core_path(path))
|
||||
{
|
||||
refresh_agents = true;
|
||||
let mut preserve_invalid_gui_file = false;
|
||||
let mut preserve_invalid_core_file = false;
|
||||
for tracked_path in tracked_changes.iter().rev() {
|
||||
if is_gui_path(tracked_path) {
|
||||
let parsed = (|| -> Result<GuiConfigFile, String> {
|
||||
let content = fs::read_to_string(tracked_path).map_err(|error| {
|
||||
format!(
|
||||
"读取 GUI 配置失败 {}: {error}",
|
||||
path_to_string(tracked_path)
|
||||
)
|
||||
})?;
|
||||
let mut config = toml::from_str::<GuiConfigFile>(&content)
|
||||
.map_err(|error| format!("解析 GUI 配置失败: {error}"))?;
|
||||
config.allow_lan = !is_loopback_host(&config.host);
|
||||
validate_gui_config(&config)?;
|
||||
Ok(config)
|
||||
})();
|
||||
let config = match parsed {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
errors.push(error);
|
||||
preserve_invalid_gui_file = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let result = (|| -> Result<(), String> {
|
||||
gui_state.replace_external(config.clone())?;
|
||||
if !preserve_invalid_core_file {
|
||||
patch_core_from_gui_config_if_valid(&config)?;
|
||||
}
|
||||
cache.clear()?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
errors.push(error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if is_core_path(tracked_path) {
|
||||
let parsed = (|| -> Result<CoreConfigSettings, String> {
|
||||
let content = fs::read_to_string(tracked_path).map_err(|error| {
|
||||
format!("读取内核配置失败 {}: {error}", path_to_string(tracked_path))
|
||||
})?;
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&content)
|
||||
.map_err(|error| format!("解析内核配置失败: {error}"))?;
|
||||
core_config_settings_from_value(&document)
|
||||
})();
|
||||
let settings = match parsed {
|
||||
Ok(settings) => settings,
|
||||
Err(error) => {
|
||||
errors.push(error);
|
||||
preserve_invalid_core_file = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let result = (|| -> Result<(), String> {
|
||||
if preserve_invalid_gui_file {
|
||||
gui_state.replace_core_settings_external(&settings)?;
|
||||
} else {
|
||||
gui_state.sync_core_settings(&settings)?;
|
||||
}
|
||||
cache.clear()?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
errors.push(error);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
refresh_agents |= tracked_changes
|
||||
.iter()
|
||||
.any(|path| !is_gui_path(path) && !is_core_path(path));
|
||||
|
||||
if refresh_agents {
|
||||
if let Err(error) = refresh_agent_config_status_cache(app, gui_state.inner(), cache.inner())
|
||||
{
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if !payload_paths.is_empty() || !errors.is_empty() {
|
||||
let _ = app.emit(
|
||||
CONFIG_FILES_CHANGED_EVENT,
|
||||
ConfigFilesChangedPayload {
|
||||
paths: payload_paths,
|
||||
errors,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn nearest_existing_watch_directory(path: &Path) -> Option<PathBuf> {
|
||||
let mut directory = path.parent();
|
||||
while let Some(candidate) = directory {
|
||||
if candidate.is_dir() {
|
||||
return Some(candidate.to_path_buf());
|
||||
}
|
||||
directory = candidate.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn ensure_configuration_watch_directories(
|
||||
watcher: &mut RecommendedWatcher,
|
||||
tracked_paths: &[PathBuf],
|
||||
watched_directories: &mut Vec<PathBuf>,
|
||||
) -> Result<Vec<PathBuf>, String> {
|
||||
let mut newly_watched = Vec::new();
|
||||
for tracked_path in tracked_paths {
|
||||
let Some(directory) = nearest_existing_watch_directory(tracked_path) else {
|
||||
continue;
|
||||
};
|
||||
let directory = normalized_config_path(&directory);
|
||||
if watched_directories
|
||||
.iter()
|
||||
.any(|watched| watched == &directory)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
watcher
|
||||
.watch(&directory, RecursiveMode::NonRecursive)
|
||||
.map_err(|error| format!("监控配置目录失败 {}: {error}", path_to_string(&directory)))?;
|
||||
watched_directories.push(directory.clone());
|
||||
newly_watched.push(directory);
|
||||
}
|
||||
Ok(newly_watched)
|
||||
}
|
||||
|
||||
pub(crate) fn start_configuration_file_watcher(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let tracked_paths = tracked_configuration_paths(&app)?;
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let mut watcher: RecommendedWatcher = notify::recommended_watcher(
|
||||
move |event: Result<notify::Event, notify::Error>| match event {
|
||||
Ok(event) if event.kind.is_access() => {}
|
||||
event => {
|
||||
let _ = sender.send(event);
|
||||
}
|
||||
},
|
||||
)
|
||||
.map_err(|error| format!("创建配置文件监控器失败: {error}"))?;
|
||||
let mut watched_directories = Vec::new();
|
||||
ensure_configuration_watch_directories(&mut watcher, &tracked_paths, &mut watched_directories)?;
|
||||
|
||||
thread::spawn(move || {
|
||||
let mut watcher = watcher;
|
||||
loop {
|
||||
let first = match receiver.recv() {
|
||||
Ok(event) => event,
|
||||
Err(_) => return,
|
||||
};
|
||||
let mut paths = Vec::new();
|
||||
let mut append_paths = |event_paths: Vec<PathBuf>| {
|
||||
for path in event_paths {
|
||||
paths.retain(|existing| existing != &path);
|
||||
paths.push(path);
|
||||
}
|
||||
};
|
||||
match first {
|
||||
Ok(event) => append_paths(event.paths),
|
||||
Err(error) => eprintln!("配置文件监控错误: {error}"),
|
||||
}
|
||||
while let Ok(event) = receiver.recv_timeout(Duration::from_millis(500)) {
|
||||
match event {
|
||||
Ok(event) => append_paths(event.paths),
|
||||
Err(error) => eprintln!("配置文件监控错误: {error}"),
|
||||
}
|
||||
}
|
||||
match ensure_configuration_watch_directories(
|
||||
&mut watcher,
|
||||
&tracked_paths,
|
||||
&mut watched_directories,
|
||||
) {
|
||||
Ok(new_directories) => {
|
||||
for tracked_path in &tracked_paths {
|
||||
let parent = tracked_path.parent().map(normalized_config_path);
|
||||
if tracked_path.is_file()
|
||||
&& parent.as_ref().is_some_and(|parent| {
|
||||
new_directories.iter().any(|directory| directory == parent)
|
||||
})
|
||||
{
|
||||
append_paths(vec![tracked_path.clone()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => eprintln!("配置目录监控更新失败: {error}"),
|
||||
}
|
||||
handle_configuration_file_changes(&app, paths, &tracked_paths);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Core configuration commands and loss-minimizing YAML editing.
|
||||
|
||||
use super::*;
|
||||
|
||||
mod aliases;
|
||||
mod commands;
|
||||
mod settings;
|
||||
mod yaml;
|
||||
pub(crate) use aliases::*;
|
||||
pub(crate) use commands::*;
|
||||
pub(crate) use settings::*;
|
||||
pub(crate) use yaml::*;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,368 @@
|
||||
use super::*;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_lan_ipv4() -> Option<String> {
|
||||
detect_lan_ipv4().map(|address| address.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn detect_lan_ipv4() -> Option<Ipv4Addr> {
|
||||
for target in ["192.0.2.1:80", "8.8.8.8:80"] {
|
||||
let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) else {
|
||||
continue;
|
||||
};
|
||||
if socket.connect(target).is_err() {
|
||||
continue;
|
||||
}
|
||||
let Ok(local_address) = socket.local_addr() else {
|
||||
continue;
|
||||
};
|
||||
let IpAddr::V4(address) = local_address.ip() else {
|
||||
continue;
|
||||
};
|
||||
if !address.is_unspecified() && !address.is_loopback() {
|
||||
return Some(address);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn save_gui_settings(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
cache: tauri::State<'_, AgentConfigStatusCache>,
|
||||
settings: GuiNetworkSettings,
|
||||
) -> Result<GuiSettings, String> {
|
||||
if settings.port == 0 {
|
||||
return Err("端口必须在 1 到 65535 之间".to_string());
|
||||
}
|
||||
|
||||
let _refresh_guard = cache
|
||||
.refresh_lock
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置状态刷新锁已损坏".to_string())?;
|
||||
let previous = gui_config_state.snapshot()?;
|
||||
let mut next = previous.clone();
|
||||
next.port = settings.port;
|
||||
next.allow_lan = settings.allow_lan;
|
||||
next.host = if settings.allow_lan {
|
||||
"0.0.0.0"
|
||||
} else {
|
||||
"127.0.0.1"
|
||||
}
|
||||
.to_string();
|
||||
patch_core_network_settings(&next)?;
|
||||
let config = match gui_config_state.update_network(settings.port, settings.allow_lan) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error = patch_core_network_settings(&previous).err();
|
||||
return Err(match rollback_error {
|
||||
Some(rollback_error) => {
|
||||
format!("{error};回滚内核网络配置也失败: {rollback_error}")
|
||||
}
|
||||
None => error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if config.port != previous.port {
|
||||
if let Err(error) = cache.clear() {
|
||||
eprintln!("清空智能体配置状态缓存失败: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(GuiSettings::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn save_network_routing_settings(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
cache: tauri::State<'_, AgentConfigStatusCache>,
|
||||
settings: GuiNetworkRoutingSettings,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
if settings.port == 0 {
|
||||
return Err("端口必须在 1 到 65535 之间".to_string());
|
||||
}
|
||||
let proxy_url = normalize_optional_config_string(settings.proxy_url, "代理 URL")?;
|
||||
let routing_session_affinity_ttl =
|
||||
normalize_optional_config_string(settings.routing_session_affinity_ttl, "会话粘性 TTL")?;
|
||||
|
||||
let _refresh_guard = cache
|
||||
.refresh_lock
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置状态刷新锁已损坏".to_string())?;
|
||||
let previous = gui_config_state.snapshot()?;
|
||||
let mut next = previous.clone();
|
||||
next.port = settings.port;
|
||||
next.allow_lan = settings.allow_lan;
|
||||
next.host = if settings.allow_lan {
|
||||
"0.0.0.0"
|
||||
} else {
|
||||
"127.0.0.1"
|
||||
}
|
||||
.to_string();
|
||||
next.proxy_url = proxy_url.clone();
|
||||
next.routing_session_affinity = settings.routing_session_affinity;
|
||||
next.routing_session_affinity_ttl = routing_session_affinity_ttl.clone();
|
||||
validate_gui_config(&next)?;
|
||||
|
||||
patch_core_network_routing_settings(&next)?;
|
||||
let config = match gui_config_state.update_network_routing(
|
||||
settings.port,
|
||||
settings.allow_lan,
|
||||
&proxy_url,
|
||||
settings.routing_session_affinity,
|
||||
&routing_session_affinity_ttl,
|
||||
) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error = patch_core_network_routing_settings(&previous).err();
|
||||
return Err(config_update_error_with_rollback(error, rollback_error));
|
||||
}
|
||||
};
|
||||
|
||||
if config.port != previous.port {
|
||||
if let Err(error) = cache.clear() {
|
||||
eprintln!("清空智能体配置状态缓存失败: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_core_config_settings(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let settings = current_core_config_settings(gui_config_state.inner())?;
|
||||
let config = gui_config_state.sync_core_settings(&settings)?;
|
||||
let api_keys = gui_api_key_values(&config.api_keys);
|
||||
if api_keys != settings.api_keys {
|
||||
patch_core_api_keys(&api_keys)?;
|
||||
}
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn add_core_api_key(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
api_key: String,
|
||||
remark: String,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let api_key = api_key.trim().to_string();
|
||||
let remark = remark.trim().to_string();
|
||||
validate_core_api_key(&api_key)?;
|
||||
validate_api_key_remark(&remark)?;
|
||||
let mut settings = current_core_config_settings(gui_config_state.inner())?;
|
||||
if settings
|
||||
.api_keys
|
||||
.iter()
|
||||
.any(|existing| existing == &api_key)
|
||||
{
|
||||
return Err("该鉴权密钥已经存在".to_string());
|
||||
}
|
||||
settings.api_keys.push(api_key);
|
||||
patch_core_api_keys(&settings.api_keys)?;
|
||||
let added_api_key = settings.api_keys.last().map(|key| GuiApiKeyEntry {
|
||||
key: key.clone(),
|
||||
remark,
|
||||
});
|
||||
let config = gui_config_state.sync_core_settings_with_api_key(&settings, added_api_key)?;
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
pub(crate) fn replace_core_api_key_value(
|
||||
api_keys: &mut [String],
|
||||
original_api_key: &str,
|
||||
replacement_api_key: String,
|
||||
) -> Result<(), String> {
|
||||
let index = api_keys
|
||||
.iter()
|
||||
.position(|existing| existing == original_api_key)
|
||||
.ok_or_else(|| "要编辑的鉴权密钥不存在,请刷新后重试".to_string())?;
|
||||
if replacement_api_key != original_api_key
|
||||
&& api_keys
|
||||
.iter()
|
||||
.any(|existing| existing == &replacement_api_key)
|
||||
{
|
||||
return Err("该鉴权密钥已经存在".to_string());
|
||||
}
|
||||
api_keys[index] = replacement_api_key;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn remove_core_api_key_value(
|
||||
api_keys: &mut Vec<String>,
|
||||
api_key: &str,
|
||||
) -> Result<(), String> {
|
||||
let index = api_keys
|
||||
.iter()
|
||||
.position(|existing| existing == api_key)
|
||||
.ok_or_else(|| "要删除的鉴权密钥不存在,请刷新后重试".to_string())?;
|
||||
api_keys.remove(index);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_core_api_key(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
original_api_key: String,
|
||||
api_key: String,
|
||||
remark: String,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let original_api_key = original_api_key.trim();
|
||||
let api_key = api_key.trim().to_string();
|
||||
let remark = remark.trim().to_string();
|
||||
if original_api_key.is_empty() {
|
||||
return Err("要编辑的鉴权密钥不能为空".to_string());
|
||||
}
|
||||
validate_core_api_key(&api_key)?;
|
||||
validate_api_key_remark(&remark)?;
|
||||
|
||||
let mut settings = current_core_config_settings(gui_config_state.inner())?;
|
||||
replace_core_api_key_value(&mut settings.api_keys, original_api_key, api_key.clone())?;
|
||||
patch_core_api_keys(&settings.api_keys)?;
|
||||
let config = gui_config_state.sync_core_settings_with_api_key(
|
||||
&settings,
|
||||
Some(GuiApiKeyEntry {
|
||||
key: api_key,
|
||||
remark,
|
||||
}),
|
||||
)?;
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn delete_core_api_key(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
api_key: String,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let api_key = api_key.trim();
|
||||
if api_key.is_empty() {
|
||||
return Err("要删除的鉴权密钥不能为空".to_string());
|
||||
}
|
||||
let mut settings = current_core_config_settings(gui_config_state.inner())?;
|
||||
remove_core_api_key_value(&mut settings.api_keys, api_key)?;
|
||||
patch_core_api_keys(&settings.api_keys)?;
|
||||
let config = gui_config_state.sync_core_settings(&settings)?;
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_core_management_secret_key(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
secret_key: String,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let secret_key = normalize_management_secret_key(secret_key)?;
|
||||
let previous = gui_config_state.snapshot()?;
|
||||
patch_core_management_secret_key(&secret_key)?;
|
||||
let config = match gui_config_state.set_management_secret_key(secret_key) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error =
|
||||
patch_core_management_secret_key(&previous.management_secret_key).err();
|
||||
return Err(config_update_error_with_rollback(error, rollback_error));
|
||||
}
|
||||
};
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn clear_core_management_secret_key(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let previous = gui_config_state.snapshot()?;
|
||||
patch_core_management_secret_key("")?;
|
||||
let config = match gui_config_state.set_management_secret_key(String::new()) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error =
|
||||
patch_core_management_secret_key(&previous.management_secret_key).err();
|
||||
return Err(config_update_error_with_rollback(error, rollback_error));
|
||||
}
|
||||
};
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_core_plugins_enabled(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
enabled: bool,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let mut settings = current_core_config_settings(gui_config_state.inner())?;
|
||||
settings.plugins_enabled = enabled;
|
||||
patch_core_plugins_enabled(settings.plugins_enabled)?;
|
||||
let config = gui_config_state.sync_core_settings(&settings)?;
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_core_routing_strategy(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
strategy: String,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
validate_routing_strategy(&strategy)?;
|
||||
let mut settings = current_core_config_settings(gui_config_state.inner())?;
|
||||
settings.routing_strategy = strategy;
|
||||
patch_core_routing_strategy(&settings.routing_strategy)?;
|
||||
let config = gui_config_state.sync_core_settings(&settings)?;
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_core_proxy_url(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
proxy_url: String,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let proxy_url = normalize_optional_config_string(proxy_url, "代理 URL")?;
|
||||
let mut settings = current_core_config_settings(gui_config_state.inner())?;
|
||||
let previous_proxy_url = settings.proxy_url.clone();
|
||||
settings.proxy_url = proxy_url;
|
||||
patch_core_proxy_url(&settings.proxy_url)?;
|
||||
let config = match gui_config_state.sync_core_settings(&settings) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error = patch_core_proxy_url(&previous_proxy_url).err();
|
||||
return Err(config_update_error_with_rollback(error, rollback_error));
|
||||
}
|
||||
};
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_core_session_affinity(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
enabled: bool,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let mut settings = current_core_config_settings(gui_config_state.inner())?;
|
||||
let previous_enabled = settings.routing_session_affinity;
|
||||
settings.routing_session_affinity = enabled;
|
||||
patch_core_session_affinity(settings.routing_session_affinity)?;
|
||||
let config = match gui_config_state.sync_core_settings(&settings) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error = patch_core_session_affinity(previous_enabled).err();
|
||||
return Err(config_update_error_with_rollback(error, rollback_error));
|
||||
}
|
||||
};
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_core_session_affinity_ttl(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
ttl: String,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let ttl = normalize_optional_config_string(ttl, "会话粘性 TTL")?;
|
||||
let mut settings = current_core_config_settings(gui_config_state.inner())?;
|
||||
let previous_ttl = settings.routing_session_affinity_ttl.clone();
|
||||
settings.routing_session_affinity_ttl = ttl;
|
||||
patch_core_session_affinity_ttl(&settings.routing_session_affinity_ttl)?;
|
||||
let config = match gui_config_state.sync_core_settings(&settings) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error = patch_core_session_affinity_ttl(&previous_ttl).err();
|
||||
return Err(config_update_error_with_rollback(error, rollback_error));
|
||||
}
|
||||
};
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+35
-21834
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,405 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
use super::windows_explorer_executable;
|
||||
use super::{
|
||||
auth_dir_path_for_core, configure_background_command, core_install_dir,
|
||||
is_hashed_management_secret_key, open_external_url_inner, path_to_string, truncate_for_error,
|
||||
GuiConfigFile, GuiConfigState,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OAuthStartResult {
|
||||
url: String,
|
||||
state: Option<String>,
|
||||
opened: bool,
|
||||
open_error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OAuthStatusResult {
|
||||
status: String,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OAuthStartApiResponse {
|
||||
url: Option<String>,
|
||||
state: Option<String>,
|
||||
error: Option<String>,
|
||||
#[serde(rename = "error_message")]
|
||||
error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OAuthStatusApiResponse {
|
||||
status: Option<String>,
|
||||
error: Option<String>,
|
||||
#[serde(rename = "error_message")]
|
||||
error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ManagementRequest {
|
||||
method: String,
|
||||
path: String,
|
||||
query: Option<HashMap<String, String>>,
|
||||
body: Option<serde_json::Value>,
|
||||
#[serde(rename = "timeoutMs")]
|
||||
timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn management_request(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
request: ManagementRequest,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let method = match request.method.trim().to_ascii_uppercase().as_str() {
|
||||
"GET" => reqwest::Method::GET,
|
||||
"POST" => reqwest::Method::POST,
|
||||
"PUT" => reqwest::Method::PUT,
|
||||
"PATCH" => reqwest::Method::PATCH,
|
||||
"DELETE" => reqwest::Method::DELETE,
|
||||
_ => return Err("不支持的管理 API 请求方法".to_string()),
|
||||
};
|
||||
let path = request.path.trim();
|
||||
if path.is_empty() || path.contains("://") || path.contains("..") {
|
||||
return Err("无效的管理 API 路径".to_string());
|
||||
}
|
||||
|
||||
let client = management_http_client()?;
|
||||
let mut builder = client
|
||||
.request(method, management_endpoint(&config, path)?)
|
||||
.header("Authorization", management_authorization(&config)?);
|
||||
if let Some(timeout_ms) = request.timeout_ms {
|
||||
builder = builder.timeout(Duration::from_millis(timeout_ms.clamp(1_000, 120_000)));
|
||||
}
|
||||
if let Some(query) = request.query {
|
||||
builder = builder.query(&query);
|
||||
}
|
||||
if let Some(body) = request.body {
|
||||
builder = builder.json(&body);
|
||||
}
|
||||
|
||||
let response = builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("请求管理 API 失败: {err}"))?;
|
||||
read_management_value(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn upload_auth_file(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
name: String,
|
||||
data: Vec<u8>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let name = name.trim().to_string();
|
||||
if name.is_empty() || !name.to_ascii_lowercase().ends_with(".json") {
|
||||
return Err("认证文件名必须以 .json 结尾".to_string());
|
||||
}
|
||||
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let client = management_http_client()?;
|
||||
let mut query = HashMap::new();
|
||||
query.insert("name".to_string(), name);
|
||||
let response = client
|
||||
.post(management_endpoint(&config, "auth-files")?)
|
||||
.header("Authorization", management_authorization(&config)?)
|
||||
.query(&query)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(data)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("上传认证文件失败: {err}"))?;
|
||||
read_management_value(response).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_auth_files_directory(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
) -> Result<(), String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let install_dir = core_install_dir()?;
|
||||
let auth_dir = auth_dir_path_for_core(&config.auth_dir, &install_dir);
|
||||
fs::create_dir_all(&auth_dir)
|
||||
.map_err(|error| format!("创建凭证目录失败 {}: {error}", path_to_string(&auth_dir)))?;
|
||||
open_directory_in_file_manager(&auth_dir)
|
||||
}
|
||||
|
||||
fn open_directory_in_file_manager(path: &Path) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut command = Command::new(windows_explorer_executable());
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut command = Command::new("open");
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut command = Command::new("xdg-open");
|
||||
|
||||
command.arg(path);
|
||||
command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
configure_background_command(&mut command);
|
||||
command
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("打开凭证目录失败 {}: {error}", path_to_string(path)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn start_oauth_login(
|
||||
app: tauri::AppHandle,
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
provider: String,
|
||||
) -> Result<OAuthStartResult, String> {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let provider_key = normalize_management_oauth_provider(&provider)?;
|
||||
let client = management_http_client()?;
|
||||
let mut request = client
|
||||
.get(management_endpoint(
|
||||
&config,
|
||||
&format!("{provider_key}-auth-url"),
|
||||
)?)
|
||||
.header("Authorization", management_authorization(&config)?);
|
||||
if management_oauth_uses_webui_callback(&provider_key) {
|
||||
request = request.query(&[("is_webui", "true")]);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("请求 OAuth 登录链接失败: {err}"))?;
|
||||
let payload = read_management_json::<OAuthStartApiResponse>(response).await?;
|
||||
if let Some(error) = payload
|
||||
.error
|
||||
.or(payload.error_message)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
let url = payload
|
||||
.url
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "内核未返回 OAuth 登录链接".to_string())?;
|
||||
let state = payload
|
||||
.state
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let (opened, open_error) = match open_external_url_inner(&app, &url) {
|
||||
Ok(()) => (true, None),
|
||||
Err(error) => (false, Some(error)),
|
||||
};
|
||||
|
||||
Ok(OAuthStartResult {
|
||||
url,
|
||||
state,
|
||||
opened,
|
||||
open_error,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_oauth_status(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
state: String,
|
||||
) -> Result<OAuthStatusResult, String> {
|
||||
let state = state.trim().to_string();
|
||||
if state.is_empty() {
|
||||
return Err("OAuth state 不能为空".to_string());
|
||||
}
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let client = management_http_client()?;
|
||||
let response = client
|
||||
.get(management_endpoint(&config, "get-auth-status")?)
|
||||
.header("Authorization", management_authorization(&config)?)
|
||||
.query(&[("state", state)])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("查询 OAuth 状态失败: {err}"))?;
|
||||
let payload = read_management_json::<OAuthStatusApiResponse>(response).await?;
|
||||
let status = payload
|
||||
.status
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "wait".to_string());
|
||||
Ok(OAuthStatusResult {
|
||||
status,
|
||||
error: payload
|
||||
.error
|
||||
.or(payload.error_message)
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn submit_oauth_callback(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
provider: String,
|
||||
redirect_url: String,
|
||||
) -> Result<(), String> {
|
||||
let redirect_url = redirect_url.trim().to_string();
|
||||
if redirect_url.is_empty() {
|
||||
return Err("回调链接不能为空".to_string());
|
||||
}
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let provider_key = normalize_management_oauth_provider(&provider)?;
|
||||
let client = management_http_client()?;
|
||||
let body = serde_json::json!({
|
||||
"provider": provider_key,
|
||||
"redirect_url": redirect_url,
|
||||
});
|
||||
let response = client
|
||||
.post(management_endpoint(&config, "oauth-callback")?)
|
||||
.header("Authorization", management_authorization(&config)?)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("提交 OAuth 回调失败: {err}"))?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("读取 OAuth 回调响应失败: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format_management_error(status.as_u16(), &text));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn management_http_client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|err| format!("创建管理 API 客户端失败: {err}"))
|
||||
}
|
||||
|
||||
pub(crate) fn management_authorization(config: &GuiConfigFile) -> Result<String, String> {
|
||||
let secret_key = config.management_secret_key.trim();
|
||||
if secret_key.is_empty() || is_hashed_management_secret_key(secret_key) {
|
||||
return Err("管理接口不可用:没有可用的明文管理密钥".to_string());
|
||||
}
|
||||
Ok(format!("Bearer {secret_key}"))
|
||||
}
|
||||
|
||||
pub(crate) fn management_endpoint(config: &GuiConfigFile, path: &str) -> Result<String, String> {
|
||||
if config.port == 0 {
|
||||
return Err("内核端口无效".to_string());
|
||||
}
|
||||
let path = path.trim_start_matches('/');
|
||||
Ok(format!(
|
||||
"http://127.0.0.1:{}/v0/management/{path}",
|
||||
config.port
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_management_oauth_provider(provider: &str) -> Result<String, String> {
|
||||
let key = provider.trim().to_ascii_lowercase().replace('_', "-");
|
||||
let key = match key.as_str() {
|
||||
"claude" | "anthropic" => "anthropic".to_string(),
|
||||
"anti-gravity" => "antigravity".to_string(),
|
||||
"grok" | "x-ai" | "x.ai" => "xai".to_string(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
if key.is_empty()
|
||||
|| !key
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
{
|
||||
return Err("无效的 OAuth 提供商".to_string());
|
||||
}
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn management_oauth_uses_webui_callback(provider_key: &str) -> bool {
|
||||
matches!(provider_key, "codex" | "anthropic" | "antigravity" | "xai")
|
||||
}
|
||||
|
||||
async fn read_management_json<T>(response: reqwest::Response) -> Result<T, String>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("读取管理 API 响应失败: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format_management_error(status.as_u16(), &text));
|
||||
}
|
||||
if text.trim().is_empty() {
|
||||
return Err("管理 API 返回了空响应".to_string());
|
||||
}
|
||||
serde_json::from_str::<T>(&text).map_err(|err| {
|
||||
format!(
|
||||
"解析管理 API 响应失败: {err}; body={}",
|
||||
truncate_for_error(&text)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn read_management_value(
|
||||
response: reqwest::Response,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("读取管理 API 响应失败: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format_management_error(status.as_u16(), &text));
|
||||
}
|
||||
if text.trim().is_empty() {
|
||||
return Ok(serde_json::Value::Null);
|
||||
}
|
||||
match serde_json::from_str::<serde_json::Value>(&text) {
|
||||
Ok(value) => Ok(value),
|
||||
Err(_) => Ok(serde_json::Value::String(text)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_management_text(response: reqwest::Response) -> Result<String, String> {
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|err| format!("读取管理 API 响应失败: {err}"))?;
|
||||
if !status.is_success() {
|
||||
return Err(format_management_error(status.as_u16(), &text));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn format_management_error(status: u16, body: &str) -> String {
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
|
||||
if let Some(message) = value
|
||||
.get("error")
|
||||
.and_then(|item| item.as_str())
|
||||
.or_else(|| value.get("message").and_then(|item| item.as_str()))
|
||||
{
|
||||
let message = message.trim();
|
||||
if !message.is_empty() {
|
||||
return format!("管理 API 错误 ({status}): {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
format!("管理 API 错误 ({status})")
|
||||
} else {
|
||||
format!("管理 API 错误 ({status}): {}", truncate_for_error(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
use super::{build_http_client_with_proxy, is_loopback_host, GuiConfigState, APP_USER_AGENT};
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
const MAX_PROVIDER_HEALTH_STREAM_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderHealthProbeRequest {
|
||||
url: String,
|
||||
header: HashMap<String, String>,
|
||||
data: String,
|
||||
protocol: String,
|
||||
timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderHealthProbeResponse {
|
||||
first_token_latency_ms: u64,
|
||||
}
|
||||
|
||||
fn provider_health_value_has_text(value: Option<&serde_json::Value>) -> bool {
|
||||
match value {
|
||||
Some(serde_json::Value::String(text)) => !text.trim().is_empty(),
|
||||
Some(serde_json::Value::Array(items)) => items
|
||||
.iter()
|
||||
.any(|item| provider_health_value_has_text(Some(item))),
|
||||
Some(serde_json::Value::Object(object)) => ["text", "content"]
|
||||
.iter()
|
||||
.any(|key| provider_health_value_has_text(object.get(*key))),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_health_json_has_text(protocol: &str, value: &serde_json::Value) -> bool {
|
||||
match protocol {
|
||||
"openai-chat" => value
|
||||
.get("choices")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|choices| {
|
||||
choices.iter().any(|choice| {
|
||||
provider_health_value_has_text(choice.pointer("/delta/content"))
|
||||
|| provider_health_value_has_text(
|
||||
choice.pointer("/delta/reasoning_content"),
|
||||
)
|
||||
|| provider_health_value_has_text(choice.pointer("/delta/reasoning"))
|
||||
|| provider_health_value_has_text(choice.pointer("/delta/thinking"))
|
||||
|| provider_health_value_has_text(choice.pointer("/message/content"))
|
||||
})
|
||||
}),
|
||||
"openai-responses" => {
|
||||
(matches!(
|
||||
value.get("type").and_then(serde_json::Value::as_str),
|
||||
Some(
|
||||
"response.output_text.delta"
|
||||
| "response.reasoning_text.delta"
|
||||
| "response.reasoning_summary_text.delta"
|
||||
)
|
||||
) && provider_health_value_has_text(value.get("delta")))
|
||||
|| value
|
||||
.get("output")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|output| {
|
||||
output.iter().any(|item| {
|
||||
item.get("content")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|content| {
|
||||
content.iter().any(|part| {
|
||||
provider_health_value_has_text(part.get("text"))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
"claude" => {
|
||||
provider_health_value_has_text(value.pointer("/delta/text"))
|
||||
|| provider_health_value_has_text(value.pointer("/delta/thinking"))
|
||||
|| value
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|content| {
|
||||
content
|
||||
.iter()
|
||||
.any(|part| provider_health_value_has_text(part.get("text")))
|
||||
})
|
||||
}
|
||||
"gemini" => value
|
||||
.get("candidates")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|candidates| {
|
||||
candidates.iter().any(|candidate| {
|
||||
candidate
|
||||
.pointer("/content/parts")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.is_some_and(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.any(|part| provider_health_value_has_text(part.get("text")))
|
||||
})
|
||||
})
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn provider_health_stream_has_text(protocol: &str, bytes: &[u8]) -> bool {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
text.lines().any(|line| {
|
||||
let line = line.trim();
|
||||
let data = line.strip_prefix("data:").map(str::trim).unwrap_or(line);
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
return false;
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(data)
|
||||
.ok()
|
||||
.is_some_and(|value| provider_health_json_has_text(protocol, &value))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn provider_health_content_type_is_streaming(content_type: &str) -> bool {
|
||||
let content_type = content_type.to_ascii_lowercase();
|
||||
content_type.contains("text/event-stream")
|
||||
|| content_type.contains("application/x-ndjson")
|
||||
|| content_type.contains("application/json-seq")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn provider_health_probe(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
request: ProviderHealthProbeRequest,
|
||||
) -> Result<ProviderHealthProbeResponse, String> {
|
||||
let url = reqwest::Url::parse(request.url.trim())
|
||||
.map_err(|error| format!("健康检测地址无效: {error}"))?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
|
||||
return Err("健康检测仅支持 HTTP 或 HTTPS 地址".to_string());
|
||||
}
|
||||
if !matches!(
|
||||
request.protocol.as_str(),
|
||||
"openai-chat" | "openai-responses" | "claude" | "gemini"
|
||||
) {
|
||||
return Err("不支持的健康检测协议".to_string());
|
||||
}
|
||||
if request.data.len() > 64 * 1024 {
|
||||
return Err("健康检测请求体过大".to_string());
|
||||
}
|
||||
|
||||
let timeout = Duration::from_millis(request.timeout_ms.unwrap_or(15_000).clamp(1_000, 120_000));
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let proxy_url = url
|
||||
.host_str()
|
||||
.filter(|host| !is_loopback_host(host))
|
||||
.map(|_| config.proxy_url.as_str())
|
||||
.unwrap_or_default();
|
||||
let client = build_http_client_with_proxy(
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(timeout),
|
||||
proxy_url,
|
||||
"创建健康检测客户端失败",
|
||||
)?;
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
for (name, value) in request.header {
|
||||
let name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
|
||||
.map_err(|error| format!("健康检测请求头名称无效: {error}"))?;
|
||||
let value = reqwest::header::HeaderValue::from_str(&value)
|
||||
.map_err(|error| format!("健康检测请求头值无效: {error}"))?;
|
||||
headers.insert(name, value);
|
||||
}
|
||||
if !headers.contains_key(reqwest::header::USER_AGENT) {
|
||||
headers.insert(
|
||||
reqwest::header::USER_AGENT,
|
||||
reqwest::header::HeaderValue::from_static(APP_USER_AGENT),
|
||||
);
|
||||
}
|
||||
|
||||
let started_at = Instant::now();
|
||||
let response = client
|
||||
.post(url)
|
||||
.headers(headers)
|
||||
.body(request.data)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("健康检测请求失败: {error}"))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let detail = response.text().await.unwrap_or_default();
|
||||
let detail = detail.trim();
|
||||
return Err(if detail.is_empty() {
|
||||
format!("上游返回 HTTP {}", status.as_u16())
|
||||
} else {
|
||||
format!("上游返回 HTTP {}: {}", status.as_u16(), detail)
|
||||
});
|
||||
}
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if !provider_health_content_type_is_streaming(&content_type) {
|
||||
return Err("上游未返回流式响应,无法测量首字延迟".to_string());
|
||||
}
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut received = Vec::new();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|error| format!("读取健康检测流失败: {error}"))?;
|
||||
if received.len().saturating_add(chunk.len()) > MAX_PROVIDER_HEALTH_STREAM_BYTES {
|
||||
return Err("健康检测在限制范围内未收到模型首字".to_string());
|
||||
}
|
||||
received.extend_from_slice(&chunk);
|
||||
if provider_health_stream_has_text(&request.protocol, &received) {
|
||||
return Ok(ProviderHealthProbeResponse {
|
||||
first_token_latency_ms: started_at.elapsed().as_millis().max(1) as u64,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err("健康检测未收到模型首字".to_string())
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use super::*;
|
||||
|
||||
mod agent_configuration;
|
||||
mod agent_state;
|
||||
mod agent_transactions;
|
||||
mod app_settings;
|
||||
mod app_update;
|
||||
mod core_config;
|
||||
mod core_runtime;
|
||||
mod model_aliases;
|
||||
mod platform;
|
||||
mod provider_health;
|
||||
mod support;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,503 @@
|
||||
use super::support::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn codex_agent_modification_merges_external_edits_and_restores_managed_fields() {
|
||||
let home = agent_test_home("codex-organic-merge");
|
||||
let path = home.join(".codex/config.toml");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let original = br#"# original comment
|
||||
model_provider = "user-provider"
|
||||
model = "user-model"
|
||||
model_catalog_json = "user-catalog.json"
|
||||
approval_policy = "on-request"
|
||||
|
||||
[model_providers.user-provider]
|
||||
name = "User Provider"
|
||||
base_url = "https://example.com/v1"
|
||||
|
||||
[model_providers.cpa-gui]
|
||||
name = "Existing CPA"
|
||||
base_url = "https://existing.invalid/v1"
|
||||
wire_api = "chat"
|
||||
experimental_bearer_token = "existing-token"
|
||||
custom_option = "keep-original"
|
||||
"#;
|
||||
fs::write(&path, original).unwrap();
|
||||
let hard_link = path.with_file_name("config-hard-link.toml");
|
||||
fs::hard_link(&path, &hard_link).unwrap();
|
||||
|
||||
let available_models = test_agent_models(&["gpt-one", "gpt-two", "gpt-three"]);
|
||||
let codex_models = test_codex_models(&["gpt-one", "gpt-two", "gpt-three"]);
|
||||
let enabled = enable_agent_modification(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
"gpt-one",
|
||||
&available_models,
|
||||
Some(&codex_models),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(enabled.outcome, "enabled");
|
||||
let backup = agent_backup_path(&path).unwrap();
|
||||
let state = agent_state_path(std::slice::from_ref(&path)).unwrap();
|
||||
let catalog_path = codex_model_catalog_path(&home);
|
||||
assert_eq!(fs::read(&backup).unwrap(), original);
|
||||
assert!(state.is_file());
|
||||
assert!(catalog_path.is_file());
|
||||
assert_eq!(fs::read(&hard_link).unwrap(), fs::read(&path).unwrap());
|
||||
assert!(fs::read_to_string(&hard_link)
|
||||
.unwrap()
|
||||
.contains(MANAGED_AGENT_PROVIDER_ID));
|
||||
|
||||
let mut externally_edited = fs::read_to_string(&path)
|
||||
.unwrap()
|
||||
.parse::<toml_edit::Document>()
|
||||
.unwrap();
|
||||
externally_edited["approval_policy"] = toml_edit::value("never");
|
||||
externally_edited["custom_after_enable"] = toml_edit::value(true);
|
||||
externally_edited["model_provider"] = toml_edit::value("external-provider");
|
||||
externally_edited["model"] = toml_edit::value("external-model");
|
||||
let provider = externally_edited["model_providers"]
|
||||
.as_table_mut()
|
||||
.unwrap()
|
||||
.get_mut(MANAGED_AGENT_PROVIDER_ID)
|
||||
.and_then(toml_edit::Item::as_table_mut)
|
||||
.unwrap();
|
||||
provider["base_url"] = toml_edit::value("https://external.invalid/v1");
|
||||
provider["custom_option"] = toml_edit::value("keep-updated");
|
||||
fs::write(&path, externally_edited.to_string()).unwrap();
|
||||
|
||||
let (configured, current_model, _) =
|
||||
inspect_codex_agent_config(&path, 8317, DEFAULT_API_KEY).unwrap();
|
||||
let inspection = inspect_agent_modification(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
configured,
|
||||
current_model.as_deref(),
|
||||
);
|
||||
assert!(inspection.enabled);
|
||||
assert_eq!(inspection.state, "active");
|
||||
assert!(inspection
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("自动重新同步")));
|
||||
|
||||
let updated = update_agent_modification(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
"gpt-three",
|
||||
&available_models,
|
||||
Some(&codex_models),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.outcome, "updated");
|
||||
assert_eq!(fs::read(&backup).unwrap(), original);
|
||||
assert_eq!(fs::read(&hard_link).unwrap(), fs::read(&path).unwrap());
|
||||
let managed: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
managed["model_provider"].as_str(),
|
||||
Some(MANAGED_AGENT_PROVIDER_ID)
|
||||
);
|
||||
assert_eq!(managed["model"].as_str(), Some("gpt-three"));
|
||||
assert_eq!(managed["approval_policy"].as_str(), Some("never"));
|
||||
assert_eq!(managed["custom_after_enable"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
managed["model_providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(),
|
||||
Some("http://127.0.0.1:8317/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
managed["model_providers"][MANAGED_AGENT_PROVIDER_ID]["custom_option"].as_str(),
|
||||
Some("keep-updated")
|
||||
);
|
||||
|
||||
let restored = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
|
||||
assert_eq!(restored.outcome, "disabled");
|
||||
assert!(restored.conflict_files.is_empty());
|
||||
let restored_content = fs::read_to_string(&path).unwrap();
|
||||
let restored_value: toml::Value = toml::from_str(&restored_content).unwrap();
|
||||
assert!(restored_content.contains("# original comment"));
|
||||
assert_eq!(
|
||||
restored_value["model_provider"].as_str(),
|
||||
Some("user-provider")
|
||||
);
|
||||
assert_eq!(restored_value["model"].as_str(), Some("user-model"));
|
||||
assert_eq!(
|
||||
restored_value["model_catalog_json"].as_str(),
|
||||
Some("user-catalog.json")
|
||||
);
|
||||
assert_eq!(restored_value["approval_policy"].as_str(), Some("never"));
|
||||
assert_eq!(restored_value["custom_after_enable"].as_bool(), Some(true));
|
||||
assert_eq!(
|
||||
restored_value["model_providers"][MANAGED_AGENT_PROVIDER_ID]["name"].as_str(),
|
||||
Some("Existing CPA")
|
||||
);
|
||||
assert_eq!(
|
||||
restored_value["model_providers"][MANAGED_AGENT_PROVIDER_ID]["base_url"].as_str(),
|
||||
Some("https://existing.invalid/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
restored_value["model_providers"][MANAGED_AGENT_PROVIDER_ID]["custom_option"].as_str(),
|
||||
Some("keep-updated")
|
||||
);
|
||||
assert_eq!(
|
||||
restored_value["model_providers"]["user-provider"]["base_url"].as_str(),
|
||||
Some("https://example.com/v1")
|
||||
);
|
||||
assert_eq!(fs::read(&hard_link).unwrap(), fs::read(&path).unwrap());
|
||||
assert!(!catalog_path.exists());
|
||||
assert!(!backup.exists());
|
||||
assert!(!state.exists());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_disable_keeps_user_configuration_added_after_enable() {
|
||||
let home = agent_test_home("codex-created-config");
|
||||
let path = home.join(".codex/config.toml");
|
||||
let models = test_agent_models(&["gpt-test"]);
|
||||
let codex_models = test_codex_models(&["gpt-test"]);
|
||||
|
||||
enable_agent_modification(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
"gpt-test",
|
||||
&models,
|
||||
Some(&codex_models),
|
||||
)
|
||||
.unwrap();
|
||||
let mut edited = fs::read_to_string(&path)
|
||||
.unwrap()
|
||||
.parse::<toml_edit::Document>()
|
||||
.unwrap();
|
||||
edited["approval_policy"] = toml_edit::value("never");
|
||||
fs::write(&path, edited.to_string()).unwrap();
|
||||
|
||||
let result = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
|
||||
assert_eq!(result.outcome, "disabled");
|
||||
let restored_content = fs::read_to_string(&path).unwrap();
|
||||
let restored: toml::Value = toml::from_str(&restored_content).unwrap();
|
||||
assert_eq!(restored["approval_policy"].as_str(), Some("never"));
|
||||
assert!(restored.get("model_provider").is_none());
|
||||
assert!(restored.get("model").is_none());
|
||||
assert!(restored.get("model_catalog_json").is_none());
|
||||
assert!(restored.get("model_providers").is_none());
|
||||
assert!(!codex_model_catalog_path(&home).exists());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_modification_removes_files_that_did_not_exist_before_enable() {
|
||||
let home = agent_test_home("opencode-absent");
|
||||
let path = home.join(".config/opencode/opencode.json");
|
||||
|
||||
let models = test_agent_models(&["gpt-test"]);
|
||||
enable_agent_modification(
|
||||
AgentClient::OpenCode,
|
||||
&home,
|
||||
8317,
|
||||
"gpt-test",
|
||||
&models,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(path.is_file());
|
||||
assert!(!agent_backup_path(&path).unwrap().exists());
|
||||
|
||||
disable_agent_modification(AgentClient::OpenCode, &home, 8317, false).unwrap();
|
||||
assert!(!path.exists());
|
||||
assert!(!agent_state_path(&[path]).unwrap().exists());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_enable_discards_backup_when_state_cannot_be_written() {
|
||||
let home = agent_test_home("state-write-failure");
|
||||
let path = home.join(".codex/config.toml");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, b"approval_policy = \"never\"\n").unwrap();
|
||||
let state_path = agent_state_path(std::slice::from_ref(&path)).unwrap();
|
||||
fs::create_dir(&state_path).unwrap();
|
||||
|
||||
let available_models = test_agent_models(&["gpt-test"]);
|
||||
let codex_models = test_codex_models(&["gpt-test"]);
|
||||
assert!(enable_agent_modification(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
"gpt-test",
|
||||
&available_models,
|
||||
Some(&codex_models),
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), b"approval_policy = \"never\"\n");
|
||||
assert!(!agent_backup_path(&path).unwrap().exists());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_code_keeps_using_the_path_that_owns_active_state() {
|
||||
let home = agent_test_home("claude-path-state");
|
||||
let directory = home.join(".claude");
|
||||
let settings = directory.join("settings.json");
|
||||
let legacy = directory.join("claude.json");
|
||||
fs::create_dir_all(&directory).unwrap();
|
||||
fs::write(&settings, b"{}\n").unwrap();
|
||||
fs::write(&legacy, b"{}\n").unwrap();
|
||||
fs::write(
|
||||
agent_state_path(std::slice::from_ref(&legacy)).unwrap(),
|
||||
b"{}\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
agent_config_paths(AgentClient::ClaudeCode, &home),
|
||||
vec![legacy]
|
||||
);
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_agent_backup_restores_even_when_gui_port_changed() {
|
||||
let home = agent_test_home("legacy-port");
|
||||
let path = home.join(".codex/config.toml");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let original = b"approval_policy = \"never\"\n";
|
||||
fs::write(agent_backup_path(&path).unwrap(), original).unwrap();
|
||||
fs::write(
|
||||
&path,
|
||||
build_codex_agent_config(
|
||||
Some(std::str::from_utf8(original).unwrap()),
|
||||
"http://127.0.0.1:9999/v1",
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-legacy",
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(agent_has_managed_marker(AgentClient::Codex, std::slice::from_ref(&path)).unwrap());
|
||||
let result = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
|
||||
assert_eq!(result.outcome, "disabled");
|
||||
assert_eq!(fs::read(&path).unwrap(), original);
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_generated_only_agent_config_is_removed_without_backup() {
|
||||
let home = agent_test_home("legacy-generated");
|
||||
let path = home.join(".codex/config.toml");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(
|
||||
&path,
|
||||
build_codex_agent_config(
|
||||
None,
|
||||
"http://127.0.0.1:8317/v1",
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-generated",
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
|
||||
assert_eq!(result.outcome, "disabled");
|
||||
assert!(!path.exists());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updating_legacy_codex_state_adds_catalog_without_replacing_original_backup() {
|
||||
let home = agent_test_home("legacy-catalog-upgrade");
|
||||
let path = home.join(".codex/config.toml");
|
||||
let catalog_path = codex_model_catalog_path(&home);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let original_config = b"approval_policy = \"never\"\n";
|
||||
let original_catalog = b"{\"models\":[{\"slug\":\"user-model\"}]}\n";
|
||||
fs::write(agent_backup_path(&path).unwrap(), original_config).unwrap();
|
||||
fs::write(
|
||||
&path,
|
||||
build_codex_agent_config(
|
||||
Some(std::str::from_utf8(original_config).unwrap()),
|
||||
"http://127.0.0.1:8317/v1",
|
||||
DEFAULT_API_KEY,
|
||||
"gpt-old",
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&catalog_path, original_catalog).unwrap();
|
||||
let mut record = build_legacy_agent_record(AgentClient::Codex, &home, 8317, "gpt-old")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
record.version = LEGACY_AGENT_MODIFICATION_STATE_VERSION;
|
||||
assert_eq!(record.files.len(), 1);
|
||||
write_agent_state(
|
||||
&agent_state_path(std::slice::from_ref(&path)).unwrap(),
|
||||
&record,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let available_models = test_agent_models(&["gpt-new"]);
|
||||
let codex_models = test_codex_models(&["gpt-new"]);
|
||||
update_agent_modification(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
"gpt-new",
|
||||
&available_models,
|
||||
Some(&codex_models),
|
||||
)
|
||||
.unwrap();
|
||||
let upgraded = load_agent_record(AgentClient::Codex, std::slice::from_ref(&path))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(upgraded.version, AGENT_MODIFICATION_STATE_VERSION);
|
||||
assert_eq!(upgraded.files.len(), 2);
|
||||
assert_eq!(
|
||||
fs::read(agent_backup_path(&path).unwrap()).unwrap(),
|
||||
original_config
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(agent_backup_path(&catalog_path).unwrap()).unwrap(),
|
||||
original_catalog
|
||||
);
|
||||
fs::write(&catalog_path, b"{\"models\":[]}\n").unwrap();
|
||||
|
||||
disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), original_config);
|
||||
assert_eq!(fs::read(&catalog_path).unwrap(), original_catalog);
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_agent_state_is_reported_as_recovery() {
|
||||
let home = agent_test_home("recovery-state");
|
||||
let path = home.join(".codex/config.toml");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, "approval_policy = \"never\"\n").unwrap();
|
||||
let available_models = test_agent_models(&["gpt-test"]);
|
||||
let codex_models = test_codex_models(&["gpt-test"]);
|
||||
enable_agent_modification(
|
||||
AgentClient::Codex,
|
||||
&home,
|
||||
8317,
|
||||
"gpt-test",
|
||||
&available_models,
|
||||
Some(&codex_models),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let state_path = agent_state_path(std::slice::from_ref(&path)).unwrap();
|
||||
let mut record = load_agent_record(AgentClient::Codex, &[path])
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
record.phase = AGENT_PHASE_APPLYING.to_string();
|
||||
write_agent_state(&state_path, &record).unwrap();
|
||||
let inspection =
|
||||
inspect_agent_modification(AgentClient::Codex, &home, 8317, true, Some("gpt-test"));
|
||||
assert!(inspection.enabled);
|
||||
assert_eq!(inspection.state, "recovery");
|
||||
|
||||
disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_backup_protocol_restores_multiple_files_and_removes_created_files() {
|
||||
let home = agent_test_home("multi-file");
|
||||
let first = home.join("first.json");
|
||||
let second = home.join("second.json");
|
||||
fs::write(&first, b"{\"original\":true}\n").unwrap();
|
||||
let updates = vec![
|
||||
AgentFileUpdate {
|
||||
path: first.clone(),
|
||||
after: "{\"managed\":1}\n".to_string(),
|
||||
},
|
||||
AgentFileUpdate {
|
||||
path: second.clone(),
|
||||
after: "{\"managed\":2}\n".to_string(),
|
||||
},
|
||||
];
|
||||
let record = prepare_agent_record(
|
||||
AgentClient::ClaudeDesktop,
|
||||
&[first.clone(), second.clone()],
|
||||
"claude-test",
|
||||
&updates,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
apply_agent_updates(AgentClient::ClaudeDesktop, &updates).unwrap();
|
||||
assert!(first.is_file());
|
||||
assert!(second.is_file());
|
||||
restore_agent_record_files(AgentClient::ClaudeDesktop, &record).unwrap();
|
||||
assert_eq!(fs::read_to_string(&first).unwrap(), "{\"original\":true}\n");
|
||||
assert!(!second.exists());
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_multi_file_apply_can_restore_without_false_conflict() {
|
||||
let home = agent_test_home("partial-recovery");
|
||||
let first = home.join("first.json");
|
||||
let second = home.join("second.json");
|
||||
fs::write(&first, b"{\"original\":1}\n").unwrap();
|
||||
fs::write(&second, b"{\"original\":2}\n").unwrap();
|
||||
let updates = vec![
|
||||
AgentFileUpdate {
|
||||
path: first.clone(),
|
||||
after: "{\"managed\":1}\n".to_string(),
|
||||
},
|
||||
AgentFileUpdate {
|
||||
path: second.clone(),
|
||||
after: "{\"managed\":2}\n".to_string(),
|
||||
},
|
||||
];
|
||||
let record = prepare_agent_record(
|
||||
AgentClient::ClaudeDesktop,
|
||||
&[first.clone(), second.clone()],
|
||||
"claude-test",
|
||||
&updates,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
write_bytes_atomically(&first, updates[0].after.as_bytes()).unwrap();
|
||||
assert_eq!(
|
||||
record_conflict_files(&record).unwrap(),
|
||||
vec![path_to_string(&second)]
|
||||
);
|
||||
assert!(record_restore_conflict_files(&record).unwrap().is_empty());
|
||||
|
||||
fs::write(&second, b"{\"external\":true}\n").unwrap();
|
||||
assert_eq!(
|
||||
record_restore_conflict_files(&record).unwrap(),
|
||||
vec![path_to_string(&second)]
|
||||
);
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_agent_write_failure_rolls_back_previous_files() {
|
||||
let home = agent_test_home("partial-write");
|
||||
let first = home.join("first.txt");
|
||||
let blocked_parent = home.join("blocked");
|
||||
fs::write(&first, b"original\n").unwrap();
|
||||
fs::write(&blocked_parent, b"not a directory").unwrap();
|
||||
let updates = vec![
|
||||
AgentFileUpdate {
|
||||
path: first.clone(),
|
||||
after: "changed\n".to_string(),
|
||||
},
|
||||
AgentFileUpdate {
|
||||
path: blocked_parent.join("second.txt"),
|
||||
after: "never written\n".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
assert!(apply_agent_updates(AgentClient::ClaudeDesktop, &updates).is_err());
|
||||
assert_eq!(fs::read_to_string(&first).unwrap(), "original\n");
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use super::support::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gui_field_edit_preserves_comments_and_unknown_configuration() {
|
||||
let home = agent_test_home("gui-field-edit");
|
||||
let path = home.join("config.toml");
|
||||
fs::write(
|
||||
&path,
|
||||
"# keep this comment\ncustom-option = \"keep\"\ncodex-session-repair-on-launch = true\nclaude-code-working-directory = \"legacy\"\nclaude-code-working-directory-prompt-disabled = true\nport = 7000\n\n[third-party]\nenabled = true\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = GuiConfigFile {
|
||||
port: 9527,
|
||||
host: "0.0.0.0".to_string(),
|
||||
allow_lan: true,
|
||||
silent_start: true,
|
||||
auth_dir: path_to_string(&home.join("custom-auth")),
|
||||
management_secret_key: "custom-secret".to_string(),
|
||||
usage_statistics_enabled: false,
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
|
||||
write_gui_config_to_path(&config, &path).unwrap();
|
||||
let content = fs::read_to_string(&path).unwrap();
|
||||
assert!(content.contains("# keep this comment"));
|
||||
assert!(content.contains("custom-option = \"keep\""));
|
||||
assert!(content.contains("[third-party]"));
|
||||
assert!(content.contains("port = 9527"));
|
||||
assert!(content.contains("host = \"0.0.0.0\""));
|
||||
assert!(content.contains("silent-start = true"));
|
||||
assert!(content.contains("management-secret-key = \"custom-secret\""));
|
||||
assert!(content.contains("usage-statistics-enabled = false"));
|
||||
assert!(!content.contains("codex-session-repair-on-launch"));
|
||||
assert!(!content.contains("claude-code-working-directory"));
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn software_write_hash_suppresses_only_the_matching_file_content() {
|
||||
let home = agent_test_home("write-hash");
|
||||
let path = home.join("config.toml");
|
||||
write_bytes_directly(&path, b"port = 8317\n").unwrap();
|
||||
assert!(consume_software_write(&path));
|
||||
assert!(!consume_software_write(&path));
|
||||
write_bytes_directly(&path, b"port = 8317\n").unwrap();
|
||||
fs::write(&path, b"port = 9000\n").unwrap();
|
||||
assert!(!consume_software_write(&path));
|
||||
fs::write(&path, b"port = 8317\n").unwrap();
|
||||
assert!(!consume_software_write(&path));
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configuration_watcher_uses_the_nearest_existing_parent() {
|
||||
let home = agent_test_home("watch-parent");
|
||||
let target = home.join("nested/client/config.json");
|
||||
|
||||
assert_eq!(
|
||||
nearest_existing_watch_directory(&target),
|
||||
Some(home.clone())
|
||||
);
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
nearest_existing_watch_directory(&target),
|
||||
target.parent().map(Path::to_path_buf)
|
||||
);
|
||||
|
||||
fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_config_defaults_are_stable() {
|
||||
let config = GuiConfigFile::default();
|
||||
let content = toml::to_string_pretty(&config).unwrap();
|
||||
|
||||
assert!(content.contains("port = 8317"));
|
||||
assert!(content.contains("allow-lan = false"));
|
||||
assert!(content.contains("run-on-startup = false"));
|
||||
assert!(content.contains("silent-start = false"));
|
||||
assert!(content.contains("close-behavior = \"ask\""));
|
||||
assert!(content.contains("window-width = 1531"));
|
||||
assert!(content.contains("window-height = 891"));
|
||||
assert!(content.contains("auth-dir = \"../oauth\""));
|
||||
assert!(content.contains("[[api-keys]]"));
|
||||
assert!(content.contains("key = \"123456\""));
|
||||
assert!(content.contains("remark = \"默认密钥\""));
|
||||
assert!(content.contains("management-secret-key = \"\""));
|
||||
assert!(content.contains("plugins-enabled = false"));
|
||||
assert!(content.contains("routing-strategy = \"round-robin\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_window_size_round_trips_through_portable_config() {
|
||||
let config = GuiConfigFile {
|
||||
window_width: Some(1440),
|
||||
window_height: Some(900),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
|
||||
let content = toml::to_string_pretty(&config).unwrap();
|
||||
let restored = toml::from_str::<GuiConfigFile>(&content).unwrap();
|
||||
|
||||
assert!(content.contains("window-width = 1440"));
|
||||
assert!(content.contains("window-height = 900"));
|
||||
assert_eq!(
|
||||
configured_window_size(&restored),
|
||||
Some(SavedWindowSize {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silent_start_defaults_off_and_requires_tray_support() {
|
||||
let legacy = toml::from_str::<GuiConfigFile>("port = 8317\n").unwrap();
|
||||
assert!(!legacy.silent_start);
|
||||
assert!(!should_start_hidden(&legacy));
|
||||
|
||||
let enabled = GuiConfigFile {
|
||||
silent_start: true,
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
assert_eq!(
|
||||
should_start_hidden(&enabled),
|
||||
cfg!(any(target_os = "windows", target_os = "macos"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_window_size_is_clamped_and_requires_both_dimensions() {
|
||||
let mut config = GuiConfigFile {
|
||||
window_width: Some(320),
|
||||
window_height: Some(30_000),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
|
||||
assert!(sanitize_gui_config(&mut config).unwrap());
|
||||
assert_eq!(config.window_width, Some(MIN_MAIN_WINDOW_WIDTH));
|
||||
assert_eq!(config.window_height, Some(MAX_SAVED_WINDOW_DIMENSION));
|
||||
|
||||
config.window_height = None;
|
||||
assert!(sanitize_gui_config(&mut config).unwrap());
|
||||
assert_eq!(config.window_width, None);
|
||||
assert_eq!(config.window_height, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_window_size_uses_display_scale_and_ignores_minimized_sizes() {
|
||||
let physical_size = tauri::PhysicalSize::new(1500, 1000);
|
||||
assert_eq!(
|
||||
logical_window_size_from_physical(&physical_size, 1.25),
|
||||
Some(SavedWindowSize {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
})
|
||||
);
|
||||
assert!(logical_window_size_from_physical(&tauri::PhysicalSize::new(0, 0), 1.0).is_none());
|
||||
assert!(logical_window_size_from_physical(&physical_size, 0.0).is_none());
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
use super::support::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn release_atom_parser_reads_first_release_tag() {
|
||||
let xml = r#"
|
||||
<feed>
|
||||
<entry>
|
||||
<link href="https://github.com/router-for-me/CLIProxyAPI/releases/tag/v7.2.80"/>
|
||||
<title>v7.2.80</title>
|
||||
</entry>
|
||||
<entry><title>v7.2.79</title></entry>
|
||||
</feed>
|
||||
"#;
|
||||
|
||||
assert_eq!(release_tag_from_atom(xml).as_deref(), Some("v7.2.80"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_update_comparison_uses_semantic_versions() {
|
||||
assert!(is_app_update_available("v0.1.9", "v0.2.0").unwrap());
|
||||
assert!(is_app_update_available("v0.2.0-beta.1", "v0.2.0").unwrap());
|
||||
assert!(!is_app_update_available("v0.2.0", "v0.2.0").unwrap());
|
||||
assert!(!is_app_update_available("v0.2.0", "v0.1.9").unwrap());
|
||||
}
|
||||
|
||||
fn portable_update_test_asset(version: &str, arch: &str) -> PortableUpdateAsset {
|
||||
let name = format!("EasyCLIProxyAPI-v{version}-Windows-{arch}.zip");
|
||||
PortableUpdateAsset {
|
||||
url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"),
|
||||
fallback_urls: Vec::new(),
|
||||
sha256: "ab".repeat(32),
|
||||
size_bytes: 1024,
|
||||
}
|
||||
}
|
||||
|
||||
fn portable_update_test_legacy_asset(version: &str, arch: &str) -> PortableUpdateAsset {
|
||||
let name = format!("EasyCLIProxyAPI-update-v{version}-Windows-{arch}.zip");
|
||||
PortableUpdateAsset {
|
||||
url: format!("{APP_RELEASE_DOWNLOAD_PREFIX}v{version}/{name}"),
|
||||
fallback_urls: Vec::new(),
|
||||
sha256: "ab".repeat(32),
|
||||
size_bytes: 1024,
|
||||
}
|
||||
}
|
||||
|
||||
fn portable_update_test_manifest(version: &str) -> PortableUpdateManifest {
|
||||
PortableUpdateManifest {
|
||||
schema_version: 1,
|
||||
version: version.to_string(),
|
||||
published_at: "2026-07-24T00:00:00.000Z".to_string(),
|
||||
release_url: format!(
|
||||
"https://github.com/router-for-me/EasyCLIProxyAPI/releases/tag/v{version}"
|
||||
),
|
||||
assets: [
|
||||
(
|
||||
"windows-amd64".to_string(),
|
||||
portable_update_test_asset(version, "amd64"),
|
||||
),
|
||||
(
|
||||
"windows-aarch64".to_string(),
|
||||
portable_update_test_asset(version, "aarch64"),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
full_assets: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_update_manifest_requires_both_matching_github_assets() {
|
||||
let manifest = portable_update_test_manifest("1.2.3");
|
||||
assert!(validate_portable_update_manifest(&manifest).is_ok());
|
||||
|
||||
let mut legacy_manifest = portable_update_test_manifest("1.2.3");
|
||||
legacy_manifest.assets.insert(
|
||||
"windows-amd64".to_string(),
|
||||
portable_update_test_legacy_asset("1.2.3", "amd64"),
|
||||
);
|
||||
legacy_manifest.assets.insert(
|
||||
"windows-aarch64".to_string(),
|
||||
portable_update_test_legacy_asset("1.2.3", "aarch64"),
|
||||
);
|
||||
assert!(validate_portable_update_manifest(&legacy_manifest).is_ok());
|
||||
|
||||
let mut dual_manifest = portable_update_test_manifest("1.2.3");
|
||||
dual_manifest.assets = [
|
||||
(
|
||||
"windows-amd64".to_string(),
|
||||
portable_update_test_legacy_asset("1.2.3", "amd64"),
|
||||
),
|
||||
(
|
||||
"windows-aarch64".to_string(),
|
||||
portable_update_test_legacy_asset("1.2.3", "aarch64"),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
dual_manifest.full_assets = Some(
|
||||
[
|
||||
(
|
||||
"windows-amd64".to_string(),
|
||||
portable_update_test_asset("1.2.3", "amd64"),
|
||||
),
|
||||
(
|
||||
"windows-aarch64".to_string(),
|
||||
portable_update_test_asset("1.2.3", "aarch64"),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
);
|
||||
assert!(validate_portable_update_manifest(&dual_manifest).is_ok());
|
||||
|
||||
let mut missing_arch = portable_update_test_manifest("1.2.3");
|
||||
missing_arch.assets.remove("windows-aarch64");
|
||||
assert!(validate_portable_update_manifest(&missing_arch).is_err());
|
||||
|
||||
let mut invalid_timestamp = portable_update_test_manifest("1.2.3");
|
||||
invalid_timestamp.published_at = "not-a-timestamp".to_string();
|
||||
assert!(validate_portable_update_manifest(&invalid_timestamp).is_err());
|
||||
|
||||
let mut foreign_host = portable_update_test_manifest("1.2.3");
|
||||
foreign_host
|
||||
.assets
|
||||
.get_mut("windows-amd64")
|
||||
.unwrap()
|
||||
.url = "https://github.com.example.invalid/router-for-me/EasyCLIProxyAPI/releases/download/v1.2.3/update.zip".to_string();
|
||||
assert!(validate_portable_update_manifest(&foreign_host).is_err());
|
||||
|
||||
let mut mismatched_tag = portable_update_test_manifest("1.2.3");
|
||||
mismatched_tag.assets.get_mut("windows-amd64").unwrap().url =
|
||||
format!("{APP_RELEASE_DOWNLOAD_PREFIX}v9.9.9/EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip");
|
||||
assert!(validate_portable_update_manifest(&mismatched_tag).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_update_asset_accepts_only_the_configured_gitcode_fallback() {
|
||||
let mut asset = portable_update_test_asset("1.2.3", "amd64");
|
||||
let filename = "EasyCLIProxyAPI-v1.2.3-Windows-amd64.zip";
|
||||
asset.fallback_urls = vec![gitcode_release_attachment_url(
|
||||
"mirror-owner/EasyCLIProxyAPI",
|
||||
"v1.2.3",
|
||||
filename,
|
||||
)];
|
||||
assert!(validate_portable_update_asset_fallbacks_for_repository(
|
||||
&asset,
|
||||
"v1.2.3",
|
||||
&[filename],
|
||||
Some("mirror-owner/EasyCLIProxyAPI"),
|
||||
)
|
||||
.is_ok());
|
||||
assert!(validate_portable_update_asset_fallbacks_for_repository(
|
||||
&asset,
|
||||
"v1.2.3",
|
||||
&[filename],
|
||||
Some("another-owner/EasyCLIProxyAPI"),
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_update_state_supports_cancellation_and_snapshot_recovery() {
|
||||
let state = AppUpdateState::default();
|
||||
let pending = PendingAppUpdate {
|
||||
version: "1.2.3".to_string(),
|
||||
asset: portable_update_test_asset("1.2.3", "amd64"),
|
||||
arch: "amd64".to_string(),
|
||||
};
|
||||
state.set_pending(
|
||||
Some(pending),
|
||||
AppUpdateTask {
|
||||
phase: "available".to_string(),
|
||||
target_version: Some("1.2.3".to_string()),
|
||||
..AppUpdateTask::default()
|
||||
},
|
||||
);
|
||||
|
||||
let token = CancellationToken::new();
|
||||
let started = state.start(token.clone()).unwrap();
|
||||
assert_eq!(started.version, "1.2.3");
|
||||
let recovered = state.snapshot();
|
||||
assert!(recovered.running);
|
||||
assert!(recovered.cancellable);
|
||||
assert_eq!(recovered.phase, "downloading");
|
||||
|
||||
state.cancel();
|
||||
assert!(token.is_cancelled());
|
||||
let finished = state.finish("cancelled", Some("cancelled".to_string()));
|
||||
assert!(!finished.running);
|
||||
assert!(!finished.cancellable);
|
||||
assert_eq!(state.snapshot().phase, "cancelled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_file_hashes_exact_portable_asset_bytes() {
|
||||
let root = agent_test_home("portable-sha256");
|
||||
let asset = root.join("update.zip");
|
||||
fs::write(&asset, b"EasyCLIProxyAPI portable update").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sha256_file(&asset).unwrap(),
|
||||
"ade7a05bacf7c9144319c0f0cf431700a8883d3f6effd3613c60749dfba1eb52"
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn write_portable_update_zip(path: &Path, entries: &[(&str, &[u8], Option<u32>)]) {
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
let file = File::create(path).unwrap();
|
||||
let mut archive = zip::ZipWriter::new(file);
|
||||
for (name, contents, unix_mode) in entries {
|
||||
let mut options = SimpleFileOptions::default();
|
||||
if let Some(mode) = unix_mode {
|
||||
options = options.unix_permissions(*mode);
|
||||
}
|
||||
archive.start_file(*name, options).unwrap();
|
||||
archive.write_all(contents).unwrap();
|
||||
}
|
||||
archive.finish().unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn portable_update_zip_accepts_the_complete_release_package() {
|
||||
let root = agent_test_home("portable-zip");
|
||||
let valid_zip = root.join("valid.zip");
|
||||
let manifest = br#"{"schemaVersion":1,"application":"EasyCLIProxyAPI","version":"1.2.3","platform":"windows","arch":"amd64","autoUpdate":true}"#;
|
||||
write_portable_update_zip(
|
||||
&valid_zip,
|
||||
&[
|
||||
(
|
||||
"EasyCLIProxyAPI-v1.2.3-Windows-amd64/EasyCLIProxyAPI.exe",
|
||||
b"new executable",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"EasyCLIProxyAPI-v1.2.3-Windows-amd64/portable-app.json",
|
||||
manifest,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"EasyCLIProxyAPI-v1.2.3-Windows-amd64/core-version.txt",
|
||||
b"7.2.109\n",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"EasyCLIProxyAPI-v1.2.3-Windows-amd64/cpa-core/CLIProxyAPI_7.2.109_windows_amd64.zip",
|
||||
b"core archive",
|
||||
None,
|
||||
),
|
||||
],
|
||||
);
|
||||
let extracted = extract_portable_update_archive(&valid_zip, &root.join("valid")).unwrap();
|
||||
assert_eq!(extracted.manifest.version, "1.2.3");
|
||||
assert!(extracted.manifest.auto_update);
|
||||
assert_eq!(
|
||||
extracted.core_archive_name,
|
||||
Some("CLIProxyAPI_7.2.109_windows_amd64.zip".to_string())
|
||||
);
|
||||
|
||||
let legacy_zip = root.join("legacy.zip");
|
||||
write_portable_update_zip(
|
||||
&legacy_zip,
|
||||
&[
|
||||
(PORTABLE_APP_BINARY, b"legacy executable", None),
|
||||
(PORTABLE_APP_MANIFEST_FILE, manifest, None),
|
||||
],
|
||||
);
|
||||
let legacy = extract_portable_update_archive(&legacy_zip, &root.join("legacy")).unwrap();
|
||||
assert_eq!(legacy.manifest.version, "1.2.3");
|
||||
assert!(legacy.core_archive_name.is_none());
|
||||
|
||||
let traversal_zip = root.join("traversal.zip");
|
||||
write_portable_update_zip(
|
||||
&traversal_zip,
|
||||
&[
|
||||
("../EasyCLIProxyAPI.exe", b"malicious", None),
|
||||
(
|
||||
"EasyCLIProxyAPI-v1.2.3-Windows-amd64/portable-app.json",
|
||||
manifest,
|
||||
None,
|
||||
),
|
||||
],
|
||||
);
|
||||
assert!(extract_portable_update_archive(&traversal_zip, &root.join("traversal")).is_err());
|
||||
|
||||
let symlink_zip = root.join("symlink.zip");
|
||||
{
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
let file = File::create(&symlink_zip).unwrap();
|
||||
let mut archive = zip::ZipWriter::new(file);
|
||||
archive
|
||||
.add_symlink(
|
||||
"EasyCLIProxyAPI-v1.2.3-Windows-amd64/EasyCLIProxyAPI.exe",
|
||||
"target.exe",
|
||||
SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
archive
|
||||
.start_file(
|
||||
"EasyCLIProxyAPI-v1.2.3-Windows-amd64/portable-app.json",
|
||||
SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
archive.write_all(manifest).unwrap();
|
||||
archive.finish().unwrap();
|
||||
}
|
||||
assert!(extract_portable_update_archive(&symlink_zip, &root.join("symlink")).is_err());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn portable_update_replacement_preserves_user_data_and_can_roll_back() {
|
||||
let root = agent_test_home("portable-replace");
|
||||
let app_dir = root.join("app");
|
||||
let work_dir = root.join("work");
|
||||
let staging = work_dir.join("staging");
|
||||
fs::create_dir_all(&app_dir).unwrap();
|
||||
fs::create_dir_all(&staging).unwrap();
|
||||
fs::create_dir_all(app_dir.join("cpa-core")).unwrap();
|
||||
fs::create_dir_all(staging.join("cpa-core")).unwrap();
|
||||
fs::write(app_dir.join(PORTABLE_APP_BINARY), b"old exe").unwrap();
|
||||
fs::write(app_dir.join(PORTABLE_APP_MANIFEST_FILE), b"old manifest").unwrap();
|
||||
fs::write(app_dir.join(CORE_VERSION_FILE), b"7.2.100").unwrap();
|
||||
fs::write(
|
||||
app_dir
|
||||
.join("cpa-core")
|
||||
.join("CLIProxyAPI_7.2.100_windows_amd64.zip"),
|
||||
b"old core archive",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(app_dir.join(GUI_CONFIG_FILE), b"user config").unwrap();
|
||||
fs::create_dir_all(app_dir.join(OAUTH_DIR_NAME)).unwrap();
|
||||
fs::write(app_dir.join(OAUTH_DIR_NAME).join("account.json"), b"oauth").unwrap();
|
||||
fs::write(staging.join(PORTABLE_APP_BINARY), b"new exe").unwrap();
|
||||
fs::write(staging.join(PORTABLE_APP_MANIFEST_FILE), b"new manifest").unwrap();
|
||||
fs::write(staging.join(CORE_VERSION_FILE), b"7.2.109").unwrap();
|
||||
fs::write(
|
||||
staging
|
||||
.join("cpa-core")
|
||||
.join("CLIProxyAPI_7.2.109_windows_amd64.zip"),
|
||||
b"new core archive",
|
||||
)
|
||||
.unwrap();
|
||||
let descriptor = PortableUpdateDescriptor {
|
||||
parent_pid: 1,
|
||||
current_exe: app_dir.join(PORTABLE_APP_BINARY),
|
||||
staged_exe: staging.join(PORTABLE_APP_BINARY),
|
||||
current_manifest: app_dir.join(PORTABLE_APP_MANIFEST_FILE),
|
||||
staged_manifest: staging.join(PORTABLE_APP_MANIFEST_FILE),
|
||||
backup_exe: app_dir.join(".EasyCLIProxyAPI.exe.update-backup"),
|
||||
backup_manifest: app_dir.join(".portable-app.json.update-backup"),
|
||||
current_core_version: app_dir.join(CORE_VERSION_FILE),
|
||||
staged_core_version: staging.join(CORE_VERSION_FILE),
|
||||
backup_core_version: app_dir.join(".core-version.txt.update-backup"),
|
||||
staged_core_archive: staging
|
||||
.join("cpa-core")
|
||||
.join("CLIProxyAPI_7.2.109_windows_amd64.zip"),
|
||||
target_core_archive: app_dir
|
||||
.join("cpa-core")
|
||||
.join("CLIProxyAPI_7.2.109_windows_amd64.zip"),
|
||||
install_core_archive: true,
|
||||
ack_path: work_dir.join("update-started.ack"),
|
||||
work_dir,
|
||||
target_version: "1.2.3".to_string(),
|
||||
};
|
||||
|
||||
replace_portable_update_files(&descriptor).unwrap();
|
||||
assert_eq!(fs::read(&descriptor.current_exe).unwrap(), b"new exe");
|
||||
assert_eq!(
|
||||
fs::read(&descriptor.current_manifest).unwrap(),
|
||||
b"new manifest"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&descriptor.current_core_version).unwrap(),
|
||||
b"7.2.109"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&descriptor.target_core_archive).unwrap(),
|
||||
b"new core archive"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(app_dir.join(GUI_CONFIG_FILE)).unwrap(),
|
||||
b"user config"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(app_dir.join(OAUTH_DIR_NAME).join("account.json")).unwrap(),
|
||||
b"oauth"
|
||||
);
|
||||
|
||||
restore_portable_update_backup(&descriptor).unwrap();
|
||||
assert_eq!(fs::read(&descriptor.current_exe).unwrap(), b"old exe");
|
||||
assert_eq!(
|
||||
fs::read(&descriptor.current_manifest).unwrap(),
|
||||
b"old manifest"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&descriptor.current_core_version).unwrap(),
|
||||
b"7.2.100"
|
||||
);
|
||||
assert!(!descriptor.target_core_archive.exists());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_release_uses_official_asset_names_and_urls() {
|
||||
let release = release_from_tag("7.2.80");
|
||||
let platform = CorePlatform {
|
||||
os: "linux".to_string(),
|
||||
arch: "x86_64".to_string(),
|
||||
asset_os: "linux".to_string(),
|
||||
asset_arch: "amd64".to_string(),
|
||||
archive_kind: "tar.gz".to_string(),
|
||||
};
|
||||
let asset = select_release_asset(&release, &platform).unwrap();
|
||||
|
||||
assert_eq!(release.tag_name, "v7.2.80");
|
||||
assert_eq!(asset.name, "CLIProxyAPI_7.2.80_linux_amd64.tar.gz");
|
||||
assert_eq!(
|
||||
asset.browser_download_url,
|
||||
"https://github.com/router-for-me/CLIProxyAPI/releases/download/v7.2.80/CLIProxyAPI_7.2.80_linux_amd64.tar.gz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_core_release_uses_gitcode_as_download_fallback() {
|
||||
let repository = "lzt404/CLIProxyAPI";
|
||||
let release = release_from_tag_for_repositories("7.2.80", Some(repository), false);
|
||||
let platform = CorePlatform {
|
||||
os: "windows".to_string(),
|
||||
arch: "x86_64".to_string(),
|
||||
asset_os: "windows".to_string(),
|
||||
asset_arch: "amd64".to_string(),
|
||||
archive_kind: "zip".to_string(),
|
||||
};
|
||||
let asset = select_release_asset(&release, &platform).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
asset.browser_download_url,
|
||||
"https://github.com/router-for-me/CLIProxyAPI/releases/download/v7.2.80/CLIProxyAPI_7.2.80_windows_amd64.zip"
|
||||
);
|
||||
assert_eq!(
|
||||
asset.fallback_download_urls,
|
||||
["https://api.gitcode.com/api/v5/repos/lzt404/CLIProxyAPI/releases/v7.2.80/attach_files/CLIProxyAPI_7.2.80_windows_amd64.zip/download"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gitcode_discovered_core_release_downloads_from_gitcode_first() {
|
||||
let release = release_from_gitcode_tag("v7.2.80", "lzt404/CLIProxyAPI");
|
||||
let platform = CorePlatform {
|
||||
os: "linux".to_string(),
|
||||
arch: "aarch64".to_string(),
|
||||
asset_os: "linux".to_string(),
|
||||
asset_arch: "aarch64".to_string(),
|
||||
archive_kind: "tar.gz".to_string(),
|
||||
};
|
||||
let asset = select_release_asset(&release, &platform).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
asset.browser_download_url,
|
||||
"https://api.gitcode.com/api/v5/repos/lzt404/CLIProxyAPI/releases/v7.2.80/attach_files/CLIProxyAPI_7.2.80_linux_aarch64.tar.gz/download"
|
||||
);
|
||||
assert!(asset.fallback_download_urls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_asset_names_cover_the_six_supported_gui_targets() {
|
||||
let targets = [
|
||||
("linux", "amd64", "tar.gz"),
|
||||
("linux", "aarch64", "tar.gz"),
|
||||
("darwin", "amd64", "tar.gz"),
|
||||
("darwin", "aarch64", "tar.gz"),
|
||||
("windows", "amd64", "zip"),
|
||||
("windows", "aarch64", "zip"),
|
||||
];
|
||||
for (os, arch, archive_kind) in targets {
|
||||
let platform = CorePlatform {
|
||||
os: os.to_string(),
|
||||
arch: arch.to_string(),
|
||||
asset_os: os.to_string(),
|
||||
asset_arch: arch.to_string(),
|
||||
archive_kind: archive_kind.to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
core_release_asset_name("v7.2.83", &platform),
|
||||
format!("CLIProxyAPI_7.2.83_{os}_{arch}.{archive_kind}")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
use super::support::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_string_api_keys_keep_custom_keys_without_special_protection() {
|
||||
let legacy = "port = 8317\nallow-lan = false\nrun-on-startup = false\nauth-dir = \"/tmp/oauth\"\napi-keys = [\"123456\", \"custom-key\"]\nmanagement-secret-key = \"123456\"\nplugins-enabled = false\nrouting-strategy = \"round-robin\"\n";
|
||||
let mut config = toml::from_str::<GuiConfigFile>(legacy).unwrap();
|
||||
|
||||
assert!(!sanitize_gui_config(&mut config).unwrap());
|
||||
assert_eq!(
|
||||
gui_api_key_values(&config.api_keys),
|
||||
vec!["123456", "custom-key"]
|
||||
);
|
||||
assert!(config.api_keys[0].remark.is_empty());
|
||||
assert!(config.api_keys[1].remark.is_empty());
|
||||
|
||||
let serialized = toml::to_string_pretty(&config).unwrap();
|
||||
assert!(serialized.contains("[[api-keys]]"));
|
||||
let reparsed = toml::from_str::<GuiConfigFile>(&serialized).unwrap();
|
||||
assert_eq!(reparsed.api_keys, config.api_keys);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_remarks_follow_matching_core_keys() {
|
||||
let existing = vec![
|
||||
default_api_key_entry(),
|
||||
GuiApiKeyEntry {
|
||||
key: "custom-key".to_string(),
|
||||
remark: "开发环境".to_string(),
|
||||
},
|
||||
];
|
||||
let core_keys = vec!["custom-key".to_string(), "new-key".to_string()];
|
||||
|
||||
let merged = merge_core_api_keys_with_gui_metadata(&existing, &core_keys, None);
|
||||
|
||||
assert_eq!(gui_api_key_values(&merged), vec!["custom-key", "new-key"]);
|
||||
assert_eq!(merged[0].remark, "开发环境");
|
||||
assert!(merged[1].remark.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_empty_api_key_list_stays_empty() {
|
||||
let existing = vec![default_api_key_entry()];
|
||||
assert!(merge_core_api_keys_with_gui_metadata(&existing, &[], None).is_empty());
|
||||
|
||||
let mut config = GuiConfigFile {
|
||||
api_keys: Vec::new(),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
sanitize_gui_config(&mut config).unwrap();
|
||||
assert!(config.api_keys.is_empty());
|
||||
|
||||
let content = toml::to_string_pretty(&config).unwrap();
|
||||
let restored = toml::from_str::<GuiConfigFile>(&content).unwrap();
|
||||
assert!(restored.api_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_config_keeps_initial_default_unless_core_has_real_keys() {
|
||||
assert!(!should_import_core_api_keys(false, &[]));
|
||||
assert!(should_import_core_api_keys(
|
||||
false,
|
||||
&["existing-key".to_string()]
|
||||
));
|
||||
assert!(should_import_core_api_keys(true, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_default_api_key_can_be_edited_and_deleted() {
|
||||
let mut api_keys = vec![DEFAULT_API_KEY.to_string()];
|
||||
|
||||
replace_core_api_key_value(&mut api_keys, DEFAULT_API_KEY, "custom-key".to_string()).unwrap();
|
||||
assert_eq!(api_keys, vec!["custom-key"]);
|
||||
|
||||
remove_core_api_key_value(&mut api_keys, "custom-key").unwrap();
|
||||
assert!(api_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_config_view_exposes_api_key_metadata_for_the_webview() {
|
||||
let mut config = GuiConfigFile::default();
|
||||
ensure_strong_management_secret(&mut config).unwrap();
|
||||
let view = serde_json::to_value(CoreConfigView::from(&config)).unwrap();
|
||||
|
||||
assert_eq!(view["apiKeys"][0]["apiKey"], DEFAULT_API_KEY);
|
||||
assert_eq!(view["apiKeys"][0]["remark"], DEFAULT_API_KEY_INITIAL_REMARK);
|
||||
assert!(view["apiKeys"][0].get("builtIn").is_none());
|
||||
assert_eq!(view["managementSecretConfigured"], true);
|
||||
assert!(view.get("managementSecretKey").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webui_management_secret_requires_a_non_empty_plaintext_value() {
|
||||
assert_eq!(
|
||||
normalize_management_secret_key(" new-webui-secret ".to_string()).unwrap(),
|
||||
"new-webui-secret"
|
||||
);
|
||||
assert!(normalize_management_secret_key(" ".to_string()).is_err());
|
||||
assert!(normalize_management_secret_key(
|
||||
"$2a$10$abcdefghijklmnopqrstuuuuuuuuuuuuuuuuuuuuuuuuuuuuu".to_string()
|
||||
)
|
||||
.is_err());
|
||||
assert!(normalize_management_secret_key("bad\nsecret".to_string()).is_err());
|
||||
assert!(normalize_management_secret_key("123456".to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn management_secret_rotation_replaces_legacy_values_and_preserves_custom_values() {
|
||||
let mut fresh = GuiConfigFile::default();
|
||||
assert!(ensure_strong_management_secret(&mut fresh).unwrap());
|
||||
assert!(fresh.management_secret_key.starts_with("wui-Aa9_"));
|
||||
assert!(fresh.management_secret_key.len() >= 50);
|
||||
assert!(!management_secret_requires_rotation(
|
||||
&fresh.management_secret_key
|
||||
));
|
||||
|
||||
let first_generated = fresh.management_secret_key.clone();
|
||||
let mut legacy = GuiConfigFile {
|
||||
management_secret_key: LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY.to_string(),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
assert!(ensure_strong_management_secret(&mut legacy).unwrap());
|
||||
assert_ne!(
|
||||
legacy.management_secret_key,
|
||||
LEGACY_DEFAULT_MANAGEMENT_SECRET_KEY
|
||||
);
|
||||
assert_ne!(legacy.management_secret_key, first_generated);
|
||||
|
||||
let mut hashed = GuiConfigFile {
|
||||
management_secret_key: "$2a$10$abcdefghijklmnopqrstuuuuuuuuuuuuuuuuuuuuuuuuuuuuu"
|
||||
.to_string(),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
assert!(ensure_strong_management_secret(&mut hashed).unwrap());
|
||||
assert!(hashed.management_secret_key.starts_with("wui-Aa9_"));
|
||||
|
||||
let mut custom = GuiConfigFile {
|
||||
management_secret_key: "user-selected-secret".to_string(),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
assert!(!ensure_strong_management_secret(&mut custom).unwrap());
|
||||
assert_eq!(custom.management_secret_key, "user-selected-secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn management_secret_key_is_preserved_and_written_to_core() {
|
||||
let mut config = GuiConfigFile {
|
||||
management_secret_key: "old-management-secret".to_string(),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
|
||||
assert!(!sanitize_gui_config(&mut config).unwrap());
|
||||
assert_eq!(config.management_secret_key, "old-management-secret");
|
||||
|
||||
let template = "remote-management:\n secret-key: stale-secret\n";
|
||||
let merged = merge_core_config_yaml(template, None, &config).unwrap();
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&merged).unwrap();
|
||||
assert_eq!(
|
||||
document["remote-management"]["secret-key"],
|
||||
"old-management-secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_auth_directory_is_preserved_and_written_to_core_config() {
|
||||
let mut config = GuiConfigFile {
|
||||
auth_dir: "/tmp/user-selected-auth".to_string(),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
ensure_strong_management_secret(&mut config).unwrap();
|
||||
|
||||
assert!(validate_gui_config(&config).is_ok());
|
||||
assert!(!sanitize_gui_config(&mut config).unwrap());
|
||||
assert_eq!(config.auth_dir, "/tmp/user-selected-auth");
|
||||
|
||||
let merged = merge_core_config_yaml("auth-dir: ~/.cli-proxy-api\n", None, &config).unwrap();
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&merged).unwrap();
|
||||
assert_eq!(document["auth-dir"], config.auth_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_auth_directory_is_relative_and_legacy_absolute_value_is_migrated() {
|
||||
let base_dir = agent_test_home("relative-default-auth-dir");
|
||||
let install_dir = base_dir.join("cpa-core");
|
||||
assert_eq!(
|
||||
auth_dir_path_for_core(DEFAULT_AUTH_DIR, &install_dir),
|
||||
base_dir.join(OAUTH_DIR_NAME)
|
||||
);
|
||||
|
||||
let mut config = GuiConfigFile {
|
||||
auth_dir: path_to_string(&fixed_oauth_dir().unwrap()),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
assert!(sanitize_gui_config(&mut config).unwrap());
|
||||
assert_eq!(config.auth_dir, DEFAULT_AUTH_DIR);
|
||||
fs::remove_dir_all(base_dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_gui_config_can_seed_managed_core_settings() {
|
||||
let legacy = "port: 8317\nallow-lan: false\nrun-on-startup: true\n";
|
||||
let mut config = serde_yaml::from_str::<GuiConfigFile>(legacy).unwrap();
|
||||
let presence = serde_yaml::from_str::<GuiConfigPresence>(legacy).unwrap();
|
||||
let core_settings = CoreConfigSettings {
|
||||
host: "0.0.0.0".to_string(),
|
||||
port: 9000,
|
||||
auth_dir: "/tmp/external-auth".to_string(),
|
||||
api_keys: vec!["existing-key".to_string()],
|
||||
management_secret_configured: true,
|
||||
usage_statistics_enabled: false,
|
||||
plugins_enabled: true,
|
||||
routing_strategy: "fill-first".to_string(),
|
||||
proxy_url: "http://127.0.0.1:8080".to_string(),
|
||||
routing_session_affinity: true,
|
||||
routing_session_affinity_ttl: "45m".to_string(),
|
||||
management_secret_key: Some("management-secret".to_string()),
|
||||
};
|
||||
|
||||
assert!(presence.api_keys.is_none());
|
||||
assert!(presence.management_secret_key.is_none());
|
||||
assert!(presence.plugins_enabled.is_none());
|
||||
assert!(presence.routing_strategy.is_none());
|
||||
apply_core_settings_to_gui_config(&mut config, &core_settings);
|
||||
|
||||
assert_eq!(gui_api_key_values(&config.api_keys), vec!["existing-key"]);
|
||||
assert_eq!(config.management_secret_key, "management-secret");
|
||||
assert_eq!(config.host, "0.0.0.0");
|
||||
assert_eq!(config.port, 9000);
|
||||
assert_eq!(config.auth_dir, "/tmp/external-auth");
|
||||
assert!(!config.usage_statistics_enabled);
|
||||
assert!(config.plugins_enabled);
|
||||
assert_eq!(config.routing_strategy, "fill-first");
|
||||
assert_eq!(config.proxy_url, "http://127.0.0.1:8080");
|
||||
assert!(config.routing_session_affinity);
|
||||
assert_eq!(config.routing_session_affinity_ttl, "45m");
|
||||
assert!(config.run_on_startup);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_api_keys_are_not_persisted_as_gui_settings() {
|
||||
let input = "api-keys:\n - your-api-key-1\n - real-key\nremote-management:\n secret-key: plain-management-secret\nplugins:\n enabled: true\nrouting:\n strategy: fill-first\n";
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(input).unwrap();
|
||||
let core_settings = core_config_settings_from_value(&document).unwrap();
|
||||
let mut config = GuiConfigFile::default();
|
||||
|
||||
apply_core_settings_to_gui_config(&mut config, &core_settings);
|
||||
|
||||
assert_eq!(core_settings.api_keys, vec!["real-key"]);
|
||||
assert_eq!(gui_api_key_values(&config.api_keys), vec!["real-key"]);
|
||||
assert_eq!(
|
||||
core_settings.management_secret_key.as_deref(),
|
||||
Some("plain-management-secret")
|
||||
);
|
||||
assert_eq!(config.management_secret_key, "plain-management-secret");
|
||||
assert!(validate_core_api_key("your-api-key-3").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashed_management_secret_is_detected_without_replacing_known_plaintext() {
|
||||
let input = "remote-management:\n secret-key: $2a$10$abcdefghijklmnopqrstuuuuuuuuuuuuuuuuuuuuuuuuuuuuu\n";
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(input).unwrap();
|
||||
let core_settings = core_config_settings_from_value(&document).unwrap();
|
||||
|
||||
assert!(core_settings
|
||||
.management_secret_key
|
||||
.as_deref()
|
||||
.is_some_and(is_hashed_management_secret_key));
|
||||
assert!(core_settings.management_secret_configured);
|
||||
let mut config = GuiConfigFile {
|
||||
management_secret_key: "known-plaintext".to_string(),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
apply_core_settings_to_gui_config(&mut config, &core_settings);
|
||||
assert_eq!(config.management_secret_key, "known-plaintext");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn management_api_requires_an_available_plaintext_secret() {
|
||||
let mut config = GuiConfigFile {
|
||||
management_secret_key: String::new(),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
assert!(management_authorization(&config).is_err());
|
||||
|
||||
config.management_secret_key =
|
||||
"$2a$10$abcdefghijklmnopqrstuuuuuuuuuuuuuuuuuuuuuuuuuuuuu".to_string();
|
||||
assert!(management_authorization(&config).is_err());
|
||||
|
||||
config.management_secret_key = "known-plaintext".to_string();
|
||||
assert_eq!(
|
||||
management_authorization(&config).unwrap(),
|
||||
"Bearer known-plaintext"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_network_patch_preserves_comments_and_other_settings() {
|
||||
let config = GuiConfigFile {
|
||||
locale: "zh-CN".to_string(),
|
||||
port: 9527,
|
||||
allow_lan: true,
|
||||
host: "0.0.0.0".to_string(),
|
||||
run_on_startup: false,
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
let input = "# Bind address\nhost: 127.0.0.1 # local only\n\n# Service port\nport: 8317 # default\ndebug: true\n";
|
||||
let updated = patch_core_network_yaml(input, &config)
|
||||
.unwrap()
|
||||
.expect("network settings should change");
|
||||
|
||||
assert_eq!(
|
||||
updated,
|
||||
"# Bind address\nhost: 0.0.0.0 # local only\n\n# Service port\nport: 9527 # default\ndebug: true\n"
|
||||
);
|
||||
assert!(updated.contains("# Bind address"));
|
||||
assert!(updated.contains("# local only"));
|
||||
assert!(updated.contains("# Service port"));
|
||||
assert!(updated.contains("# default"));
|
||||
assert!(updated.contains("debug: true"));
|
||||
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&updated).unwrap();
|
||||
assert_eq!(
|
||||
document["host"],
|
||||
serde_norway::Value::String("0.0.0.0".to_string())
|
||||
);
|
||||
assert_eq!(document["port"], serde_norway::to_value(9527_u16).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_network_patch_skips_unchanged_yaml() {
|
||||
let config = GuiConfigFile::default();
|
||||
let input = "host: 127.0.0.1\nport: 8317\n";
|
||||
|
||||
assert!(patch_core_network_yaml(input, &config).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_network_routing_patch_updates_all_fields_together() {
|
||||
let config = GuiConfigFile {
|
||||
port: 9527,
|
||||
allow_lan: true,
|
||||
host: "0.0.0.0".to_string(),
|
||||
proxy_url: "socks5://127.0.0.1:7890".to_string(),
|
||||
routing_session_affinity: true,
|
||||
routing_session_affinity_ttl: "2h".to_string(),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
let input = "# network\nhost: 127.0.0.1\nport: 8317\nproxy-url: \"\"\n# routing\nrouting:\n strategy: round-robin\n# unrelated\ndebug: true\n";
|
||||
let updated = patch_core_network_routing_yaml(input, &config)
|
||||
.unwrap()
|
||||
.expect("confirmed settings should change");
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&updated).unwrap();
|
||||
|
||||
assert_eq!(document["host"], "0.0.0.0");
|
||||
assert_eq!(document["port"], 9527);
|
||||
assert_eq!(document["proxy-url"], "socks5://127.0.0.1:7890");
|
||||
assert_eq!(document["routing"]["session-affinity"], true);
|
||||
assert_eq!(document["routing"]["session-affinity-ttl"], "2h");
|
||||
assert_eq!(document["routing"]["strategy"], "round-robin");
|
||||
assert_eq!(document["debug"], true);
|
||||
assert!(updated.contains("# network"));
|
||||
assert!(updated.contains("# routing"));
|
||||
assert!(updated.contains("# unrelated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_config_controls_preserve_comments_and_unrelated_values() {
|
||||
let input = "# Client authentication\napi-keys:\n - old-key\n\n# Plugin runtime\nplugins:\n enabled: false # global switch\n dir: plugins\n\n# Credential routing\nrouting:\n strategy: round-robin # current strategy\n session-affinity: true\n\ndebug: true # untouched\n";
|
||||
let mut document = yaml_serde_edit::YamlValue::parse(input).unwrap();
|
||||
let mut updated = document.get().clone();
|
||||
|
||||
set_core_api_keys(
|
||||
&mut updated,
|
||||
vec!["old-key".to_string(), "new-key".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
set_nested_yaml_value(&mut updated, &["plugins", "enabled"], true).unwrap();
|
||||
set_nested_yaml_value(
|
||||
&mut updated,
|
||||
&["routing", "strategy"],
|
||||
"fill-first".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
document.set(updated);
|
||||
|
||||
let rendered = document.get_string();
|
||||
assert!(rendered.contains("# Client authentication"));
|
||||
assert!(rendered.contains("# Plugin runtime"));
|
||||
assert!(rendered.contains("# global switch"));
|
||||
assert!(rendered.contains("# Credential routing"));
|
||||
assert!(rendered.contains("# current strategy"));
|
||||
assert!(rendered.contains("debug: true # untouched"));
|
||||
|
||||
let settings = core_config_settings_from_value(document.get()).unwrap();
|
||||
assert_eq!(settings.api_keys, vec!["old-key", "new-key"]);
|
||||
assert!(settings.plugins_enabled);
|
||||
assert_eq!(settings.routing_strategy, "fill-first");
|
||||
assert_eq!(document.get()["plugins"]["dir"], "plugins");
|
||||
assert_eq!(document.get()["routing"]["session-affinity"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_edit_runtime_patches_supported_fields_without_reflowing_yaml() {
|
||||
let input = "# Client authentication\napi-keys:\n - old-key\n\n# Plugin runtime\nplugins:\n enabled: false # global switch\n dir: plugins\n\n# Credential routing\nrouting:\n strategy: round-robin # current strategy\n session-affinity: true\n\ndebug: true # untouched\n";
|
||||
let file = input.parse::<yaml_edit::YamlFile>().unwrap();
|
||||
let document = file.document().unwrap();
|
||||
|
||||
assert!(set_yaml_edit_nested_value(
|
||||
&document, "plugins", "enabled", true
|
||||
));
|
||||
assert!(set_yaml_edit_nested_value(
|
||||
&document,
|
||||
"routing",
|
||||
"strategy",
|
||||
"fill-first".to_string()
|
||||
));
|
||||
|
||||
let rendered = patch_core_api_keys_yaml(
|
||||
&file.to_string(),
|
||||
&["new-key".to_string(), "backup-key".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(rendered.contains("# Client authentication"));
|
||||
assert!(rendered.contains("# Plugin runtime"));
|
||||
assert!(rendered.contains("# global switch"));
|
||||
assert!(rendered.contains("# Credential routing"));
|
||||
assert!(rendered.contains("# current strategy"));
|
||||
assert!(rendered.contains("debug: true # untouched"));
|
||||
assert!(rendered.contains("dir: plugins"));
|
||||
assert!(rendered.contains("session-affinity: true"));
|
||||
|
||||
let settings =
|
||||
core_config_settings_from_value(&serde_norway::from_str(&rendered).unwrap()).unwrap();
|
||||
assert_eq!(settings.api_keys, vec!["new-key", "backup-key"]);
|
||||
assert!(settings.plugins_enabled);
|
||||
assert_eq!(settings.routing_strategy, "fill-first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_edit_sequence_shrink_keeps_following_top_level_key_valid() {
|
||||
let input = "# API keys for authentication\napi-keys:\n - first-key\n - second-key\n - third-key\n\n# Enable debug logging\ndebug: false\n";
|
||||
let original = serde_norway::from_str::<serde_norway::Value>(input).unwrap();
|
||||
let mut updated = original.clone();
|
||||
set_core_api_keys(&mut updated, vec!["first-key".to_string()]).unwrap();
|
||||
|
||||
let rendered = render_yaml_value_changes(input, &original, &updated)
|
||||
.unwrap_or_else(|error| panic!("sequence shrink failed: {error}"));
|
||||
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered)
|
||||
.unwrap_or_else(|error| panic!("invalid YAML: {error}\n{rendered}"));
|
||||
|
||||
assert_eq!(parsed["api-keys"][0], "first-key");
|
||||
assert_eq!(parsed["api-keys"].as_sequence().unwrap().len(), 1);
|
||||
assert_eq!(parsed["debug"], false);
|
||||
assert!(rendered.find("api-keys:").unwrap() < rendered.find("debug:").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_yaml_ast_patch_handles_core_comments_around_nested_mapping() {
|
||||
let input = "host: 127.0.0.1\nremote-management:\n# Whether to allow remote access.\n allow-remote: false\n# Management key.\n# All requests require this key.\n secret-key: old\n# Disable panel.\n disable-control-panel: false\nauth-dir: /tmp/old\napi-keys:\n - old-key\n";
|
||||
let rendered = patch_core_yaml_document(input, |document| {
|
||||
let auth_changed = set_core_yaml_top_level_value(
|
||||
document,
|
||||
"auth-dir",
|
||||
serde_norway::Value::String("/tmp/new".to_string()),
|
||||
)?;
|
||||
let secret_changed = set_core_yaml_nested_value(
|
||||
document,
|
||||
"remote-management",
|
||||
"secret-key",
|
||||
serde_norway::Value::String("123456".to_string()),
|
||||
)?;
|
||||
Ok(auth_changed || secret_changed)
|
||||
})
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered)
|
||||
.unwrap_or_else(|error| panic!("invalid YAML: {error}\n{rendered}"));
|
||||
|
||||
assert_eq!(parsed["auth-dir"], "/tmp/new");
|
||||
assert_eq!(parsed["remote-management"]["secret-key"], "123456");
|
||||
assert!(rendered.contains("# All requests require this key."));
|
||||
assert!(rendered.contains("disable-control-panel: false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_edit_runtime_patch_removes_empty_keys_and_skips_unsupported_sections() {
|
||||
let input = "# Client authentication\napi-keys:\n - old-key\nplugins:\n enabled: true\nrouting:\n strategy: fill-first\n";
|
||||
let rendered = patch_core_api_keys_yaml(input, &[]).unwrap();
|
||||
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered).unwrap();
|
||||
let root = parsed.as_mapping().unwrap();
|
||||
assert!(yaml_mapping_value(root, "api-keys").is_none(), "{rendered}");
|
||||
assert_eq!(
|
||||
core_config_settings_from_value(&parsed).unwrap().api_keys,
|
||||
Vec::<String>::new()
|
||||
);
|
||||
|
||||
let missing_api_keys = "host: 127.0.0.1\nport: 8317\n";
|
||||
let rendered = patch_core_api_keys_yaml(
|
||||
missing_api_keys,
|
||||
&["new-key".to_string(), "backup-key".to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
let settings =
|
||||
core_config_settings_from_value(&serde_norway::from_str(&rendered).unwrap()).unwrap();
|
||||
assert_eq!(settings.api_keys, vec!["new-key", "backup-key"]);
|
||||
assert!(rendered.contains("host: 127.0.0.1"));
|
||||
assert!(rendered.contains("port: 8317"));
|
||||
|
||||
// Nested plugin/routing sections are still optional for comment-preserving
|
||||
// runtime patches; missing maps remain unsupported and stay untouched.
|
||||
let unsupported = "host: 127.0.0.1\nport: 8317\n";
|
||||
let file = unsupported.parse::<yaml_edit::YamlFile>().unwrap();
|
||||
let document = file.document().unwrap();
|
||||
assert!(!set_yaml_edit_nested_value(
|
||||
&document, "plugins", "enabled", true
|
||||
));
|
||||
assert!(!set_yaml_edit_nested_value(
|
||||
&document,
|
||||
"routing",
|
||||
"strategy",
|
||||
"fill-first".to_string()
|
||||
));
|
||||
assert_eq!(file.to_string(), unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_edit_runtime_patch_recreates_api_keys_after_delete_all() {
|
||||
let input = "# Client authentication\napi-keys:\n - old-key\nplugins:\n enabled: true\n";
|
||||
let cleared = patch_core_api_keys_yaml(input, &[]).unwrap();
|
||||
let rendered = patch_core_api_keys_yaml(&cleared, &["restored-key".to_string()]).unwrap();
|
||||
assert!(rendered.contains("# Client authentication"), "{rendered}");
|
||||
let settings =
|
||||
core_config_settings_from_value(&serde_norway::from_str(&rendered).unwrap()).unwrap();
|
||||
assert_eq!(settings.api_keys, vec!["restored-key"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_edit_runtime_patch_adds_api_keys_to_core_style_config() {
|
||||
let input = "host: 127.0.0.1\nremote-management:\n# nested setting comment\n allow-remote: false\nauth-dir: /tmp/oauth\n# API keys for authentication\n# Enable debug logging\ndebug: false\n\n# Optional payload configuration\n# payload:\n# filter:\n# - models:\n# - name: \"gemini-2.5-pro\"\n# params:\n# - \"generationConfig.responseJsonSchema\"\n";
|
||||
let rendered =
|
||||
patch_core_api_keys_yaml(input, &["new-key".to_string(), "backup-key".to_string()])
|
||||
.unwrap();
|
||||
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered)
|
||||
.unwrap_or_else(|error| panic!("invalid YAML: {error}\n{rendered}"));
|
||||
let settings = core_config_settings_from_value(&parsed).unwrap();
|
||||
assert_eq!(settings.api_keys, vec!["new-key", "backup-key"]);
|
||||
assert!(rendered.contains("# Optional payload configuration"));
|
||||
assert!(rendered.contains("generationConfig.responseJsonSchema"));
|
||||
assert!(rendered.find("auth-dir: /tmp/oauth").unwrap() < rendered.find("api-keys:").unwrap());
|
||||
assert!(rendered.find("api-keys:").unwrap() < rendered.find("debug: false").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_edit_runtime_patch_updates_existing_real_core_config() {
|
||||
let input = "host: 0.0.0.0\nremote-management:\n# nested comment\n allow-remote: false\nauth-dir: /tmp/oauth\n# API keys for authentication\napi-keys:\n - '123456'\n# Enable debug logging\ndebug: false\n\n# payload:\n# filter:\n# - models:\n# - name: gemini\n";
|
||||
let rendered =
|
||||
patch_core_api_keys_yaml(input, &[DEFAULT_API_KEY.to_string(), "new-key".to_string()])
|
||||
.unwrap();
|
||||
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered)
|
||||
.unwrap_or_else(|error| panic!("invalid YAML: {error}\n{rendered}"));
|
||||
assert_eq!(
|
||||
core_config_settings_from_value(&parsed).unwrap().api_keys,
|
||||
vec![DEFAULT_API_KEY, "new-key"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_api_key_patch_replaces_indentationless_core_sequence() {
|
||||
let input =
|
||||
"host: 0.0.0.0\nport: 8317\nauth-dir: /tmp/oauth\napi-keys:\n- '123456'\ndebug: false\n";
|
||||
let rendered = patch_core_api_keys_yaml(input, &[DEFAULT_API_KEY.to_string()])
|
||||
.unwrap_or_else(|error| panic!("patch failed: {error}"));
|
||||
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered)
|
||||
.unwrap_or_else(|error| panic!("invalid YAML: {error}\n{rendered}"));
|
||||
|
||||
assert_eq!(
|
||||
core_config_settings_from_value(&parsed).unwrap().api_keys,
|
||||
vec![DEFAULT_API_KEY]
|
||||
);
|
||||
assert_eq!(rendered.matches("- '123456'").count(), 1, "{rendered}");
|
||||
assert!(rendered.contains("debug: false"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_edit_runtime_patch_migrates_legacy_api_key_entries() {
|
||||
let input = "auth:\n providers:\n config-api-key:\n api-key-entries:\n - api-key: first-key\n - key: second-key\nplugins:\n enabled: false\n";
|
||||
let rendered = patch_core_api_keys_yaml(input, &["migrated-key".to_string()]).unwrap();
|
||||
let parsed = serde_norway::from_str::<serde_norway::Value>(&rendered).unwrap();
|
||||
let settings = core_config_settings_from_value(&parsed).unwrap();
|
||||
assert_eq!(settings.api_keys, vec!["migrated-key"]);
|
||||
assert!(
|
||||
nested_yaml_value(
|
||||
parsed.as_mapping().unwrap(),
|
||||
&["auth", "providers", "config-api-key", "api-key-entries"]
|
||||
)
|
||||
.is_none(),
|
||||
"{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_config_reads_legacy_api_key_entries() {
|
||||
let input = "auth:\n providers:\n config-api-key:\n api-key-entries:\n - api-key: first-key\n - key: second-key\nplugins:\n enabled: false\nrouting:\n strategy: round-robin\n";
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(input).unwrap();
|
||||
let settings = core_config_settings_from_value(&document).unwrap();
|
||||
|
||||
assert_eq!(settings.api_keys, vec!["first-key", "second-key"]);
|
||||
assert!(!settings.plugins_enabled);
|
||||
assert_eq!(settings.routing_strategy, "round-robin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_config_reads_proxy_and_session_affinity_fields() {
|
||||
let canonical = serde_norway::from_str::<serde_norway::Value>(
|
||||
"proxy-url: socks5://127.0.0.1:7890\nrouting:\n session-affinity: true\n session-affinity-ttl: 2h\n",
|
||||
)
|
||||
.unwrap();
|
||||
let settings = core_config_settings_from_value(&canonical).unwrap();
|
||||
assert_eq!(settings.proxy_url, "socks5://127.0.0.1:7890");
|
||||
assert!(settings.routing_session_affinity);
|
||||
assert_eq!(settings.routing_session_affinity_ttl, "2h");
|
||||
|
||||
let aliases = serde_norway::from_str::<serde_norway::Value>(
|
||||
"routing:\n sessionAffinity: true\n sessionAffinityTTL: 30m\n",
|
||||
)
|
||||
.unwrap();
|
||||
let settings = core_config_settings_from_value(&aliases).unwrap();
|
||||
assert!(settings.routing_session_affinity);
|
||||
assert_eq!(settings.routing_session_affinity_ttl, "30m");
|
||||
|
||||
let defaults = core_config_settings_from_value(&serde_norway::from_str("{}").unwrap()).unwrap();
|
||||
assert_eq!(defaults.proxy_url, "");
|
||||
assert!(!defaults.routing_session_affinity);
|
||||
assert_eq!(defaults.routing_session_affinity_ttl, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_session_settings_use_canonical_yaml_and_preserve_unrelated_content() {
|
||||
let input = "# global proxy\nproxy-url: old\n# routing options\nrouting:\n strategy: round-robin\n sessionAffinity: false\n sessionAffinityTTL: 10m\n# unrelated option\ndebug: true\n";
|
||||
let config = GuiConfigFile {
|
||||
proxy_url: "http://127.0.0.1:8080".to_string(),
|
||||
routing_session_affinity: true,
|
||||
routing_session_affinity_ttl: "1h".to_string(),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
let rendered = apply_gui_managed_settings(input, &config).unwrap();
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&rendered).unwrap();
|
||||
|
||||
assert_eq!(document["proxy-url"], "http://127.0.0.1:8080");
|
||||
assert_eq!(document["routing"]["session-affinity"], true);
|
||||
assert_eq!(document["routing"]["session-affinity-ttl"], "1h");
|
||||
assert!(rendered.contains("# global proxy"));
|
||||
assert!(rendered.contains("# unrelated option"));
|
||||
assert_eq!(document["debug"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_core_strings_are_trimmed_and_reject_control_characters() {
|
||||
assert_eq!(
|
||||
normalize_optional_config_string(" socks5://proxy:7890 ".to_string(), "代理 URL")
|
||||
.unwrap(),
|
||||
"socks5://proxy:7890"
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_optional_config_string(" 1h ".to_string(), "TTL").unwrap(),
|
||||
"1h"
|
||||
);
|
||||
assert!(normalize_optional_config_string("bad\nvalue".to_string(), "代理 URL").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_proxy_supports_http_and_socks5_urls() {
|
||||
for proxy_url in ["http://127.0.0.1:8080", "socks5://127.0.0.1:7890"] {
|
||||
apply_configured_proxy(reqwest::Client::builder(), proxy_url)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_config_validates_keys_and_routing_strategy() {
|
||||
assert!(validate_core_api_key("sk-valid_123").is_ok());
|
||||
assert!(validate_core_api_key("").is_err());
|
||||
assert!(validate_core_api_key("contains space").is_err());
|
||||
assert!(validate_routing_strategy("round-robin").is_ok());
|
||||
assert!(validate_routing_strategy("fill-first").is_ok());
|
||||
assert!(validate_routing_strategy("random").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_yaml_is_not_written_again() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"cpa-gui-unchanged-yaml-{}-{}.yaml",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let content = "host: 127.0.0.1\nport: 8317\n";
|
||||
fs::write(&path, content).unwrap();
|
||||
|
||||
assert!(!write_yaml_if_changed(&path, content).unwrap());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), content);
|
||||
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_merge_uses_template_and_preserves_current_values() {
|
||||
let template = "# Current release template\nhost: \"\" # template bind address\nport: 8317\n\n# Client authentication\napi-keys:\n - template-key\n\n# Plugin runtime\nplugins:\n enabled: false # plugin switch\n\n# Credential routing\nrouting:\n strategy: round-robin # routing switch\n\n# New release option\nnew-option: true\nnested:\n # Nested template comment\n keep: template\n added: from-template\nlist:\n - template-item\n";
|
||||
let current = "host: 127.0.0.1\nport: 9000\nnested:\n keep: current\n current-only: retained\nlist:\n - current-a\n - current-b\nextra: true\n";
|
||||
let config = GuiConfigFile {
|
||||
locale: "zh-CN".to_string(),
|
||||
port: 9527,
|
||||
allow_lan: true,
|
||||
host: "0.0.0.0".to_string(),
|
||||
run_on_startup: false,
|
||||
silent_start: false,
|
||||
close_behavior: WindowsCloseBehavior::Ask,
|
||||
window_width: None,
|
||||
window_height: None,
|
||||
auth_dir: path_to_string(&fixed_oauth_dir().unwrap()),
|
||||
api_keys: vec![
|
||||
default_api_key_entry(),
|
||||
GuiApiKeyEntry {
|
||||
key: "gui-key".to_string(),
|
||||
remark: "测试密钥".to_string(),
|
||||
},
|
||||
],
|
||||
api_access_remarks: Vec::new(),
|
||||
management_secret_key: String::new(),
|
||||
usage_statistics_enabled: false,
|
||||
plugins_enabled: true,
|
||||
routing_strategy: "fill-first".to_string(),
|
||||
proxy_url: "socks5://127.0.0.1:7890".to_string(),
|
||||
routing_session_affinity: true,
|
||||
routing_session_affinity_ttl: "1h".to_string(),
|
||||
};
|
||||
let merged = merge_core_config_yaml(template, Some(current), &config).unwrap();
|
||||
|
||||
assert!(merged.contains("# Current release template"));
|
||||
assert!(merged.contains("# template bind address"), "{merged}");
|
||||
assert!(merged.contains("# New release option"));
|
||||
assert!(merged.contains("# Nested template comment"));
|
||||
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&merged).unwrap();
|
||||
assert_eq!(
|
||||
document["host"],
|
||||
serde_norway::Value::String("0.0.0.0".to_string())
|
||||
);
|
||||
assert_eq!(document["port"], serde_norway::to_value(9527_u16).unwrap());
|
||||
assert_eq!(document["api-keys"][0], DEFAULT_API_KEY);
|
||||
assert_eq!(document["api-keys"][1], "gui-key");
|
||||
assert_eq!(document["plugins"]["enabled"], true, "{merged}");
|
||||
assert_eq!(document["routing"]["strategy"], "fill-first");
|
||||
assert_eq!(document["proxy-url"], "socks5://127.0.0.1:7890");
|
||||
assert_eq!(document["routing"]["session-affinity"], true);
|
||||
assert_eq!(document["routing"]["session-affinity-ttl"], "1h");
|
||||
assert_eq!(document["usage-statistics-enabled"], false);
|
||||
assert_eq!(document["new-option"], serde_norway::Value::Bool(true));
|
||||
assert_eq!(
|
||||
document["nested"]["keep"],
|
||||
serde_norway::Value::String("current".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
document["nested"]["added"],
|
||||
serde_norway::Value::String("from-template".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
document["nested"]["current-only"],
|
||||
serde_norway::Value::String("retained".to_string())
|
||||
);
|
||||
assert_eq!(document["extra"], serde_norway::Value::Bool(true));
|
||||
assert_eq!(
|
||||
document["list"],
|
||||
serde_norway::Value::Sequence(vec![
|
||||
serde_norway::Value::String("current-a".to_string()),
|
||||
serde_norway::Value::String("current-b".to_string()),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_merge_without_current_config_uses_gui_defaults() {
|
||||
let template = "# Template\nhost: \"\"\nport: 9000\napi-keys:\n - template-key\nplugins:\n enabled: true\nrouting:\n strategy: fill-first\ndebug: false\n";
|
||||
let mut config = GuiConfigFile::default();
|
||||
ensure_strong_management_secret(&mut config).unwrap();
|
||||
let merged = merge_core_config_yaml(template, None, &config).unwrap();
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&merged).unwrap();
|
||||
|
||||
assert!(merged.contains("# Template"));
|
||||
assert_eq!(
|
||||
document["host"],
|
||||
serde_norway::Value::String("127.0.0.1".to_string())
|
||||
);
|
||||
assert_eq!(document["port"], serde_norway::to_value(8317_u16).unwrap());
|
||||
assert_eq!(document["debug"], serde_norway::Value::Bool(false));
|
||||
assert_eq!(document["api-keys"][0], DEFAULT_API_KEY, "{merged}");
|
||||
assert_eq!(document["plugins"]["enabled"], false);
|
||||
assert_eq!(document["routing"]["strategy"], "round-robin");
|
||||
assert_eq!(document["usage-statistics-enabled"], true);
|
||||
assert_eq!(
|
||||
document["remote-management"]["secret-key"],
|
||||
config.management_secret_key
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_merge_can_shrink_template_api_key_sequence() {
|
||||
let template = "host: \"\"\nport: 8317\nremote-management:\n secret-key: \"\"\nauth-dir: ~/.cli-proxy-api\napi-keys:\n - template-one\n - template-two\n - template-three\ndebug: false\nplugins:\n enabled: false\nrouting:\n strategy: round-robin\n";
|
||||
let current = "host: 127.0.0.1\nport: 8317\nremote-management:\n secret-key: hashed\nauth-dir: C:/oauth\napi-keys:\n - '123456'\ndebug: false\nplugins:\n enabled: false\nrouting:\n strategy: round-robin\n";
|
||||
|
||||
let mut config = GuiConfigFile::default();
|
||||
ensure_strong_management_secret(&mut config).unwrap();
|
||||
let merged = merge_core_config_yaml(template, Some(current), &config).unwrap();
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&merged)
|
||||
.unwrap_or_else(|error| panic!("invalid YAML: {error}\n{merged}"));
|
||||
|
||||
assert_eq!(document["api-keys"][0], DEFAULT_API_KEY);
|
||||
assert_eq!(document["api-keys"].as_sequence().unwrap().len(), 1);
|
||||
assert_eq!(document["debug"], false);
|
||||
assert_eq!(
|
||||
document["remote-management"]["secret-key"],
|
||||
config.management_secret_key
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
use super::support::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replacing_a_core_preserves_only_regular_bundled_assets() {
|
||||
let root = agent_test_home("bundled-assets");
|
||||
let source = root.join("source");
|
||||
let target = root.join("target");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
fs::write(
|
||||
source.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz"),
|
||||
b"archive",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
source.join("CLIProxyAPI_7.2.83_linux_amd64_no-plugin.tar.gz"),
|
||||
b"portable",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(source.join(CORE_CHECKSUMS_FILE), b"checksums").unwrap();
|
||||
|
||||
preserve_bundled_core_assets(&source, &target).unwrap();
|
||||
|
||||
assert!(target
|
||||
.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz")
|
||||
.is_file());
|
||||
assert!(!target
|
||||
.join("CLIProxyAPI_7.2.83_linux_amd64_no-plugin.tar.gz")
|
||||
.exists());
|
||||
assert!(target.join(CORE_CHECKSUMS_FILE).is_file());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_core_migrates_old_fields_into_the_new_template() {
|
||||
let root = agent_test_home("core-config-migrate");
|
||||
let source = root.join("source");
|
||||
let target = root.join("target");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
let old_config = "# Old comment\nhost: 127.0.0.1\nport: 9527\nnested:\n keep: old\n old-only: retained\nlist:\n - old-a\n - old-b\nextra: true\n";
|
||||
let new_template = "# New template\nhost: \"\"\nport: 8317\nnested:\n keep: new-default\n added: new-field\nlist:\n - new-default\nnew-option: true\n";
|
||||
fs::write(source.join(CORE_CONFIG_FILE), old_config).unwrap();
|
||||
fs::write(target.join(CORE_EXAMPLE_CONFIG_FILE), new_template).unwrap();
|
||||
|
||||
migrate_core_config_for_update(&source, &target).unwrap();
|
||||
|
||||
let migrated = fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap();
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&migrated).unwrap();
|
||||
assert!(migrated.contains("# New template"));
|
||||
assert_eq!(document["host"], "127.0.0.1");
|
||||
assert_eq!(document["port"], 9527);
|
||||
assert_eq!(document["nested"]["keep"], "old");
|
||||
assert_eq!(document["nested"]["added"], "new-field");
|
||||
assert_eq!(document["nested"]["old-only"], "retained");
|
||||
assert_eq!(document["list"][0], "old-a");
|
||||
assert_eq!(document["list"][1], "old-b");
|
||||
assert_eq!(document["new-option"], true);
|
||||
assert_eq!(document["extra"], true);
|
||||
assert_eq!(
|
||||
fs::read_to_string(target.join(CORE_EXAMPLE_CONFIG_FILE)).unwrap(),
|
||||
new_template
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_a_core_discards_invalid_fields_and_continues_migration() {
|
||||
let root = agent_test_home("core-config-migrate-invalid");
|
||||
let source = root.join("source");
|
||||
let target = root.join("target");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
fs::write(
|
||||
source.join(CORE_CONFIG_FILE),
|
||||
"host: 127.0.0.1\nbroken: [invalid\nport: 9527\napi-keys:\n- old-a\n- old-b\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
target.join(CORE_EXAMPLE_CONFIG_FILE),
|
||||
"host: \"\"\nbroken: new-default\nport: 8317\napi-keys:\n - new-default\nnew-option: true\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(target.join(CORE_CONFIG_FILE), "staged: untouched\n").unwrap();
|
||||
|
||||
migrate_core_config_for_update(&source, &target).unwrap();
|
||||
|
||||
let migrated = fs::read_to_string(target.join(CORE_CONFIG_FILE)).unwrap();
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&migrated).unwrap();
|
||||
assert_eq!(document["host"], "127.0.0.1");
|
||||
assert_eq!(document["broken"], "new-default");
|
||||
assert_eq!(document["port"], 9527);
|
||||
assert_eq!(document["api-keys"][0], "old-a");
|
||||
assert_eq!(document["api-keys"][1], "old-b");
|
||||
assert_eq!(document["new-option"], true);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_core_bootstrap_runs_only_when_no_core_binary_exists() {
|
||||
let root = agent_test_home("bundled-bootstrap-detection");
|
||||
let install_dir = root.join("cpa-core");
|
||||
|
||||
assert!(core_needs_bundled_bootstrap(&install_dir));
|
||||
|
||||
let existing_version = install_dir.join("existing-version");
|
||||
fs::create_dir_all(&existing_version).unwrap();
|
||||
fs::write(existing_version.join(core_binary_name()), b"existing core").unwrap();
|
||||
|
||||
assert!(!core_needs_bundled_bootstrap(&install_dir));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_core_locations_include_macos_app_resources() {
|
||||
let contents_dir = agent_test_home("bundled-macos-resources")
|
||||
.join("EasyCLIProxyAPI.app")
|
||||
.join("Contents");
|
||||
let executable_dir = contents_dir.join("MacOS");
|
||||
let base_dir = agent_test_home("bundled-macos-data");
|
||||
let resource_location = (
|
||||
contents_dir.join("Resources").join(CORE_VERSION_FILE),
|
||||
contents_dir.join("Resources").join("cpa-core"),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
macos_app_resources_dir(&executable_dir),
|
||||
Some(contents_dir.join("Resources"))
|
||||
);
|
||||
assert!(bundled_core_locations(&base_dir, &executable_dir).contains(&resource_location));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_project_root_is_detected_from_the_portable_development_directory() {
|
||||
let root = agent_test_home("bundled-source-root");
|
||||
fs::create_dir_all(root.join("src-tauri")).unwrap();
|
||||
fs::create_dir_all(root.join("bin-work")).unwrap();
|
||||
fs::write(root.join("package.json"), b"{}").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
source_project_root(&root.join("bin-work")),
|
||||
Some(root.clone())
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_source_archive_and_checksums_are_copied_into_the_installation() {
|
||||
let root = agent_test_home("selected-bundled-asset");
|
||||
let source = root.join("source");
|
||||
let target = root.join("target");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
let archive = source.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz");
|
||||
fs::write(&archive, b"archive").unwrap();
|
||||
fs::write(source.join(CORE_CHECKSUMS_FILE), b"checksums").unwrap();
|
||||
|
||||
preserve_selected_bundled_core_asset(&archive, &target).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
fs::read(target.join("CLIProxyAPI_7.2.83_linux_amd64.tar.gz")).unwrap(),
|
||||
b"archive"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(target.join(CORE_CHECKSUMS_FILE)).unwrap(),
|
||||
b"checksums"
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_page_assets_parse_download_links_and_sha256() {
|
||||
let html = r#"
|
||||
<li><a href="/router-for-me/CLIProxyAPI/releases/download/v1.2.3/checksums.txt">checksums.txt</a></li>
|
||||
<li><a href="/router-for-me/CLIProxyAPI/releases/download/v1.2.3/CLIProxyAPI_1.2.3_linux_amd64.tar.gz">asset</a>
|
||||
<span>sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef</span>
|
||||
</li>
|
||||
"#;
|
||||
|
||||
let assets = parse_release_assets(html);
|
||||
assert_eq!(assets.len(), 2);
|
||||
assert_eq!(assets[1].name, "CLIProxyAPI_1.2.3_linux_amd64.tar.gz");
|
||||
assert_eq!(
|
||||
assets[1].digest.as_deref(),
|
||||
Some("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
);
|
||||
assert!(assets[1]
|
||||
.browser_download_url
|
||||
.ends_with("/releases/download/v1.2.3/CLIProxyAPI_1.2.3_linux_amd64.tar.gz"));
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use super::support::*;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thinking_alias_adds_fork_and_matching_payload_rule() {
|
||||
let input = "# Keep this comment\ndebug: true\npayload:\n override:\n - models:\n - name: existing-fast\n protocol: codex\n params:\n service_tier: priority\n";
|
||||
let source = test_codex_oauth_thinking_source("gpt-5.5");
|
||||
let rendered =
|
||||
add_model_alias_to_yaml(input, &source, "gpt-5.5-xhigh", "xhigh", false).unwrap();
|
||||
let aliases = thinking_aliases_from_yaml(&rendered).unwrap();
|
||||
|
||||
assert!(rendered.contains("# Keep this comment"), "{rendered}");
|
||||
assert!(rendered.contains("service_tier: priority"), "{rendered}");
|
||||
assert_eq!(
|
||||
aliases,
|
||||
vec![ThinkingAliasEntry {
|
||||
source_model: "gpt-5.5".to_string(),
|
||||
alias: "gpt-5.5-xhigh".to_string(),
|
||||
effort: Some("xhigh".to_string()),
|
||||
provider: "Codex OAuth".to_string(),
|
||||
kind: "codex-oauth".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_alias_can_be_created_without_overrides() {
|
||||
let source = test_codex_oauth_thinking_source("gpt-5.5");
|
||||
let rendered = add_model_alias_to_yaml("{}\n", &source, "gpt-5.5-alias", "", false).unwrap();
|
||||
|
||||
assert!(rendered.contains("alias: gpt-5.5-alias"), "{rendered}");
|
||||
assert!(!rendered.contains("payload:"), "{rendered}");
|
||||
assert_eq!(
|
||||
thinking_aliases_from_yaml(&rendered).unwrap(),
|
||||
vec![ThinkingAliasEntry {
|
||||
source_model: "gpt-5.5".to_string(),
|
||||
alias: "gpt-5.5-alias".to_string(),
|
||||
effort: None,
|
||||
provider: "Codex OAuth".to_string(),
|
||||
kind: "codex-oauth".to_string(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_model_alias_can_be_created_without_overrides() {
|
||||
let input = "codex-api-key:\n - api-key: test\n base-url: https://example.com/v1\n models:\n - name: gpt-custom\n";
|
||||
let available_models = test_agent_models(&["gpt-custom"]);
|
||||
let sources = resolved_alias_sources(input, &[], &available_models, false).unwrap();
|
||||
let source = sources
|
||||
.iter()
|
||||
.find(|source| source.source.model == "gpt-custom")
|
||||
.unwrap();
|
||||
let rendered = add_model_alias_to_yaml(input, source, "gpt-custom-alias", "", false).unwrap();
|
||||
|
||||
assert!(rendered.contains("alias: gpt-custom-alias"), "{rendered}");
|
||||
assert!(!rendered.contains("thinking:"), "{rendered}");
|
||||
assert!(!rendered.contains("payload:"), "{rendered}");
|
||||
let entries = thinking_aliases_from_yaml(&rendered).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].effort, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_alias_can_apply_reasoning_and_fast_together() {
|
||||
let source = test_codex_oauth_thinking_source("gpt-5.6-sol");
|
||||
let rendered =
|
||||
add_model_alias_to_yaml("{}\n", &source, "gpt-5.6-sol-xhigh-fast", "xhigh", true).unwrap();
|
||||
|
||||
assert!(rendered.contains("reasoning.effort: xhigh"), "{rendered}");
|
||||
assert!(rendered.contains("service_tier: priority"), "{rendered}");
|
||||
assert_eq!(thinking_aliases_from_yaml(&rendered).unwrap().len(), 1);
|
||||
assert_eq!(speed_aliases_from_yaml(&rendered).unwrap().len(), 1);
|
||||
|
||||
let restored = remove_thinking_alias_from_yaml(&rendered, "gpt-5.6-sol-xhigh-fast").unwrap();
|
||||
assert!(!restored.contains("gpt-5.6-sol-xhigh-fast"), "{restored}");
|
||||
assert!(!restored.contains("service_tier: priority"), "{restored}");
|
||||
|
||||
let legacy = "oauth-model-alias:\n codex:\n - name: gpt-5.6-sol\n alias: legacy-combined\n fork: true\npayload:\n override:\n - models:\n - name: legacy-combined\n protocol: codex\n params:\n reasoning.effort: high\n - models:\n - name: legacy-combined\n protocol: codex\n params:\n service_tier: priority\n";
|
||||
let restored = remove_thinking_alias_from_yaml(legacy, "legacy-combined").unwrap();
|
||||
assert!(!restored.contains("legacy-combined"), "{restored}");
|
||||
assert!(!restored.contains("service_tier: priority"), "{restored}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_alias_adds_fast_service_tier_and_removes_only_its_rule() {
|
||||
let input = "payload:\n override:\n - models:\n - name: existing-thinker\n protocol: codex\n params:\n reasoning.effort: xhigh\n";
|
||||
let source = test_codex_oauth_thinking_source("gpt-5.6-sol");
|
||||
let rendered = add_speed_alias_to_yaml(input, &source, "gpt-5.6-sol-fast").unwrap();
|
||||
|
||||
assert!(rendered.contains("alias: gpt-5.6-sol-fast"), "{rendered}");
|
||||
assert!(rendered.contains("service_tier: priority"), "{rendered}");
|
||||
assert!(!rendered.contains("reasoning.effort: fast"), "{rendered}");
|
||||
assert_eq!(
|
||||
speed_aliases_from_yaml(&rendered).unwrap(),
|
||||
vec![SpeedAliasEntry {
|
||||
source_model: "gpt-5.6-sol".to_string(),
|
||||
alias: "gpt-5.6-sol-fast".to_string(),
|
||||
service_tier: "priority".to_string(),
|
||||
provider: "Codex OAuth".to_string(),
|
||||
kind: "codex-oauth".to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
let restored = remove_speed_alias_from_yaml(&rendered, "gpt-5.6-sol-fast").unwrap();
|
||||
assert!(!restored.contains("gpt-5.6-sol-fast"), "{restored}");
|
||||
assert!(restored.contains("reasoning.effort: xhigh"), "{restored}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_alias_supports_openai_compatible_model_entries() {
|
||||
let input = "openai-compatibility:\n - name: Relay\n base-url: https://example.com/v1\n api-key-entries:\n - api-key: test\n models:\n - name: gpt-5.6-terra\n display-name: Terra\n";
|
||||
let available_models = test_agent_models(&["gpt-5.6-terra"]);
|
||||
let sources = resolved_thinking_alias_sources(input, &[], &available_models).unwrap();
|
||||
let source = sources
|
||||
.iter()
|
||||
.find(|source| source.source.model == "gpt-5.6-terra")
|
||||
.unwrap();
|
||||
let rendered = add_speed_alias_to_yaml(input, source, "gpt-5.6-terra-fast").unwrap();
|
||||
|
||||
assert!(rendered.contains("alias: gpt-5.6-terra-fast"), "{rendered}");
|
||||
assert!(
|
||||
rendered.contains("display-name: Terra (Fast)"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(rendered.contains("protocol: openai"), "{rendered}");
|
||||
assert!(rendered.contains("service_tier: priority"), "{rendered}");
|
||||
assert_eq!(speed_aliases_from_yaml(&rendered).unwrap().len(), 1);
|
||||
|
||||
let restored = remove_speed_alias_from_yaml(&rendered, "gpt-5.6-terra-fast").unwrap();
|
||||
assert!(!restored.contains("gpt-5.6-terra-fast"), "{restored}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn speed_alias_sources_include_codex_models_without_reasoning_levels() {
|
||||
let definitions = vec![CodexModelDefinition {
|
||||
id: "gpt-speed-only".to_string(),
|
||||
display_name: None,
|
||||
description: None,
|
||||
context_window: None,
|
||||
reasoning_levels: Vec::new(),
|
||||
supports_tools: None,
|
||||
}];
|
||||
let available_models = test_agent_models(&["gpt-speed-only"]);
|
||||
|
||||
assert!(
|
||||
resolved_thinking_alias_sources("{}", &definitions, &available_models)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let sources = resolved_speed_alias_sources("{}", &definitions, &available_models).unwrap();
|
||||
assert_eq!(sources.len(), 1);
|
||||
assert_eq!(sources[0].source.model, "gpt-speed-only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_alias_effort_accepts_provider_defined_levels() {
|
||||
assert_eq!(validate_thinking_alias_effort(" AUTO ").unwrap(), "auto");
|
||||
assert_eq!(validate_thinking_alias_effort("ultra").unwrap(), "ultra");
|
||||
assert_eq!(
|
||||
validate_thinking_alias_effort("vendor_level-2.1").unwrap(),
|
||||
"vendor_level-2.1"
|
||||
);
|
||||
assert!(validate_thinking_alias_effort("").is_err());
|
||||
assert!(validate_thinking_alias_effort("high value").is_err());
|
||||
assert!(validate_thinking_alias_effort("32768").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_alias_removal_keeps_other_models_in_grouped_rule() {
|
||||
let input = "oauth-model-alias:\n codex:\n - name: gpt-5.5\n alias: gpt-5.5-xhigh\n fork: true\n - name: gpt-5.4\n alias: gpt-5.4-xhigh\n fork: true\npayload:\n override:\n - models:\n - name: gpt-5.5-xhigh\n protocol: codex\n - name: gpt-5.4-xhigh\n protocol: codex\n params:\n reasoning.effort: xhigh\n";
|
||||
let rendered = remove_thinking_alias_from_yaml(input, "gpt-5.5-xhigh").unwrap();
|
||||
let aliases = thinking_aliases_from_yaml(&rendered).unwrap();
|
||||
|
||||
assert_eq!(aliases.len(), 1);
|
||||
assert_eq!(aliases[0].alias, "gpt-5.4-xhigh");
|
||||
assert!(!rendered.contains("gpt-5.5-xhigh"), "{rendered}");
|
||||
assert!(rendered.contains("gpt-5.4-xhigh"), "{rendered}");
|
||||
assert!(rendered.contains("reasoning.effort: xhigh"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_alias_rejects_duplicate_client_visible_name() {
|
||||
let input = "oauth-model-alias:\n codex:\n - name: gpt-5.5\n alias: gpt-5.5-high\n fork: true\n";
|
||||
let source = test_codex_oauth_thinking_source("gpt-5.4");
|
||||
assert!(
|
||||
add_model_alias_to_yaml(input, &source, "GPT-5.5-HIGH", "high", false)
|
||||
.unwrap_err()
|
||||
.contains("已存在")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_alias_supports_openai_compatible_model_entries() {
|
||||
let input = "openai-compatibility:\n - name: DeepSeek\n base-url: https://api.deepseek.com\n api-key-entries:\n - api-key: test\n models:\n - name: deepseek-chat\n display-name: DeepSeek Chat\n thinking:\n levels: [low, medium, high]\n";
|
||||
let available_models = test_agent_models(&["deepseek-chat"]);
|
||||
let sources = resolved_thinking_alias_sources(input, &[], &available_models).unwrap();
|
||||
let source = sources
|
||||
.iter()
|
||||
.find(|source| source.source.model == "deepseek-chat")
|
||||
.unwrap();
|
||||
let rendered =
|
||||
add_model_alias_to_yaml(input, source, "deepseek-chat-high", "high", false).unwrap();
|
||||
let value: serde_norway::Value = serde_norway::from_str(&rendered).unwrap();
|
||||
let root = value.as_mapping().unwrap();
|
||||
let providers = yaml_mapping_value(root, "openai-compatibility")
|
||||
.and_then(serde_norway::Value::as_sequence)
|
||||
.unwrap();
|
||||
let models = yaml_mapping_value(providers[0].as_mapping().unwrap(), "models")
|
||||
.and_then(serde_norway::Value::as_sequence)
|
||||
.unwrap();
|
||||
let alias_model = models[1].as_mapping().unwrap();
|
||||
|
||||
assert_eq!(models.len(), 2);
|
||||
assert_eq!(
|
||||
yaml_mapping_value(alias_model, "name").and_then(serde_norway::Value::as_str),
|
||||
Some("deepseek-chat")
|
||||
);
|
||||
assert_eq!(
|
||||
yaml_mapping_value(alias_model, "alias").and_then(serde_norway::Value::as_str),
|
||||
Some("deepseek-chat-high")
|
||||
);
|
||||
assert!(rendered.contains("protocol: openai"), "{rendered}");
|
||||
assert!(rendered.contains("reasoning_effort: high"), "{rendered}");
|
||||
assert!(rendered.contains("thinking.type: enabled"), "{rendered}");
|
||||
assert!(!rendered.contains("oauth-model-alias"), "{rendered}");
|
||||
|
||||
let entries = thinking_aliases_from_yaml(&rendered).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].provider, "DeepSeek");
|
||||
assert_eq!(entries[0].kind, "openai-compatible");
|
||||
|
||||
let restored = remove_thinking_alias_from_yaml(&rendered, "deepseek-chat-high").unwrap();
|
||||
assert!(!restored.contains("deepseek-chat-high"), "{restored}");
|
||||
assert!(!restored.contains("reasoning_effort"), "{restored}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_alias_supports_codex_api_model_entries() {
|
||||
let input = "codex-api-key:\n - api-key: test\n base-url: https://example.com/v1\n models:\n - name: gpt-custom\n";
|
||||
let available_models = test_agent_models(&["gpt-custom"]);
|
||||
let sources = resolved_thinking_alias_sources(input, &[], &available_models).unwrap();
|
||||
let source = sources
|
||||
.iter()
|
||||
.find(|source| source.source.kind == "codex-api")
|
||||
.unwrap();
|
||||
let rendered =
|
||||
add_model_alias_to_yaml(input, source, "gpt-custom-xhigh", "xhigh", false).unwrap();
|
||||
|
||||
assert!(rendered.contains("alias: gpt-custom-xhigh"), "{rendered}");
|
||||
assert!(rendered.contains("protocol: codex"), "{rendered}");
|
||||
assert!(rendered.contains("reasoning.effort: xhigh"), "{rendered}");
|
||||
assert!(!rendered.contains("oauth-model-alias"), "{rendered}");
|
||||
let entries = thinking_aliases_from_yaml(&rendered).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].kind, "codex-api");
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_tray_presentation_tracks_core_state_and_busy_actions() {
|
||||
let mut status = CoreStatus {
|
||||
installed: false,
|
||||
running: false,
|
||||
managed: false,
|
||||
process_id: None,
|
||||
current_version: None,
|
||||
install_dir: String::new(),
|
||||
binary_path: None,
|
||||
message: String::new(),
|
||||
};
|
||||
|
||||
let missing = windows_tray_presentation(&status, false, "zh-CN");
|
||||
assert_eq!(missing.status_text, "内核状态:未安装");
|
||||
assert!(!missing.toggle_enabled);
|
||||
assert!(!missing.restart_enabled);
|
||||
|
||||
status.installed = true;
|
||||
let stopped = windows_tray_presentation(&status, false, "zh-CN");
|
||||
assert_eq!(stopped.toggle_text, "启动内核");
|
||||
assert!(stopped.toggle_enabled);
|
||||
assert!(!stopped.restart_enabled);
|
||||
|
||||
status.running = true;
|
||||
let running = windows_tray_presentation(&status, false, "zh-CN");
|
||||
assert_eq!(running.status_text, "内核状态:运行中");
|
||||
assert_eq!(running.toggle_text, "停止内核");
|
||||
assert!(running.toggle_enabled);
|
||||
assert!(running.restart_enabled);
|
||||
|
||||
let busy = windows_tray_presentation(&status, true, "zh-CN");
|
||||
assert_eq!(busy.status_text, "内核状态:处理中");
|
||||
assert!(!busy.toggle_enabled);
|
||||
assert!(!busy.restart_enabled);
|
||||
|
||||
let english = windows_tray_presentation(&status, false, "en-US");
|
||||
assert_eq!(english.status_text, "Core status: Running");
|
||||
assert_eq!(english.toggle_text, "Stop Core");
|
||||
|
||||
let japanese = windows_tray_presentation(&status, false, "ja-JP");
|
||||
assert_eq!(japanese.status_text, "コア状態:実行中");
|
||||
assert_eq!(japanese.toggle_text, "コアを停止");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_locale_normalization_has_a_stable_chinese_fallback() {
|
||||
assert_eq!(normalize_app_locale("en"), "en");
|
||||
assert_eq!(normalize_app_locale("en-US"), "en");
|
||||
assert_eq!(normalize_app_locale("ja-JP"), "ja");
|
||||
assert_eq!(normalize_app_locale("zh-TW"), "zh-TW");
|
||||
assert_eq!(normalize_app_locale("zh-Hant-HK"), "zh-TW");
|
||||
assert_eq!(normalize_app_locale("unsupported"), "zh-CN");
|
||||
assert_eq!(GuiConfigFile::default().locale, "zh-CN");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_chatgpt_discovery_parser_accepts_registered_app_and_executable() {
|
||||
let app = parse_windows_codex_app_discovery_output("APPID:OpenAI.Codex_2p2nqsd0c76g0!App\r\n")
|
||||
.unwrap();
|
||||
match app {
|
||||
CodexAppTarget::WindowsAppId(app_id) => {
|
||||
assert_eq!(app_id, "OpenAI.Codex_2p2nqsd0c76g0!App");
|
||||
}
|
||||
CodexAppTarget::Application(_) => panic!("expected Store application ID"),
|
||||
}
|
||||
|
||||
let executable = parse_windows_codex_app_discovery_output(
|
||||
"warning\r\nEXE:C:\\Program Files\\OpenAI\\ChatGPT\\ChatGPT.exe\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
match executable {
|
||||
CodexAppTarget::Application(path) => {
|
||||
assert_eq!(
|
||||
path,
|
||||
PathBuf::from(r"C:\Program Files\OpenAI\ChatGPT\ChatGPT.exe")
|
||||
);
|
||||
}
|
||||
CodexAppTarget::WindowsAppId(_) => panic!("expected desktop executable"),
|
||||
}
|
||||
|
||||
assert!(parse_windows_codex_app_discovery_output("MSEdgePWA:ChatGPT\r\n").is_none());
|
||||
|
||||
let registry_output = r"HKEY_CURRENT_USER\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages\OpenAI.Codex_26.715.4045.0_x64__2p2nqsd0c76g0";
|
||||
assert_eq!(
|
||||
parse_windows_codex_app_id_from_registry(registry_output).as_deref(),
|
||||
Some("OpenAI.Codex_2p2nqsd0c76g0!App")
|
||||
);
|
||||
assert_eq!(
|
||||
windows_codex_app_id_from_package_full_name("OpenAI.ChatGPT_1.2.3.4_arm64__2p2nqsd0c76g0")
|
||||
.as_deref(),
|
||||
Some("OpenAI.ChatGPT_2p2nqsd0c76g0!App")
|
||||
);
|
||||
assert!(windows_codex_app_id_from_package_full_name(
|
||||
"Microsoft.MicrosoftEdge_1.0.0.0_x64__8wekyb3d8bbwe"
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn provider_health_stream_detects_only_real_model_text() {
|
||||
assert!(provider_health_content_type_is_streaming(
|
||||
"text/event-stream; charset=utf-8"
|
||||
));
|
||||
assert!(provider_health_content_type_is_streaming(
|
||||
"application/x-ndjson"
|
||||
));
|
||||
assert!(!provider_health_content_type_is_streaming(
|
||||
"application/json"
|
||||
));
|
||||
|
||||
let openai_metadata = br#"data: {"choices":[{"delta":{"role":"assistant","content":""}}]}
|
||||
|
||||
"#;
|
||||
let openai_text = br#"data: {"choices":[{"delta":{"content":"H"}}]}
|
||||
|
||||
"#;
|
||||
assert!(!provider_health_stream_has_text(
|
||||
"openai-chat",
|
||||
openai_metadata
|
||||
));
|
||||
assert!(provider_health_stream_has_text("openai-chat", openai_text));
|
||||
let openai_reasoning = br#"data: {"choices":[{"delta":{"reasoning_content":"R"}}]}
|
||||
|
||||
"#;
|
||||
assert!(provider_health_stream_has_text(
|
||||
"openai-chat",
|
||||
openai_reasoning
|
||||
));
|
||||
|
||||
let responses_metadata = br#"event: response.created
|
||||
data: {"type":"response.created"}
|
||||
|
||||
"#;
|
||||
let responses_text = br#"event: response.output_text.delta
|
||||
data: {"type":"response.output_text.delta","delta":"H"}
|
||||
|
||||
"#;
|
||||
assert!(!provider_health_stream_has_text(
|
||||
"openai-responses",
|
||||
responses_metadata
|
||||
));
|
||||
assert!(provider_health_stream_has_text(
|
||||
"openai-responses",
|
||||
responses_text
|
||||
));
|
||||
|
||||
let claude_metadata = br#"event: message_start
|
||||
data: {"type":"message_start"}
|
||||
|
||||
"#;
|
||||
let claude_text = br#"event: content_block_delta
|
||||
data: {"delta":{"type":"text_delta","text":"H"}}
|
||||
|
||||
"#;
|
||||
assert!(!provider_health_stream_has_text("claude", claude_metadata));
|
||||
assert!(provider_health_stream_has_text("claude", claude_text));
|
||||
|
||||
let gemini_text = br#"data: {"candidates":[{"content":{"parts":[{"text":"H"}]}}]}
|
||||
|
||||
"#;
|
||||
assert!(provider_health_stream_has_text("gemini", gemini_text));
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn agent_test_home(name: &str) -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"cpa-gui-agent-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
pub(super) fn test_agent_models(names: &[&str]) -> Vec<AgentModelOption> {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| AgentModelOption {
|
||||
name: (*name).to_string(),
|
||||
alias: None,
|
||||
is_alias: false,
|
||||
context_window: Some(200_000),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn test_codex_models(names: &[&str]) -> String {
|
||||
let payload = serde_json::json!({
|
||||
"models": names.iter().map(|name| serde_json::json!({ "id": name })).collect::<Vec<_>>()
|
||||
});
|
||||
let runtime = codex_catalog::parse_runtime_models(&payload).unwrap();
|
||||
codex_catalog::prepare_catalog(&runtime).unwrap().json
|
||||
}
|
||||
|
||||
pub(super) fn test_codex_oauth_thinking_source(model: &str) -> ResolvedThinkingAliasSource {
|
||||
ResolvedThinkingAliasSource {
|
||||
source: ThinkingAliasSource {
|
||||
id: format!("codex-oauth:{model}"),
|
||||
model: model.to_string(),
|
||||
display_name: None,
|
||||
provider: "Codex OAuth".to_string(),
|
||||
kind: "codex-oauth".to_string(),
|
||||
protocol: "codex".to_string(),
|
||||
},
|
||||
location: ThinkingAliasSourceLocation::CodexOauth,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
use super::*;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const MACOS_TRAY_ID: &str = "macos-tray";
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MacosTrayClickState {
|
||||
last_click: Option<Instant>,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn set_macos_dock_visible(visible: bool) -> bool {
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
eprintln!("更新 Dock 图标状态必须在主线程执行");
|
||||
return false;
|
||||
};
|
||||
let policy = if visible {
|
||||
NSApplicationActivationPolicy::Regular
|
||||
} else {
|
||||
NSApplicationActivationPolicy::Accessory
|
||||
};
|
||||
if !NSApplication::sharedApplication(mtm).setActivationPolicy(policy) {
|
||||
eprintln!("更新 Dock 图标状态失败");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn show_main_window_on_main_thread(app_handle: &tauri::AppHandle) {
|
||||
let Some(window) = app_handle.get_webview_window("main") else {
|
||||
return;
|
||||
};
|
||||
if !set_macos_dock_visible(true) {
|
||||
return;
|
||||
}
|
||||
if let Err(error) = window.show() {
|
||||
eprintln!("显示主窗口失败: {error}");
|
||||
set_macos_dock_visible(false);
|
||||
return;
|
||||
}
|
||||
if window.is_minimized().unwrap_or(false) {
|
||||
if let Err(error) = window.unminimize() {
|
||||
eprintln!("恢复主窗口失败: {error}");
|
||||
}
|
||||
}
|
||||
if let Err(error) = window.set_focus() {
|
||||
eprintln!("聚焦主窗口失败: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn show_main_window(app_handle: &tauri::AppHandle) {
|
||||
if MainThreadMarker::new().is_some() {
|
||||
show_main_window_on_main_thread(app_handle);
|
||||
return;
|
||||
}
|
||||
|
||||
let app_handle = app_handle.clone();
|
||||
if let Err(error) = app_handle.clone().run_on_main_thread(move || {
|
||||
show_main_window_on_main_thread(&app_handle);
|
||||
}) {
|
||||
eprintln!("调度主窗口显示失败: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn show_macos_tray_menu<R: tauri::Runtime>(tray: &TrayIcon<R>) {
|
||||
let result = tray.with_inner_tray_icon(|tray_icon| {
|
||||
let Some(status_item) = tray_icon.ns_status_item() else {
|
||||
return;
|
||||
};
|
||||
let mtm = MainThreadMarker::new().expect("tray menu must be shown on the main thread");
|
||||
if let Some(menu) = status_item.menu(mtm) {
|
||||
#[allow(deprecated)]
|
||||
status_item.popUpStatusItemMenu(&menu);
|
||||
}
|
||||
});
|
||||
|
||||
if let Err(error) = result {
|
||||
eprintln!("显示托盘菜单失败: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn setup_macos_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
|
||||
let locale = app
|
||||
.state::<GuiConfigState>()
|
||||
.snapshot()
|
||||
.map(|config| config.locale)
|
||||
.unwrap_or_else(|_| "zh-CN".to_string());
|
||||
let open_main_window = MenuItem::with_id(
|
||||
app,
|
||||
"open-main-window",
|
||||
locale_text(&locale, "打开主界面", "Open Main Window"),
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let quit = MenuItem::with_id(
|
||||
app,
|
||||
"quit",
|
||||
locale_text(&locale, "退出", "Quit"),
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let menu = Menu::with_items(app, &[&open_main_window, &quit])?;
|
||||
let click_state = Arc::new(Mutex::new(MacosTrayClickState::default()));
|
||||
let double_click_interval = Duration::from_secs_f64(NSEvent::doubleClickInterval());
|
||||
|
||||
TrayIconBuilder::with_id(MACOS_TRAY_ID)
|
||||
.icon(
|
||||
app.default_window_icon()
|
||||
.cloned()
|
||||
.expect("application icon is required for the tray"),
|
||||
)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(move |app_handle, event| match event.id().as_ref() {
|
||||
"open-main-window" => show_main_window(app_handle),
|
||||
"quit" => app_handle.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(move |tray, event| {
|
||||
if !matches!(
|
||||
event,
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
}
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
let Ok(mut state) = click_state.lock() else {
|
||||
eprintln!("读取托盘点击状态失败");
|
||||
return;
|
||||
};
|
||||
state.sequence += 1;
|
||||
let sequence = state.sequence;
|
||||
|
||||
if state
|
||||
.last_click
|
||||
.is_some_and(|last_click| now.duration_since(last_click) <= double_click_interval)
|
||||
{
|
||||
state.last_click = None;
|
||||
drop(state);
|
||||
show_main_window(tray.app_handle());
|
||||
return;
|
||||
}
|
||||
|
||||
state.last_click = Some(now);
|
||||
drop(state);
|
||||
|
||||
let app_handle = tray.app_handle().clone();
|
||||
let click_state = Arc::clone(&click_state);
|
||||
let tray_id = tray.id().clone();
|
||||
thread::spawn(move || {
|
||||
thread::sleep(double_click_interval);
|
||||
let should_show_menu = match click_state.lock() {
|
||||
Ok(mut state) if state.sequence == sequence => {
|
||||
state.last_click = None;
|
||||
true
|
||||
}
|
||||
Ok(_) => false,
|
||||
Err(_) => {
|
||||
eprintln!("读取托盘点击状态失败");
|
||||
false
|
||||
}
|
||||
};
|
||||
if !should_show_menu {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(tray) = app_handle.tray_by_id(&tray_id) {
|
||||
show_macos_tray_menu(&tray);
|
||||
}
|
||||
});
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_TRAY_ID: &str = "windows-tray";
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_TRAY_OPEN_MENU_ID: &str = "windows-tray-open-main-window";
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_TRAY_STATUS_MENU_ID: &str = "windows-tray-core-status";
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_TRAY_TOGGLE_CORE_MENU_ID: &str = "windows-tray-toggle-core";
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_TRAY_RESTART_CORE_MENU_ID: &str = "windows-tray-restart-core";
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_TRAY_QUIT_MENU_ID: &str = "windows-tray-quit";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) enum WindowsTrayCoreAction {
|
||||
Toggle,
|
||||
Restart,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) struct WindowsTrayPresentation {
|
||||
pub(crate) status_text: String,
|
||||
pub(crate) toggle_text: String,
|
||||
pub(crate) toggle_enabled: bool,
|
||||
pub(crate) restart_enabled: bool,
|
||||
pub(crate) tooltip: String,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn windows_tray_presentation(
|
||||
status: &CoreStatus,
|
||||
busy: bool,
|
||||
locale: &str,
|
||||
) -> WindowsTrayPresentation {
|
||||
let status_text = if busy {
|
||||
locale_text(locale, "内核状态:处理中", "Core status: Working")
|
||||
} else if !status.installed {
|
||||
locale_text(locale, "内核状态:未安装", "Core status: Not installed")
|
||||
} else if status.running {
|
||||
locale_text(locale, "内核状态:运行中", "Core status: Running")
|
||||
} else {
|
||||
locale_text(locale, "内核状态:已停止", "Core status: Stopped")
|
||||
};
|
||||
let toggle_text = if busy {
|
||||
locale_text(locale, "处理中...", "Working...")
|
||||
} else if status.running {
|
||||
locale_text(locale, "停止内核", "Stop Core")
|
||||
} else {
|
||||
locale_text(locale, "启动内核", "Start Core")
|
||||
};
|
||||
let tooltip = if busy {
|
||||
locale_text(
|
||||
locale,
|
||||
"EasyCLIProxyAPI · 内核处理中",
|
||||
"EasyCLIProxyAPI · Core working",
|
||||
)
|
||||
} else if !status.installed {
|
||||
locale_text(
|
||||
locale,
|
||||
"EasyCLIProxyAPI · 内核未安装",
|
||||
"EasyCLIProxyAPI · Core not installed",
|
||||
)
|
||||
} else if status.running {
|
||||
locale_text(
|
||||
locale,
|
||||
"EasyCLIProxyAPI · 内核运行中",
|
||||
"EasyCLIProxyAPI · Core running",
|
||||
)
|
||||
} else {
|
||||
locale_text(
|
||||
locale,
|
||||
"EasyCLIProxyAPI · 内核已停止",
|
||||
"EasyCLIProxyAPI · Core stopped",
|
||||
)
|
||||
};
|
||||
|
||||
WindowsTrayPresentation {
|
||||
status_text: status_text.to_string(),
|
||||
toggle_text: toggle_text.to_string(),
|
||||
toggle_enabled: status.installed && !busy,
|
||||
restart_enabled: status.installed && status.running && !busy,
|
||||
tooltip: tooltip.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) struct WindowsTrayState {
|
||||
open_main_window: MenuItem<tauri::Wry>,
|
||||
status_item: MenuItem<tauri::Wry>,
|
||||
toggle_core_item: MenuItem<tauri::Wry>,
|
||||
restart_core_item: MenuItem<tauri::Wry>,
|
||||
quit_item: MenuItem<tauri::Wry>,
|
||||
locale: Mutex<String>,
|
||||
busy: AtomicBool,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
impl WindowsTrayState {
|
||||
fn locale(&self) -> String {
|
||||
self.locale
|
||||
.lock()
|
||||
.map(|locale| locale.clone())
|
||||
.unwrap_or_else(|_| "zh-CN".to_string())
|
||||
}
|
||||
|
||||
fn set_locale(&self, locale: &str) {
|
||||
let normalized = normalize_app_locale(locale);
|
||||
if let Ok(mut current) = self.locale.lock() {
|
||||
*current = normalized.to_string();
|
||||
}
|
||||
let labels = [
|
||||
(
|
||||
&self.open_main_window,
|
||||
locale_text(normalized, "打开主界面", "Open Main Window"),
|
||||
),
|
||||
(
|
||||
&self.restart_core_item,
|
||||
locale_text(normalized, "重启内核", "Restart Core"),
|
||||
),
|
||||
(&self.quit_item, locale_text(normalized, "退出", "Quit")),
|
||||
];
|
||||
for (item, label) in labels {
|
||||
if let Err(error) = item.set_text(label) {
|
||||
eprintln!("更新 Windows 托盘语言失败: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_action(&self) -> bool {
|
||||
if self
|
||||
.busy
|
||||
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let locale = self.locale();
|
||||
if let Err(error) = self.status_item.set_text(locale_text(
|
||||
&locale,
|
||||
"内核状态:处理中",
|
||||
"Core status: Working",
|
||||
)) {
|
||||
eprintln!("更新 Windows 托盘内核状态失败: {error}");
|
||||
}
|
||||
if let Err(error) =
|
||||
self.toggle_core_item
|
||||
.set_text(locale_text(&locale, "处理中...", "Working..."))
|
||||
{
|
||||
eprintln!("更新 Windows 托盘操作文本失败: {error}");
|
||||
}
|
||||
if let Err(error) = self.toggle_core_item.set_enabled(false) {
|
||||
eprintln!("更新 Windows 托盘操作状态失败: {error}");
|
||||
}
|
||||
if let Err(error) = self.restart_core_item.set_enabled(false) {
|
||||
eprintln!("更新 Windows 托盘重启状态失败: {error}");
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn finish_action(&self) {
|
||||
self.busy.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
fn update(&self, status: &CoreStatus) -> String {
|
||||
let presentation =
|
||||
windows_tray_presentation(status, self.busy.load(Ordering::Acquire), &self.locale());
|
||||
if let Err(error) = self.status_item.set_text(presentation.status_text.clone()) {
|
||||
eprintln!("更新 Windows 托盘内核状态失败: {error}");
|
||||
}
|
||||
if let Err(error) = self
|
||||
.toggle_core_item
|
||||
.set_text(presentation.toggle_text.clone())
|
||||
{
|
||||
eprintln!("更新 Windows 托盘操作文本失败: {error}");
|
||||
}
|
||||
if let Err(error) = self
|
||||
.toggle_core_item
|
||||
.set_enabled(presentation.toggle_enabled)
|
||||
{
|
||||
eprintln!("更新 Windows 托盘操作状态失败: {error}");
|
||||
}
|
||||
if let Err(error) = self
|
||||
.restart_core_item
|
||||
.set_enabled(presentation.restart_enabled)
|
||||
{
|
||||
eprintln!("更新 Windows 托盘重启状态失败: {error}");
|
||||
}
|
||||
presentation.tooltip
|
||||
}
|
||||
|
||||
fn show_error(&self, error: &str) {
|
||||
let mut summary = error.chars().take(48).collect::<String>();
|
||||
if error.chars().count() > 48 {
|
||||
summary.push('…');
|
||||
}
|
||||
let text = match normalize_app_locale(&self.locale()) {
|
||||
"en" => format!("Operation failed: {summary}"),
|
||||
"zh-TW" => format!("操作失敗:{summary}"),
|
||||
"ja" => format!("操作に失敗しました:{summary}"),
|
||||
_ => format!("操作失败:{summary}"),
|
||||
};
|
||||
if let Err(update_error) = self.status_item.set_text(text) {
|
||||
eprintln!("更新 Windows 托盘错误状态失败: {update_error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn show_windows_main_window(app_handle: &tauri::AppHandle) {
|
||||
let Some(window) = app_handle.get_webview_window("main") else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = window.show() {
|
||||
eprintln!("显示主窗口失败: {error}");
|
||||
return;
|
||||
}
|
||||
if window.is_minimized().unwrap_or(false) {
|
||||
if let Err(error) = window.unminimize() {
|
||||
eprintln!("恢复主窗口失败: {error}");
|
||||
}
|
||||
}
|
||||
if let Err(error) = window.set_focus() {
|
||||
eprintln!("聚焦主窗口失败: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn update_windows_tray_status(app_handle: &tauri::AppHandle, status: &CoreStatus) {
|
||||
let tooltip = app_handle
|
||||
.try_state::<WindowsTrayState>()
|
||||
.map(|tray_state| tray_state.update(status))
|
||||
.unwrap_or_else(|| windows_tray_presentation(status, false, "zh-CN").tooltip);
|
||||
|
||||
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
|
||||
if let Err(error) = tray.set_tooltip(Some(&tooltip)) {
|
||||
eprintln!("更新 Windows 托盘提示失败: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn update_windows_tray_locale(
|
||||
app_handle: &tauri::AppHandle,
|
||||
locale: &str,
|
||||
status: &CoreStatus,
|
||||
) {
|
||||
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
|
||||
tray_state.set_locale(locale);
|
||||
}
|
||||
update_windows_tray_status(app_handle, status);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn show_windows_tray_action_error(app_handle: &tauri::AppHandle, error: &str) {
|
||||
eprintln!("Windows 托盘内核操作失败: {error}");
|
||||
let locale = app_handle
|
||||
.try_state::<WindowsTrayState>()
|
||||
.map(|state| state.locale())
|
||||
.unwrap_or_else(|| "zh-CN".to_string());
|
||||
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
|
||||
tray_state.show_error(error);
|
||||
}
|
||||
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
|
||||
if let Err(update_error) = tray.set_tooltip(Some(locale_text(
|
||||
&locale,
|
||||
"EasyCLIProxyAPI · 内核操作失败",
|
||||
"EasyCLIProxyAPI · Core operation failed",
|
||||
))) {
|
||||
eprintln!("更新 Windows 托盘错误提示失败: {update_error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn run_windows_tray_core_action(
|
||||
app_handle: &tauri::AppHandle,
|
||||
action: WindowsTrayCoreAction,
|
||||
) {
|
||||
let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() else {
|
||||
return;
|
||||
};
|
||||
if !tray_state.begin_action() {
|
||||
return;
|
||||
}
|
||||
if let Some(tray) = app_handle.tray_by_id(WINDOWS_TRAY_ID) {
|
||||
if let Err(error) = tray.set_tooltip(Some(locale_text(
|
||||
&tray_state.locale(),
|
||||
"EasyCLIProxyAPI · 内核处理中",
|
||||
"EasyCLIProxyAPI · Core working",
|
||||
))) {
|
||||
eprintln!("更新 Windows 托盘处理中提示失败: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
let app_handle = app_handle.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let process_state = app_handle.state::<CoreProcessState>();
|
||||
let gui_config_state = app_handle.state::<GuiConfigState>();
|
||||
let result = (|| -> Result<CoreStatus, String> {
|
||||
match action {
|
||||
WindowsTrayCoreAction::Toggle => {
|
||||
let config = gui_config_state.snapshot()?;
|
||||
let status =
|
||||
current_core_status(Some(process_state.inner()), Some(config.port))?;
|
||||
if status.running {
|
||||
stop_core_process_with_state(
|
||||
process_state.inner(),
|
||||
gui_config_state.inner(),
|
||||
)
|
||||
} else {
|
||||
start_core_process_with_state(
|
||||
process_state.inner(),
|
||||
gui_config_state.inner(),
|
||||
)
|
||||
}
|
||||
}
|
||||
WindowsTrayCoreAction::Restart => {
|
||||
restart_core_process_with_state(process_state.inner(), gui_config_state.inner())
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if let Some(tray_state) = app_handle.try_state::<WindowsTrayState>() {
|
||||
tray_state.finish_action();
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(status) => emit_core_status(&app_handle, &status),
|
||||
Err(error) => {
|
||||
if let Ok(config) = gui_config_state.snapshot() {
|
||||
if let Ok(status) =
|
||||
current_core_status(Some(process_state.inner()), Some(config.port))
|
||||
{
|
||||
emit_core_status(&app_handle, &status);
|
||||
}
|
||||
}
|
||||
show_windows_tray_action_error(&app_handle, &error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) fn setup_windows_tray(app: &mut tauri::App<tauri::Wry>) -> tauri::Result<()> {
|
||||
let locale = app
|
||||
.state::<GuiConfigState>()
|
||||
.snapshot()
|
||||
.map(|config| config.locale)
|
||||
.unwrap_or_else(|_| "zh-CN".to_string());
|
||||
let open_main_window = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_OPEN_MENU_ID,
|
||||
locale_text(&locale, "打开主界面", "Open Main Window"),
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let status_item = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_STATUS_MENU_ID,
|
||||
locale_text(&locale, "内核状态:正在检查", "Core status: Checking"),
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let toggle_core_item = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_TOGGLE_CORE_MENU_ID,
|
||||
locale_text(&locale, "启动内核", "Start Core"),
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let restart_core_item = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_RESTART_CORE_MENU_ID,
|
||||
locale_text(&locale, "重启内核", "Restart Core"),
|
||||
false,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let quit = MenuItem::with_id(
|
||||
app,
|
||||
WINDOWS_TRAY_QUIT_MENU_ID,
|
||||
locale_text(&locale, "退出", "Quit"),
|
||||
true,
|
||||
None::<&str>,
|
||||
)?;
|
||||
let separator_one = PredefinedMenuItem::separator(app)?;
|
||||
let separator_two = PredefinedMenuItem::separator(app)?;
|
||||
let menu = Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&open_main_window,
|
||||
&separator_one,
|
||||
&status_item,
|
||||
&toggle_core_item,
|
||||
&restart_core_item,
|
||||
&separator_two,
|
||||
&quit,
|
||||
],
|
||||
)?;
|
||||
|
||||
TrayIconBuilder::with_id(WINDOWS_TRAY_ID)
|
||||
.icon(
|
||||
app.default_window_icon()
|
||||
.cloned()
|
||||
.expect("application icon is required for the tray"),
|
||||
)
|
||||
.tooltip("EasyCLIProxyAPI")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(move |app_handle, event| match event.id().as_ref() {
|
||||
WINDOWS_TRAY_OPEN_MENU_ID => show_windows_main_window(app_handle),
|
||||
WINDOWS_TRAY_TOGGLE_CORE_MENU_ID => {
|
||||
run_windows_tray_core_action(app_handle, WindowsTrayCoreAction::Toggle)
|
||||
}
|
||||
WINDOWS_TRAY_RESTART_CORE_MENU_ID => {
|
||||
run_windows_tray_core_action(app_handle, WindowsTrayCoreAction::Restart)
|
||||
}
|
||||
WINDOWS_TRAY_QUIT_MENU_ID => app_handle.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(move |tray, event| {
|
||||
if matches!(
|
||||
event,
|
||||
TrayIconEvent::DoubleClick {
|
||||
button: MouseButton::Left,
|
||||
..
|
||||
}
|
||||
) {
|
||||
show_windows_main_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
let _ = app.manage(WindowsTrayState {
|
||||
open_main_window,
|
||||
status_item,
|
||||
toggle_core_item,
|
||||
restart_core_item,
|
||||
quit_item: quit,
|
||||
locale: Mutex::new(locale),
|
||||
busy: AtomicBool::new(false),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { MessageKey } from './resources';
|
||||
import type { MessageKey } from './locales/zh-CN';
|
||||
|
||||
export const jaOverrides = {
|
||||
'apiAccess.models.add': 'カスタムモデルを追加',
|
||||
|
||||
@@ -0,0 +1,930 @@
|
||||
import type { MessageKey } from './zh-CN';
|
||||
|
||||
export const en: Record<MessageKey, string> = {
|
||||
'apiAccess.models.add': 'Add Custom Model',
|
||||
'apiAccess.models.namePlaceholder': 'Model name, for example gpt-4o',
|
||||
'apiAccess.models.aliasPlaceholder': 'Model alias (optional)',
|
||||
'apiAccess.models.remove': 'Remove model',
|
||||
'app.language': 'Language',
|
||||
'app.theme.light': 'Light mode',
|
||||
'app.theme.dark': 'Dark mode',
|
||||
'app.theme.switchToLight': 'Switch to light mode',
|
||||
'app.theme.switchToDark': 'Switch to dark mode',
|
||||
'app.desktopConsole': 'Desktop Console',
|
||||
'app.navigation': 'Main navigation',
|
||||
'app.nav.home': 'Home',
|
||||
'app.nav.versions': 'Version Management',
|
||||
'app.nav.kernel': 'Core',
|
||||
'app.nav.config': 'Advanced Settings',
|
||||
'app.nav.thinkingAliases': 'Model Aliases',
|
||||
'app.nav.oauth': 'OAuth',
|
||||
'app.nav.api': 'API Access',
|
||||
'app.nav.authFiles': 'Auth Files',
|
||||
'app.nav.quota': 'Quota Lookup',
|
||||
'app.nav.usageRecords': 'Usage',
|
||||
'app.nav.agents': 'Agent Configuration',
|
||||
'app.coreRequired.title': 'Start the core first',
|
||||
'app.coreRequired.description': 'Start the CPA core to use settings, OAuth, API access, and other features.',
|
||||
'app.contact.title': 'Contact us on QQ',
|
||||
'app.contact.label': 'Contact Us',
|
||||
'app.close.title': 'What would you like to do?',
|
||||
'app.close.description': 'Exit the app, or keep EasyCLIProxyAPI running in the system tray.',
|
||||
'app.close.minimizing': 'Minimizing...',
|
||||
'app.close.minimize': 'Minimize to Tray',
|
||||
'app.close.exiting': 'Exiting...',
|
||||
'app.close.exit': 'Exit App',
|
||||
'app.close.remember': 'Remember this choice',
|
||||
'error.render.title': 'Something went wrong while rendering this page',
|
||||
'error.unknown': 'Unknown error',
|
||||
'error.reload': 'Reload Page',
|
||||
'quota.title': 'Quota Lookup',
|
||||
'quota.queryableCredentials.one': '{count} queryable credential',
|
||||
'quota.queryableCredentials.other': '{count} queryable credentials',
|
||||
'quota.credentials.one': '{count} credential',
|
||||
'quota.credentials.other': '{count} credentials',
|
||||
'quota.readList': 'Load List',
|
||||
'quota.refreshAll': 'Refresh All',
|
||||
'quota.loadingFiles': 'Loading authentication files',
|
||||
'quota.empty.title': 'No quota data available',
|
||||
'quota.empty.description': 'Add a credential that supports quota queries under Auth Files first.',
|
||||
'quota.unknownProvider': 'Unknown',
|
||||
'quota.reset': 'Reset Quota',
|
||||
'quota.fileDisabled': 'Authentication file is disabled',
|
||||
'quota.refresh': 'Fetch or refresh quota',
|
||||
'quota.notFetched': 'Quota has not been fetched',
|
||||
'quota.disabled': 'Disabled',
|
||||
'quota.fetch': 'Fetch Quota',
|
||||
'quota.querying': 'Querying',
|
||||
'quota.resetCredits': 'Manual resets',
|
||||
'quota.earliestExpiry': 'Earliest expiry',
|
||||
'quota.remaining': '{percent}% remaining',
|
||||
'quota.confirm.title': 'Reset the Codex quota for “{name}”?',
|
||||
'quota.confirm.cost': 'This will consume one manual reset credit.',
|
||||
'quota.confirm.available': 'Currently available: {count}',
|
||||
'quota.confirm.expiry': 'Earliest expiry: {time}',
|
||||
'quota.confirm.warning': 'The reset runs only after you confirm. Canceling will not consume a reset credit.',
|
||||
'oauth.title': 'OAuth Sign-In',
|
||||
'oauth.tabs.label': 'OAuth management sections',
|
||||
'oauth.loginSuccess': '{provider} sign-in succeeded',
|
||||
'oauth.priorityApplyFailed': 'OAuth sign-in succeeded, but the new credential priority could not be set to 0: {error}',
|
||||
'oauth.authFailed': 'Authentication failed',
|
||||
'oauth.loginFailed': '{provider} sign-in failed{detail}',
|
||||
'oauth.missingState': 'The core did not return an OAuth state',
|
||||
'oauth.missingStatePolling': 'The core did not return an OAuth state, so sign-in status cannot be polled',
|
||||
'oauth.openFailedDetail': 'The sign-in link is ready, but the browser could not be opened: {error}',
|
||||
'oauth.openFailed': 'The sign-in link is ready, but the browser could not be opened. Open it manually.',
|
||||
'oauth.linkCopied': 'Sign-in link copied',
|
||||
'oauth.linkCopyFailed': 'Failed to copy the sign-in link',
|
||||
'oauth.pasteCallback': 'Paste the callback URL',
|
||||
'oauth.pasteXaiCallback': 'Paste the code or callback URL returned by xAI',
|
||||
'oauth.invalidCallback': 'The callback is invalid',
|
||||
'oauth.invalidXaiCallback': 'Could not build the xAI callback. Check the code and state.',
|
||||
'oauth.callbackSubmittedNotice': 'Callback submitted. Waiting for authentication to finish.',
|
||||
'oauth.loginAnother': 'Sign In Another Account',
|
||||
'oauth.loggingIn': 'Signing in…',
|
||||
'oauth.startLogin': 'Start Sign-In',
|
||||
'oauth.hint': 'Starting sign-in opens your browser for authorization. If it does not open, use the link below.',
|
||||
'oauth.authorizationLink': 'Authorization link',
|
||||
'oauth.copyLink': 'Copy Link',
|
||||
'oauth.openLink': 'Open Link',
|
||||
'oauth.callbackPlaceholder': 'Paste the full callback URL',
|
||||
'oauth.xaiCallbackPlaceholder': 'Paste the code or full callback URL',
|
||||
'oauth.submitCallback': 'Submit Callback',
|
||||
'oauth.callbackSubmitted': 'Callback submitted. Waiting for authentication.',
|
||||
'oauth.callbackFailed': 'Callback submission failed{detail}',
|
||||
'oauth.openBrowser': 'Open Browser',
|
||||
'oauth.status.completed': 'Completed',
|
||||
'oauth.status.failed': 'Failed',
|
||||
'oauth.status.waiting': 'Waiting for Callback',
|
||||
'oauth.status.signedOut': 'Not Signed In',
|
||||
'usage.title': 'Usage History',
|
||||
'usage.collector.collecting': 'Collecting Locally',
|
||||
'usage.collector.error': 'Collection Error',
|
||||
'usage.collector.waiting': 'Waiting for Core',
|
||||
'usage.longTermRecords': '{count} stored records',
|
||||
'usage.pageLabel': 'Usage history page',
|
||||
'usage.tab.overview': 'Overview',
|
||||
'usage.tab.analysis': 'Analysis',
|
||||
'usage.tab.events': 'Request Details',
|
||||
'usage.tab.pricing': 'Price Statistics',
|
||||
'usage.filter.timeRange': 'Time range',
|
||||
'usage.range.4h': 'Last 4 Hours',
|
||||
'usage.range.24h': 'Last 24 Hours',
|
||||
'usage.range.today': 'Today',
|
||||
'usage.range.7d': 'Last 7 Days',
|
||||
'usage.range.30d': 'Last 30 Days',
|
||||
'usage.range.all': 'All Time',
|
||||
'usage.range.custom': 'Custom',
|
||||
'usage.filter.model': 'Model',
|
||||
'usage.filter.allModels': 'All Models',
|
||||
'usage.filter.allProviders': 'All Providers',
|
||||
'usage.filter.source': 'Source',
|
||||
'usage.filter.allSources': 'All Sources',
|
||||
'usage.filter.allKeys': 'All Keys',
|
||||
'usage.filter.result': 'Request result',
|
||||
'usage.filter.allResults': 'All Results',
|
||||
'usage.result.success': 'Success',
|
||||
'usage.result.failed': 'Failed',
|
||||
'usage.filter.startTime': 'Start time',
|
||||
'usage.filter.endTime': 'End time',
|
||||
'usage.filter.to': 'to',
|
||||
'usage.loading': 'Loading local SQLite usage history',
|
||||
'usage.stat.requests': 'Total Requests',
|
||||
'usage.stat.requestMeta': 'OK {success} · Fail {failed}',
|
||||
'usage.stat.tokens': 'Total Tokens',
|
||||
'usage.stat.tokenMeta': 'In {input} · Out {output}',
|
||||
'usage.stat.successRate': 'Success Rate',
|
||||
'usage.stat.reasoningMeta': 'Reasoning {tokens}',
|
||||
'usage.stat.averageLatency': 'Average Latency',
|
||||
'usage.stat.tps': 'TPS',
|
||||
'usage.stat.performanceMeta': 'RPM {rpm} · {latency} ms',
|
||||
'usage.stat.cacheHitRate': 'Cache Hit Rate',
|
||||
'usage.stat.cacheHitMeta': 'Cache {hit}/{input}',
|
||||
'usage.stat.estimatedCost': 'Estimated Cost',
|
||||
'usage.stat.costMeta': 'Priced {priced}/{total} · Est.',
|
||||
'usage.pricing.coverage': '{priced} / {total} requests priced · {saved} model prices available',
|
||||
'usage.pricing.search': 'Search model',
|
||||
'usage.pricing.add': 'Add Manually',
|
||||
'usage.pricing.sync': 'Sync Prices',
|
||||
'usage.pricing.syncing': 'Syncing',
|
||||
'usage.pricing.syncResult': 'Prices updated: {imported} imported, {skipped} manual prices kept, {unmatched} unmatched',
|
||||
'usage.pricing.syncFallback': 'Remote prices could not be read; built-in prices remain active',
|
||||
'usage.pricing.model': 'Model',
|
||||
'usage.pricing.calls': 'Requests',
|
||||
'usage.pricing.cost': 'Estimated Cost',
|
||||
'usage.pricing.prompt': 'Input ($/1M)',
|
||||
'usage.pricing.completion': 'Output ($/1M)',
|
||||
'usage.pricing.cache': 'Legacy Cache ($/1M)',
|
||||
'usage.pricing.cacheRead': 'Cache Read ($/1M)',
|
||||
'usage.pricing.cacheCreation': 'Cache Creation ($/1M)',
|
||||
'usage.pricing.actions': 'Actions',
|
||||
'usage.pricing.optional': 'Optional; blank uses fallback',
|
||||
'usage.pricing.modelRequired': 'Enter a model name',
|
||||
'usage.pricing.saving': 'Saving',
|
||||
'usage.pricing.saved': 'Model price saved; overview cost updated',
|
||||
'usage.pricing.deleted': 'Model price deleted; overview cost updated',
|
||||
'usage.pricing.deleteConfirm': 'Delete the price for {model}?',
|
||||
'usage.trend.title': 'Request and Token Trend',
|
||||
'usage.trend.description': 'Grouped by local system hour',
|
||||
'usage.token.title': 'Token Breakdown',
|
||||
'usage.token.description': 'Cache, reasoning, and generation usage',
|
||||
'usage.token.input': 'Input',
|
||||
'usage.token.output': 'Output',
|
||||
'usage.token.reasoning': 'Reasoning',
|
||||
'usage.token.cacheRead': 'Cache Read',
|
||||
'usage.token.cacheCreation': 'Cache Creation',
|
||||
'usage.trend.aria': 'Request trend',
|
||||
'usage.analysis.models': 'Model Usage',
|
||||
'usage.analysis.sources': 'Request Sources',
|
||||
'usage.analysis.keys': 'Authentication Keys',
|
||||
'usage.analysis.hours': 'Hourly Distribution',
|
||||
'usage.analysis.sortedByTokens': 'Sorted by tokens',
|
||||
'usage.analysis.itemMeta': '{requests} requests · {percent}% · {tokens} tokens',
|
||||
'usage.events.total': '{count} records',
|
||||
'usage.events.page': 'Page {page} of {total}',
|
||||
'usage.column.time': 'Time',
|
||||
'usage.column.model': 'Model',
|
||||
'usage.column.source': 'Source',
|
||||
'usage.column.result': 'Result',
|
||||
'usage.column.latency': 'Total Duration',
|
||||
'usage.column.ttft': 'First Token Latency',
|
||||
'usage.column.input': 'Input',
|
||||
'usage.column.output': 'Output',
|
||||
'usage.column.reasoning': 'Reasoning',
|
||||
'usage.column.cache': 'Cache',
|
||||
'usage.column.total': 'Total',
|
||||
'usage.key.noRemark': 'No remark',
|
||||
'usage.previous': 'Previous',
|
||||
'usage.next': 'Next',
|
||||
'usage.empty': 'No usage records match the current filters',
|
||||
'aliases.effort.low': 'Light',
|
||||
'aliases.effort.medium': 'Balanced',
|
||||
'aliases.effort.high': 'Deep',
|
||||
'aliases.effort.xhigh': 'Extra High',
|
||||
'aliases.effort.max': 'Maximum',
|
||||
'aliases.source.openAiCompatible': 'OpenAI Compatible',
|
||||
'aliases.source.other': 'Other Source',
|
||||
'aliases.source.available': 'Models currently available from the core',
|
||||
'aliases.error.selectModel': 'Select an original model first',
|
||||
'aliases.error.emptyAlias': 'The model alias cannot be empty',
|
||||
'aliases.error.emptyEffort': 'The reasoning effort cannot be empty',
|
||||
'aliases.error.emptyOptions': 'Set a reasoning effort or enable Fast',
|
||||
'aliases.error.unsupportedEffort': 'This model does not support a reasoning-effort override; only Fast can be used',
|
||||
'aliases.error.unsupportedFast': 'Fast is not available for this model',
|
||||
'aliases.created': 'Created {alias} with reasoning effort fixed to {effort}',
|
||||
'aliases.createdCombined': 'Created {alias} with reasoning effort {effort} and Fast enabled',
|
||||
'aliases.deleteConfirm': 'Delete the model alias “{alias}”?\nIts reasoning-effort and Fast overrides will also be removed.',
|
||||
'aliases.deleted': 'Deleted {alias}',
|
||||
'aliases.create.title': 'Create Model Alias',
|
||||
'aliases.create.description': 'Reasoning effort and the Fast service tier can be configured independently or together',
|
||||
'aliases.originalModel': 'Original Model',
|
||||
'aliases.loadingModels': 'Loading models',
|
||||
'aliases.searchModel': 'Search and select an original model',
|
||||
'aliases.availableModels': 'Available original models',
|
||||
'aliases.noMatch': 'No matching models or sources',
|
||||
'aliases.noModels': 'No models are currently available',
|
||||
'aliases.sourceLabel': 'Source: {source}',
|
||||
'aliases.sourceHint': 'Models with the same name are listed separately for Codex OAuth, Codex API, and OpenAI-compatible sources',
|
||||
'aliases.effort.title': 'Reasoning Effort',
|
||||
'aliases.effort.description': 'Configure it independently or use it together with Fast',
|
||||
'aliases.effort.none': 'Not set',
|
||||
'aliases.effort.noneHint': 'Do not override reasoning effort; use another enabled option only',
|
||||
'aliases.effort.unsupported': 'This model has no available reasoning efforts, so only Fast has been enabled.',
|
||||
'aliases.fast.title': 'Speed Option',
|
||||
'aliases.fast.description': 'Always use the Fast request tier (service_tier=priority)',
|
||||
'aliases.fast.enabled': 'Enabled; works together with reasoning effort',
|
||||
'aliases.fast.disabled': 'Disabled',
|
||||
'aliases.customLevel': 'Custom level: {effort}',
|
||||
'aliases.customLevelHint': 'Enter another reasoning level supported by the model provider',
|
||||
'aliases.custom': 'Custom',
|
||||
'aliases.customPlaceholder': 'For example: minimal, auto, ultra, or a provider-specific level',
|
||||
'aliases.collapseCustom': 'Collapse custom input',
|
||||
'aliases.customHelp': 'Enter only a level name supported by the target model',
|
||||
'aliases.aliasName.title': 'Model Alias',
|
||||
'aliases.aliasName.description': 'Adds another entry point without renaming the original model',
|
||||
'aliases.aliasName.autoDescription': 'Generated from the model and selected options; edit it as needed without renaming the original',
|
||||
'aliases.aliasName.example': 'For example: {model}-{effort}',
|
||||
'aliases.aliasName.selectFirst': 'Select the original model, then enter the alias clients will use',
|
||||
'aliases.notSelected': 'No model selected',
|
||||
'aliases.enterAlias': 'Enter a model alias',
|
||||
'aliases.notSet': 'Not set',
|
||||
'aliases.creating': 'Creating',
|
||||
'aliases.create': 'Create Alias',
|
||||
'aliases.createdList.title': 'Created Aliases',
|
||||
'aliases.createdList.description': 'Each alias shows its reasoning effort and Fast status together',
|
||||
'aliases.loadingConfig': 'Loading configuration',
|
||||
'aliases.empty.title': 'No model aliases yet',
|
||||
'aliases.empty.description': 'Select a model and alias option on the left to create one',
|
||||
'aliases.unboundEffort': 'No effort bound',
|
||||
'aliases.delete': 'Delete {alias}',
|
||||
'speedAliases.created': 'Created {alias} with the Fast service tier fixed',
|
||||
'speedAliases.deleted': 'Deleted {alias}',
|
||||
'speedAliases.deleteConfirm': 'Delete the speed alias “{alias}”?\nIts Fast service-tier override will also be removed.',
|
||||
'speedAliases.fast.title': 'Fast',
|
||||
'speedAliases.aliasName.example': 'For example: {model}-fast',
|
||||
'speedAliases.delete': 'Delete {alias}',
|
||||
'config.routing.roundRobin': 'Round Robin',
|
||||
'config.routing.fillFirst': 'Fill First',
|
||||
'config.tabs.label': 'Advanced settings sections',
|
||||
'config.tabs.general': 'General Settings',
|
||||
'config.tabs.network': 'Network & Routing',
|
||||
'config.tabs.software': 'Software Settings',
|
||||
'config.error.emptyKey': 'The authentication key cannot be empty',
|
||||
'config.error.invalidKey': 'Use visible ASCII characters only, without spaces',
|
||||
'config.error.duplicateKey': 'This authentication key already exists',
|
||||
'config.error.remarkTooLong': 'The remark cannot exceed 80 characters',
|
||||
'config.error.portRange': 'The port must be an integer from 1 to 65535',
|
||||
'config.error.networkRestartFailed': 'Settings were saved, but the core failed to restart: {error}',
|
||||
'config.error.saveFailed': 'Failed to save: {error}',
|
||||
'config.notice.keyAdded': 'Authentication key added',
|
||||
'config.notice.keyUpdated': 'Authentication key updated',
|
||||
'config.notice.keyDeleted': 'Authentication key deleted',
|
||||
'config.notice.keyCopied': 'Authentication key copied',
|
||||
'config.notice.keyCopyFailed': 'Failed to copy the authentication key',
|
||||
'config.notice.routingUpdated': 'Routing strategy updated',
|
||||
'config.notice.networkUpdated': 'Network and session routing settings saved',
|
||||
'config.notice.networkRestarted': 'Network settings saved and the core restarted',
|
||||
'config.notice.networkNextStart': 'Network settings saved and will apply on the next core start',
|
||||
'config.notice.proxyUpdated': 'Proxy URL saved',
|
||||
'config.notice.sessionAffinityEnabled': 'Session affinity enabled',
|
||||
'config.notice.sessionAffinityDisabled': 'Session affinity disabled',
|
||||
'config.notice.sessionTtlUpdated': 'Session affinity TTL saved',
|
||||
'config.notice.softwareUpdated': 'Software settings saved',
|
||||
'config.software.title': 'Software Settings',
|
||||
'config.software.autostart': 'Launch at startup',
|
||||
'config.software.autostartDescription': 'Automatically launch EasyCLIProxyAPI when you sign in',
|
||||
'config.software.silentStart': 'Silent Launch',
|
||||
'config.software.silentStartDescription': 'Start without showing the main window and run directly in the system tray',
|
||||
'config.software.closeBehavior': 'Close behavior',
|
||||
'config.software.closeBehaviorDescription': 'Choose whether closing the window exits the app or keeps it running in the system tray',
|
||||
'config.software.behavior.ask': 'Ask every time',
|
||||
'config.software.behavior.minimize': 'Minimize to Tray',
|
||||
'config.software.behavior.exit': 'Exit App',
|
||||
'config.webuiKey.title': 'WebUI Key',
|
||||
'config.webuiKey.configured': 'Configured',
|
||||
'config.webuiKey.unconfigured': 'Not configured',
|
||||
'config.webuiKey.heading': 'Management access credential',
|
||||
'config.webuiKey.description': 'Used to sign in to the CPA WebUI and call the Management API. It is different from the API authentication keys above.',
|
||||
'config.webuiKey.securityHint': 'The current key is not displayed. If you forget it, simply reset the key.',
|
||||
'config.webuiKey.newKey': 'New WebUI key',
|
||||
'config.webuiKey.confirmKey': 'Confirm new key',
|
||||
'config.webuiKey.placeholder': 'Enter a new plaintext key',
|
||||
'config.webuiKey.confirmPlaceholder': 'Enter the new key again',
|
||||
'config.webuiKey.generate': 'Generate secure key',
|
||||
'config.webuiKey.save': 'Save key',
|
||||
'config.webuiKey.open': 'Open WebUI',
|
||||
'config.webuiKey.error.empty': 'The WebUI key cannot be empty',
|
||||
'config.webuiKey.error.legacyDefault': 'The legacy default key 123456 is no longer allowed',
|
||||
'config.webuiKey.error.tooLong': 'The WebUI key cannot exceed 512 characters',
|
||||
'config.webuiKey.error.invalid': 'The WebUI key cannot contain control characters',
|
||||
'config.webuiKey.error.mismatch': 'The WebUI keys do not match',
|
||||
'config.webuiKey.error.openFailed': 'Could not open the WebUI: {error}',
|
||||
'config.webuiKey.notice.updated': 'WebUI key updated',
|
||||
'config.keys.title': 'Authentication Keys',
|
||||
'config.keys.count': 'Key count',
|
||||
'config.keys.add': 'Add Authentication Key',
|
||||
'config.unavailable': 'Configuration Unavailable',
|
||||
'common.retry': 'Retry',
|
||||
'config.keys.noRemark': 'No remark',
|
||||
'config.keys.copy': 'Copy authentication key',
|
||||
'config.keys.copyNth': 'Copy authentication key {number}',
|
||||
'config.keys.edit': 'Edit authentication key',
|
||||
'config.keys.editNth': 'Edit authentication key {number}',
|
||||
'config.keys.delete': 'Delete authentication key',
|
||||
'config.keys.deleteNth': 'Delete authentication key {number}',
|
||||
'config.keys.empty': 'No authentication keys',
|
||||
'common.loading': 'Loading',
|
||||
'common.unavailable': 'Unavailable',
|
||||
'common.enabled': 'Enabled',
|
||||
'common.disabled': 'Disabled',
|
||||
'config.routing.title': 'Routing Strategy',
|
||||
'config.network.title': 'Network & Session Routing',
|
||||
'config.network.networkSection': 'Network Settings',
|
||||
'config.network.routingSection': 'Session Routing',
|
||||
'config.network.saving': 'Saving',
|
||||
'config.network.saved': 'Saved',
|
||||
'config.network.unsaved': 'Unsaved changes',
|
||||
'config.network.confirmSave': 'Confirm & Save',
|
||||
'config.network.port': 'Port',
|
||||
'config.network.portHint': 'Range 1–65535; changing it restarts a running core',
|
||||
'config.network.allowLan': 'Allow LAN',
|
||||
'config.network.allowLanHint': 'Allow other devices on the LAN to access this proxy',
|
||||
'config.network.proxyUrl': 'Proxy URL',
|
||||
'config.network.proxyPlaceholder': 'For example, socks5://127.0.0.1:7890',
|
||||
'config.network.proxyHint': 'Global proxy for the core and app external services; leave blank to disable',
|
||||
'config.network.sessionAffinity': 'Session Affinity',
|
||||
'config.network.sessionAffinityHint': 'Prefer the previously selected credential for the same session',
|
||||
'config.network.sessionTtl': 'Session Affinity TTL',
|
||||
'config.network.sessionTtlHint': 'For example, 1h; leave blank to use the core default',
|
||||
'config.keys.addTitle': 'Add Authentication Key',
|
||||
'config.keys.editTitle': 'Edit Authentication Key',
|
||||
'common.close': 'Close',
|
||||
'config.keys.label': 'Authentication Key',
|
||||
'config.keys.hide': 'Hide key',
|
||||
'config.keys.show': 'Show key',
|
||||
'config.keys.remark': 'Remark',
|
||||
'config.keys.remarkPlaceholder': 'For example: Development, Alice’s key',
|
||||
'config.keys.generate': 'Generate Key',
|
||||
'config.keys.adding': 'Adding',
|
||||
'common.add': 'Add',
|
||||
'config.keys.deleteTitle': 'Delete Authentication Key',
|
||||
'config.keys.deleteAllWarning': 'Deleting all keys will allow all keys by default',
|
||||
'common.cancel': 'Cancel',
|
||||
'common.deleting': 'Deleting',
|
||||
'common.delete': 'Delete',
|
||||
'authFiles.unknownProvider': 'Unknown Provider',
|
||||
'authFiles.status.disabled': 'Disabled',
|
||||
'authFiles.status.unavailable': 'Unavailable',
|
||||
'authFiles.status.ready': 'Ready',
|
||||
'authFiles.quota.loading': 'Querying quota',
|
||||
'authFiles.quota.failed': 'Failed to fetch quota',
|
||||
'authFiles.quota.aria': 'Quota information',
|
||||
'authFiles.quota.empty': 'No quota data',
|
||||
'authFiles.quota.resets': '{count} manual resets',
|
||||
'authFiles.quota.expiry': 'Earliest expiry {time}',
|
||||
'authFiles.models.noneForProvider': 'This provider has no configurable model definitions',
|
||||
'authFiles.models.updated': 'Open models updated for {provider}',
|
||||
'authFiles.priority.button': 'Priority {priority}',
|
||||
'authFiles.priority.title': 'Edit Credential Priority',
|
||||
'authFiles.priority.label': 'Priority',
|
||||
'authFiles.priority.placeholder': 'For example, 10 or -1',
|
||||
'authFiles.priority.hint': 'Integers only. Larger values have higher priority. Leave blank or enter 0 to restore the default.',
|
||||
'authFiles.priority.invalid': 'Enter a valid integer priority',
|
||||
'authFiles.priority.updated': 'Priority updated for {name}',
|
||||
'authFiles.uploaded': 'Uploaded {count} authentication files',
|
||||
'authFiles.uploadFailed': '{count} files failed to upload: {errors}',
|
||||
'authFiles.notice.enabled': 'Authentication file enabled',
|
||||
'authFiles.notice.disabled': 'Authentication file disabled',
|
||||
'authFiles.runtimeDeleteError': 'Runtime authentication files cannot be deleted from disk',
|
||||
'authFiles.deleteConfirm': 'Delete “{name}”?',
|
||||
'authFiles.deleted': 'Authentication file deleted',
|
||||
'common.copyFailed': 'Copy failed',
|
||||
'authFiles.title': 'Authentication Files',
|
||||
'authFiles.summary': '{files} files · {disabled} disabled',
|
||||
'common.refresh': 'Refresh',
|
||||
'common.upload': 'Upload',
|
||||
'authFiles.import': 'Import',
|
||||
'authFiles.searchPlaceholder': 'Search by file name, account, or provider',
|
||||
'authFiles.filter.allProviders': 'All Providers',
|
||||
'authFiles.filter.allStatuses': 'All Statuses',
|
||||
'authFiles.filter.enabled': 'Enabled',
|
||||
'authFiles.filter.disabled': 'Disabled',
|
||||
'authFiles.filter.runtime': 'Runtime Only',
|
||||
'authFiles.loading': 'Loading authentication files',
|
||||
'authFiles.empty.filtered': 'No matching authentication files',
|
||||
'authFiles.empty.none': 'No authentication files',
|
||||
'authFiles.empty.tryFilter': 'Try different filters',
|
||||
'authFiles.empty.upload': 'Uploaded JSON authentication files will appear here',
|
||||
'authFiles.unknownSize': 'Unknown size',
|
||||
'authFiles.runtime': 'Runtime',
|
||||
'authFiles.quota.querying': 'Querying',
|
||||
'authFiles.quota.refresh': 'Refresh Quota',
|
||||
'authFiles.quota.fetch': 'Fetch Quota',
|
||||
'authFiles.models.settings': 'Configure open models',
|
||||
'authFiles.models.button': 'Models',
|
||||
'authFiles.copyName': 'Copy file name',
|
||||
'authFiles.openDirectory': 'Open Folder',
|
||||
'common.enable': 'Enable',
|
||||
'common.disable': 'Disable',
|
||||
'authFiles.runtimeFootnote': '{count} credentials come from runtime storage and can only be managed through their provider or authentication source.',
|
||||
'authFiles.models.title': 'Open Models',
|
||||
'authFiles.models.description': '{provider} · All models are open by default; disable any you do not need',
|
||||
'authFiles.models.search': 'Search model names',
|
||||
'authFiles.models.summary': '{total} total · {open} open',
|
||||
'authFiles.models.closeVisible': 'Close Visible',
|
||||
'authFiles.models.openVisible': 'Open Visible',
|
||||
'authFiles.models.openAll': 'Open All',
|
||||
'authFiles.models.closeAll': 'Close All',
|
||||
'authFiles.models.loading': 'Loading model definitions',
|
||||
'authFiles.models.loadFailed': 'Failed to load models',
|
||||
'authFiles.models.noMatch': 'No matching models',
|
||||
'authFiles.models.empty': 'No model definitions',
|
||||
'common.saving': 'Saving',
|
||||
'authFiles.models.save': 'Save ({count} open)',
|
||||
'agents.description.claudeCode': 'Uses the CPA Anthropic-compatible API',
|
||||
'agents.description.claudeDesktop': 'Uses the CPA third-party inference gateway on Windows, macOS, and Linux Beta',
|
||||
'agents.description.codex': 'Uses the CPA Responses API',
|
||||
'agents.description.opencode': 'Uses the CPA OpenAI-compatible provider',
|
||||
'agents.description.openclaw': 'Uses the CPA model provider and default model',
|
||||
'agents.description.hermes': 'Uses the CPA custom provider',
|
||||
'agents.description.pi': 'Loads models dynamically in Pi through the CLIProxyAPI provider',
|
||||
'agents.list.detecting': 'Detecting',
|
||||
'agents.list.unsupported': 'Unsupported on this platform',
|
||||
'agents.list.notInstalled': 'Installation not detected',
|
||||
'agents.list.modified': 'Modified · {model}',
|
||||
'agents.list.installedVersion': 'Installed · {version}',
|
||||
'agents.list.installed': 'Installed · original configuration',
|
||||
'agents.list.pluginNotInstalled': 'Pi provider not installed',
|
||||
'agents.list.piInstalled': 'Pi plugin installed',
|
||||
'agents.list.configured': 'Configured · EasyCLIProxyAPI',
|
||||
'agents.model.loading': 'Loading models',
|
||||
'agents.model.loadFailed': 'Failed to load models',
|
||||
'agents.model.select': 'Select a model',
|
||||
'agents.model.none': 'Model list is empty',
|
||||
'agents.model.search': 'Search model names or aliases',
|
||||
'agents.model.clearSearch': 'Clear search',
|
||||
'agents.model.refresh': 'Refresh models',
|
||||
'agents.model.fetching': 'Fetching models',
|
||||
'agents.model.noMatch': 'No matching models',
|
||||
'agents.model.unavailable': 'Model list is empty',
|
||||
'agents.model.tryKeywords': 'Try different keywords',
|
||||
'agents.model.connectFirst': 'Connect an available model, then refresh',
|
||||
'agents.model.available': 'Available model',
|
||||
'agents.model.count': '{count} available models',
|
||||
'agents.model.stale': 'Refresh failed; showing the previous results',
|
||||
'agents.error.modelsLoading': 'The model list is still loading. Try again shortly.',
|
||||
'agents.error.noModels': 'No models are available, so configuration changes cannot be applied',
|
||||
'agents.error.selectionGone': 'The selected model is no longer available. Refresh and select it again.',
|
||||
'agents.error.mappingSelectionGone': 'A Claude Desktop mapping is no longer available. Refresh the models and select it again.',
|
||||
'agents.error.applyFirst': 'The selected model has not been applied. Click “Apply Configuration Changes” first.',
|
||||
'agents.status.detecting': 'Detecting',
|
||||
'agents.status.unsupported': 'Unsupported Platform',
|
||||
'agents.status.notInstalled': 'Not Installed',
|
||||
'agents.status.conflict': 'Configuration Conflict',
|
||||
'agents.status.modified': 'Configuration Modified',
|
||||
'agents.status.original': 'Original Configuration',
|
||||
'agents.status.invalid': 'Invalid Configuration',
|
||||
'agents.status.applied': 'Applied',
|
||||
'agents.status.unconfigured': 'Not Configured',
|
||||
'agents.title': 'Agent Configuration',
|
||||
'agents.redetect': 'Detect Again',
|
||||
'agents.tabs.label': 'Agent configuration sections',
|
||||
'agents.tabs.core': 'Core Configuration',
|
||||
'agents.tabs.sessions': 'Session Management',
|
||||
'agents.sessions.title': 'Codex Session Management',
|
||||
'agents.sessions.description': 'Scan local Codex sessions and safely restore historical sessions, back them up, or delete them.',
|
||||
'agents.sessions.refresh': 'Refresh',
|
||||
'agents.sessions.currentPage': 'Current page',
|
||||
'agents.sessions.active': 'Active',
|
||||
'agents.sessions.archived': 'Archived',
|
||||
'agents.sessions.database': 'Database source',
|
||||
'agents.sessions.databaseMissing': 'No session database found',
|
||||
'agents.sessions.repairTitle': 'Restore Historical Sessions',
|
||||
'agents.sessions.repairDescription': 'Sync sessions from other providers to the current session provider.',
|
||||
'agents.sessions.repairNow': 'Restore now',
|
||||
'agents.sessions.repairing': 'Restoring',
|
||||
'agents.sessions.loadFailed': 'Failed to load Codex sessions: {error}',
|
||||
'agents.sessions.localSessions': 'Local sessions',
|
||||
'agents.sessions.pageDescription': 'Page {page} · up to {size} items per page',
|
||||
'agents.sessions.selected': '{count} selected',
|
||||
'agents.sessions.selectAll': 'Select this page',
|
||||
'agents.sessions.clearSelection': 'Clear selection',
|
||||
'agents.sessions.deleteSelected': 'Delete selected',
|
||||
'agents.sessions.multiSelect': 'Select multiple',
|
||||
'agents.sessions.loading': 'Scanning local sessions…',
|
||||
'agents.sessions.empty': 'No Codex sessions were found.',
|
||||
'agents.sessions.selectSession': 'Select session {title}',
|
||||
'agents.sessions.untitled': 'Untitled session',
|
||||
'agents.sessions.noProject': 'No project path',
|
||||
'agents.sessions.noProvider': 'Unknown provider',
|
||||
'agents.sessions.noTime': 'Unknown update time',
|
||||
'agents.sessions.activeBadge': 'Active',
|
||||
'agents.sessions.archivedBadge': 'Archived',
|
||||
'agents.sessions.previousPage': 'Previous page',
|
||||
'agents.sessions.nextPage': 'Next page',
|
||||
'agents.sessions.pageNumber': 'Page {page}',
|
||||
'agents.sessions.deleteOneTitle': 'Delete this session?',
|
||||
'agents.sessions.deleteManyTitle': 'Delete {count} sessions?',
|
||||
'agents.sessions.deleteOneDescription': 'A backup will be created before “{title}” and its known related records are deleted. There is no in-app undo.',
|
||||
'agents.sessions.deleteManyDescription': 'A backup will be created before these {count} sessions and their known related records are deleted: {preview}{extra}',
|
||||
'agents.sessions.deleteMore': ', plus {count} more',
|
||||
'agents.sessions.confirmDelete': 'Back up and delete',
|
||||
'agents.sessions.deleteResult': 'Deleted {deleted}; failed {failed}. {details}{backup}',
|
||||
'agents.sessions.deleteFailed': 'Failed to delete sessions: {error}',
|
||||
'agents.sessions.backupAt': 'Backup: {path}',
|
||||
'agents.sessions.repairResult': 'Historical sessions restored to {provider}: migrated {files} rollout files, updated {rows} database rows, and skipped {skipped} locked files.',
|
||||
'agents.sessions.repairFailed': 'Failed to restore historical sessions: {error}',
|
||||
'agents.sessions.progressCount': '{processed}/{total}',
|
||||
'agents.sessions.progress.scanning': 'Scanning sessions',
|
||||
'agents.sessions.progress.backingUp': 'Creating backup',
|
||||
'agents.sessions.progress.rewriting': 'Migrating historical sessions',
|
||||
'agents.sessions.progress.updatingDatabase': 'Synchronizing databases',
|
||||
'agents.sessions.progress.complete': 'Restoration complete',
|
||||
'agents.sessions.scanIndex': 'Check invalid session records',
|
||||
'agents.sessions.scanningIndex': 'Checking invalid records',
|
||||
'agents.sessions.cleanupNoCandidates': 'No safely removable invalid session records were found.',
|
||||
'agents.sessions.cleanupPreviewFailed': 'Failed to check invalid session records: {error}',
|
||||
'agents.sessions.cleanupTitle': 'Removable invalid index entries',
|
||||
'agents.sessions.cleanupDescription': 'Found {count} records that exist only in session_index.jsonl and no longer have a real session source. Nothing is selected by default. Exit Codex and ChatGPT, then confirm each item to remove. Unknown record formats are preserved.',
|
||||
'agents.sessions.cleanupSelectAll': 'Select all candidates',
|
||||
'agents.sessions.cleanupConfirm': 'Remove {count} selected',
|
||||
'agents.sessions.cleanupResult': 'Removed {count} invalid session index entries. {backup}',
|
||||
'agents.sessions.cleanupFailed': 'Failed to remove invalid session index entries: {error}',
|
||||
'agents.localClients': 'Local Clients',
|
||||
'agents.selectClient': 'Select an agent to manage',
|
||||
'agents.installStatus': 'Installation Status',
|
||||
'agents.clientDetected': 'Client detected',
|
||||
'agents.clientNotDetected': 'Client not detected',
|
||||
'agents.clientVersion': 'Client Version',
|
||||
'agents.cliVersion': 'CLI Version',
|
||||
'agents.appVersion': 'App Version',
|
||||
'agents.pluginVersion': 'Plugin Version',
|
||||
'agents.notFetched': 'Not available',
|
||||
'agents.useModel': 'Model',
|
||||
'agents.pending': 'Not Applied',
|
||||
'agents.claudeDesktopMapping.title': 'Claude Desktop Model → Upstream Model',
|
||||
'agents.claudeDesktopMapping.description': 'Choose the upstream model each Claude Desktop model actually calls.',
|
||||
'agents.claudeCodeMapping.title': 'Claude Code Model → Upstream Model',
|
||||
'agents.claudeCodeMapping.description': 'Choose the upstream model used for each task role.',
|
||||
'agents.claudeDesktopMapping.customMapping': 'Custom Mapping',
|
||||
'agents.claudeDesktopMapping.customMappingHint': 'When enabled, only aliased models are shown; when disabled, only original models without aliases are shown.',
|
||||
'agents.claudeDesktopMapping.opus': 'Map Claude Opus 5 to',
|
||||
'agents.claudeDesktopMapping.sonnet': 'Map Claude Sonnet 4.6 to',
|
||||
'agents.claudeDesktopMapping.haiku': 'Map Claude Haiku 4.5 to',
|
||||
'agents.model.readingAvailable': 'Loading available models',
|
||||
'agents.model.current': 'Currently applied: {model}',
|
||||
'agents.model.firstSelection': '{count} models available; the first is selected by default',
|
||||
'agents.modify.title': 'Configuration Changes',
|
||||
'agents.modify.oauthConfiguration': 'OAuth sign-in configuration mode',
|
||||
'agents.oauthLoginRequired.title': 'Codex sign-in required',
|
||||
'agents.oauthLoginRequired.enableDescription': 'No valid Codex sign-in credentials were found, so OAuth configuration mode cannot be enabled. Sign in to your ChatGPT account in the Codex app. If the Codex app cannot trigger sign-in, use',
|
||||
'agents.oauthLoginRequired.enableClearConfiguration': 'Clear Configuration',
|
||||
'agents.oauthLoginRequired.enableDescriptionSuffix': 'first.',
|
||||
'agents.oauthLoginRequired.applyDescription': 'OAuth configuration cannot be applied because no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex, then apply the configuration.',
|
||||
'agents.oauthLoginRequired.confirm': 'Got it',
|
||||
'agents.modify.apply': 'Apply Configuration Changes',
|
||||
'agents.modify.update': 'Update Configuration',
|
||||
'agents.modify.close': 'Close Configuration Changes',
|
||||
'agents.modify.default': 'Default Configuration',
|
||||
'agents.modify.clear': 'Clear Configuration',
|
||||
'agents.modify.invalid': 'The current configuration cannot be parsed. Apply Configuration Changes rebuilds a minimal valid configuration; Default Configuration resets the whole file.',
|
||||
'agents.pi.installTitle': 'Pi CLIProxyAPI provider',
|
||||
'agents.pi.installDescription': 'Install or update the plugin and repair CPA credentials, the default provider, and the default model.',
|
||||
'agents.pi.install': 'Install Pi Plugin',
|
||||
'agents.pi.update': 'Update Pi Plugin',
|
||||
'agents.pi.updateAvailable': 'Version {version} is available',
|
||||
'agents.pi.updating': 'Updating plugin',
|
||||
'agents.pi.installing': 'Installing plugin',
|
||||
'agents.pi.repair': 'Apply Configuration',
|
||||
'agents.pi.repairing': 'Applying configuration',
|
||||
'agents.pi.uninstall': 'Uninstall Pi Plugin',
|
||||
'agents.pi.uninstalling': 'Uninstalling plugin',
|
||||
'agents.default.title': 'Reset to Default Configuration',
|
||||
'agents.default.description': 'This rewrites every related {name} configuration file using the selected model and deletes all other user content in those files. No backup is created and the operation cannot be undone.',
|
||||
'agents.default.confirm': 'Write Default Configuration',
|
||||
'agents.clear.title': 'Clear Codex Configuration',
|
||||
'agents.clear.description': 'This permanently deletes auth.json and config.toml from the Codex configuration directory and resets the application state tracked by this app. No backup is created and the operation cannot be undone.',
|
||||
'agents.clear.confirm': 'Clear Configuration',
|
||||
'agents.clear.success': 'The Codex configuration has been cleared.',
|
||||
'apiAccess.compatibleName': 'OpenAI Compatible {number}',
|
||||
'apiAccess.error.headerMissingColon': 'Custom header line {number} is missing a colon',
|
||||
'apiAccess.error.headerInvalid': 'Custom header line {number} is invalid',
|
||||
'apiAccess.error.partialLoad': 'Some connections could not be loaded: {errors}',
|
||||
'apiAccess.error.requiredAll': 'Remark, Base URL, and key are required',
|
||||
'apiAccess.error.requiredBaseKey': 'Base URL and API key are required',
|
||||
'apiAccess.error.requiredKey': 'API key is required',
|
||||
'apiAccess.error.baseRequired': 'A Base URL is required for {provider}',
|
||||
'apiAccess.error.noModels': 'No models were discovered. Check the Base URL and API key.',
|
||||
'apiAccess.error.stale': 'The connection configuration changed elsewhere. Refresh and try again.',
|
||||
'apiAccess.error.duplicate': 'An identical connection already exists',
|
||||
'apiAccess.error.remarkInvalid': 'The remark cannot exceed 80 characters or contain line breaks or control characters',
|
||||
'apiAccess.notice.updated': 'Connection updated',
|
||||
'apiAccess.notice.added': 'Connection added',
|
||||
'apiAccess.deleteConfirm': 'Delete “{remark}”?',
|
||||
'apiAccess.notice.deleted': 'Connection deleted',
|
||||
'apiAccess.notice.enabled': 'Connection enabled',
|
||||
'apiAccess.notice.disabled': 'Connection disabled',
|
||||
'apiAccess.title': 'API Access',
|
||||
'apiAccess.count': '{count} connections',
|
||||
'apiAccess.add': 'Add',
|
||||
'apiAccess.matches': '{count} matching connections',
|
||||
'apiAccess.search': 'Search remarks, keys, or addresses',
|
||||
'apiAccess.loading': 'Loading configuration',
|
||||
'apiAccess.empty.filtered': 'No matching connections',
|
||||
'apiAccess.empty.none': 'No API connections',
|
||||
'apiAccess.empty.tryKeyword': 'Try a different keyword',
|
||||
'apiAccess.empty.addFirst': 'Click “Add” in the upper-right to create your first connection',
|
||||
'apiAccess.keys.count': '{count} keys',
|
||||
'apiAccess.keys.summary': '{key} · {count} keys total',
|
||||
'apiAccess.remarkValue': 'Remark: {remark}',
|
||||
'apiAccess.defaultUrl': 'Using default address',
|
||||
'apiAccess.models.summary': '{count} models · {models}',
|
||||
'apiAccess.priorityValue': 'Priority {priority}',
|
||||
'apiAccess.enable': 'Enable connection',
|
||||
'apiAccess.disable': 'Disable connection',
|
||||
'apiAccess.status.disabled': 'Disabled',
|
||||
'apiAccess.status.enabled': 'Enabled',
|
||||
'apiAccess.toggleAria': '{remark} connection {action} switch',
|
||||
'apiAccess.health.action': 'Health Check',
|
||||
'apiAccess.health.title': 'Model Health Check',
|
||||
'apiAccess.health.description': '{provider}',
|
||||
'apiAccess.health.warning': 'The check sends one minimal streaming request to each model, stops at the first output token, and may incur a small charge.',
|
||||
'apiAccess.health.search': 'Search model names or aliases',
|
||||
'apiAccess.health.loadingModels': 'Loading the model list…',
|
||||
'apiAccess.health.modelLoadFailed': 'Could not refresh the model list: {error}',
|
||||
'apiAccess.health.noMatch': 'No matching models',
|
||||
'apiAccess.health.notChecked': 'Not checked',
|
||||
'apiAccess.health.checkOne': 'Check',
|
||||
'apiAccess.health.retryOne': 'Check Again',
|
||||
'apiAccess.health.checkAll': 'Check All Models',
|
||||
'apiAccess.health.checkingProgress': 'Checking {checked}/{total}',
|
||||
'apiAccess.health.summary': 'Checked {checked}/{total} · Healthy {healthy} · Failed {failed}',
|
||||
'apiAccess.health.checking': 'Checking…',
|
||||
'apiAccess.health.healthy': 'Healthy',
|
||||
'apiAccess.health.failed': 'Failed',
|
||||
'apiAccess.health.noModel': 'No model is available for a health check',
|
||||
'apiAccess.health.timeout': 'The request timed out',
|
||||
'apiAccess.health.missingDirectKey': 'A directly usable plaintext API key is required to measure first-token latency',
|
||||
'apiAccess.health.firstTokenInline': 'TTFT {latency} ms',
|
||||
'apiAccess.health.firstTokenLatencyResult': 'Time to first token: {latency} ms',
|
||||
'common.edit': 'Edit',
|
||||
'apiAccess.error.enterBaseUrl': 'Enter a Base URL first',
|
||||
'apiAccess.error.noAvailableModels': 'No available models found',
|
||||
'apiAccess.error.baseBeforeModels': 'Enter a Base URL before fetching models',
|
||||
'apiAccess.models.selected': '{count} models selected',
|
||||
'apiAccess.models.restricted': 'Model restrictions configured',
|
||||
'apiAccess.models.autoAll': 'All models will be opened by default when saved',
|
||||
'apiAccess.models.upstreamDefault': 'Using upstream default models',
|
||||
'apiAccess.models.hiddenHint': 'Unchecked models will be hidden from the available model list',
|
||||
'apiAccess.models.autoHint': 'Models are fetched automatically when saving; you can deselect unneeded items in advance',
|
||||
'apiAccess.models.allHint': 'All upstream models are currently open',
|
||||
'apiAccess.dialog.edit': 'Edit API Connection',
|
||||
'apiAccess.dialog.add': 'Add API Connection',
|
||||
'apiAccess.field.keysMany': 'API Keys (one per line)',
|
||||
'apiAccess.field.key': 'API Key',
|
||||
'apiAccess.field.remark': 'Remark',
|
||||
'apiAccess.remarkPlaceholder': 'For example, Production or Team primary account',
|
||||
'apiAccess.baseRequiredPlaceholder': 'Required, for example https://api.example.com',
|
||||
'apiAccess.baseOptionalPlaceholder': 'Optional; leave blank to use the default address',
|
||||
'apiAccess.preset.title': 'OpenAI-Compatible Preset',
|
||||
'apiAccess.preset.description': 'The official DeepSeek address is prefilled; all discovered models are opened when saved',
|
||||
'apiAccess.thinking.builtIn': 'Built-in Reasoning Levels',
|
||||
'apiAccess.thinking.builtInDescription': 'Applied automatically to all currently open models',
|
||||
'apiAccess.thinking.title': 'Reasoning Levels',
|
||||
'apiAccess.thinking.description': 'Add levels supported by the upstream provider',
|
||||
'apiAccess.thinking.placeholder': 'For example: low, medium, or a custom level',
|
||||
'apiAccess.thinking.add': 'Add',
|
||||
'apiAccess.thinking.delete': 'Delete {level}',
|
||||
'apiAccess.thinking.deleteAria': 'Delete reasoning level {level}',
|
||||
'apiAccess.thinking.empty': 'thinking.levels is omitted when no levels are added',
|
||||
'apiAccess.models.title': 'Models',
|
||||
'apiAccess.models.description': 'Checked models are open; uncheck a model to hide it from the list',
|
||||
'apiAccess.models.fetch': 'Fetch Models',
|
||||
'apiAccess.field.priority': 'Priority',
|
||||
'common.optional': 'Optional',
|
||||
'apiAccess.advanced': 'Advanced Settings',
|
||||
'apiAccess.field.prefix': 'Model Prefix',
|
||||
'apiAccess.prefixPlaceholder': 'Optional, for example team-a',
|
||||
'apiAccess.field.headers': 'Custom Headers (one Name: Value per line)',
|
||||
'apiAccess.field.excludedModels': 'Excluded Models (one per line; wildcards supported)',
|
||||
'apiAccess.field.testModel': 'Test Model',
|
||||
'apiAccess.cloak.mode': 'Claude Cloaking Mode',
|
||||
'apiAccess.cloak.default': 'Default (auto)',
|
||||
'apiAccess.cloak.words': 'Cloaking Keywords (one per line)',
|
||||
'apiAccess.cloak.strict': 'Strict Mode',
|
||||
'apiAccess.cloak.strictDescription': 'Keep only the Claude Code system prompt',
|
||||
'apiAccess.cloak.enableStrict': 'Enable strict mode',
|
||||
'apiAccess.cloak.cacheUser': 'Cache User ID',
|
||||
'apiAccess.cloak.cacheUserDescription': 'Reuse the cloaked user identifier',
|
||||
'apiAccess.websocket.description': 'Enable WebSocket for this Codex connection',
|
||||
'apiAccess.websocket.enable': 'Enable WebSocket',
|
||||
'apiAccess.cooling.title': 'Disable Cooldown',
|
||||
'apiAccess.cooling.description': 'Allow this connection to remain selectable after upstream rate limiting',
|
||||
'common.save': 'Save',
|
||||
'apiAccess.modelDialog.title': 'Select Models',
|
||||
'apiAccess.modelDialog.summary': '{found} found · {selected} selected',
|
||||
'apiAccess.modelDialog.deselectAll': 'Deselect All',
|
||||
'apiAccess.modelDialog.selectVisible': 'Select Visible',
|
||||
'common.clear': 'Clear',
|
||||
'apiAccess.modelDialog.fetching': 'Fetching models',
|
||||
'apiAccess.modelDialog.fetchFailed': 'Failed to fetch models',
|
||||
'apiAccess.modelDialog.noMatch': 'No matching models',
|
||||
'apiAccess.modelDialog.none': 'No models found',
|
||||
'apiAccess.modelDialog.tryKeyword': 'Try a different keyword',
|
||||
'apiAccess.modelDialog.checkCredentials': 'Check the Base URL and API key',
|
||||
'apiAccess.modelDialog.apply': 'Apply Selection ({count})',
|
||||
'kernel.install.completed': '{version} installed',
|
||||
'kernel.error.progressListener': 'Failed to listen for download progress: {error}',
|
||||
'kernel.action.start': 'start',
|
||||
'kernel.action.stop': 'stop',
|
||||
'kernel.action.restart': 'restart',
|
||||
'kernel.notice.actionSuccess': 'Core {action} succeeded',
|
||||
'kernel.notice.actionFailed': 'Core {action} failed: {error}',
|
||||
'kernel.notice.networkRestarted': 'Network settings saved and the core restarted',
|
||||
'kernel.notice.networkRestartFailed': 'Network settings saved, but the core failed to restart',
|
||||
'kernel.notice.networkNextStart': 'Network settings saved and will take effect the next time the core starts',
|
||||
'kernel.notice.networkSaveFailed': 'Failed to save network settings: {error}',
|
||||
'kernel.error.port': 'The port must be between 1 and 65535',
|
||||
'kernel.error.installTask': 'Failed to read the installation task: {error}',
|
||||
'kernel.install.installingVersion': 'Installing {version}',
|
||||
'kernel.phase.preparingDownload': 'Preparing Download',
|
||||
'kernel.phase.downloading': 'Downloading',
|
||||
'kernel.phase.extracting': 'Extracting',
|
||||
'kernel.phase.completed': 'Installation Complete',
|
||||
'kernel.install.installingBundled': 'Installing bundled core {version}',
|
||||
'kernel.phase.preparingBundled': 'Preparing Bundled Core',
|
||||
'kernel.install.bundledCompleted': 'Bundled core {version} installed',
|
||||
'kernel.phase.failed': 'Installation Failed',
|
||||
'kernel.phase.cancelled': 'Canceled',
|
||||
'kernel.install.cancelling': 'Canceling download',
|
||||
'kernel.notice.copyFailed': 'Failed to copy access information',
|
||||
'kernel.error.openUpdate': 'Failed to open the update page: {error}',
|
||||
'kernel.progress.completed': 'Completed',
|
||||
'kernel.progress.extracting': 'Extracting files',
|
||||
'kernel.progress.waiting': 'Waiting for progress information',
|
||||
'kernel.status.running': 'Running',
|
||||
'kernel.status.stopped': 'Stopped',
|
||||
'kernel.status.notInstalled': 'Not Installed',
|
||||
'common.detectionFailed': 'Detection Failed',
|
||||
'common.detecting': 'Detecting',
|
||||
'kernel.update.failedRetry': 'Check failed; retry',
|
||||
'kernel.update.toVersion': 'Update to {version}',
|
||||
'kernel.update.checking': 'Checking',
|
||||
'kernel.update.failed': 'Check Failed',
|
||||
'kernel.update.notChecked': 'Not Checked',
|
||||
'kernel.update.statusChecking': 'Checking',
|
||||
'kernel.update.notYetChecked': 'Not Checked Yet',
|
||||
'kernel.update.latest': 'Up to Date',
|
||||
'kernel.update.available': 'New Version Available',
|
||||
'appUpdate.eyebrow': 'App Update',
|
||||
'appUpdate.title': 'Application Version',
|
||||
'appUpdate.current': 'Current Version',
|
||||
'appUpdate.latest': 'Latest Version',
|
||||
'appUpdate.status': 'Update Status',
|
||||
'appUpdate.manualFallback': 'If detection fails, update manually from the GitHub page',
|
||||
'appUpdate.upToDate': 'Up to Date',
|
||||
'appUpdate.available': 'Version {version} is available',
|
||||
'appUpdate.check': 'Check for Updates',
|
||||
'appUpdate.checking': 'Checking',
|
||||
'appUpdate.catalog.label': 'Codex Model Catalog',
|
||||
'appUpdate.catalog.description': 'Silently syncs from GitHub when the app starts',
|
||||
'appUpdate.catalog.update': 'Sync Model Catalog',
|
||||
'appUpdate.catalog.updateHint': 'Check GitHub and update the Codex model catalog',
|
||||
'appUpdate.catalog.updating': 'Syncing Model Catalog',
|
||||
'appUpdate.catalog.updated': 'Model catalog synced from GitHub',
|
||||
'appUpdate.catalog.unchanged': 'Model catalog is already up to date',
|
||||
'appUpdate.installNow': 'Update Now',
|
||||
'appUpdate.openRelease': 'Open Download Page',
|
||||
'appUpdate.confirmTitle': 'Install Application Update',
|
||||
'appUpdate.confirmDescription': 'Version {version} will be downloaded and installed. The app will restart, and a managed core will briefly stop and resume.',
|
||||
'appUpdate.progressTitle': 'Updating Application',
|
||||
'appUpdate.cancelDownload': 'Cancel Download',
|
||||
'appUpdate.badgeAvailable': 'Application version {version} is available',
|
||||
'appUpdate.phase.idle': 'Waiting',
|
||||
'appUpdate.phase.checking': 'Checking',
|
||||
'appUpdate.phase.available': 'Update Available',
|
||||
'appUpdate.phase.downloading': 'Downloading',
|
||||
'appUpdate.phase.verifying': 'Verifying',
|
||||
'appUpdate.phase.staging': 'Preparing Update',
|
||||
'appUpdate.phase.waitingForExit': 'Waiting for App to Exit',
|
||||
'appUpdate.phase.restarting': 'Restarting',
|
||||
'appUpdate.phase.completed': 'Update Complete',
|
||||
'appUpdate.phase.cancelled': 'Download Canceled',
|
||||
'appUpdate.phase.failed': 'Update Failed',
|
||||
'kernel.install.inProgress': 'Installing',
|
||||
'kernel.install.githubFailed': 'Cannot connect to GitHub. Use Offline Install.',
|
||||
'kernel.install.titleCancelling': 'Canceling Download',
|
||||
'kernel.install.titleInstalling': 'Installing Core',
|
||||
'kernel.install.titleCompleted': 'Installation Complete',
|
||||
'kernel.install.titleCancelled': 'Download Canceled',
|
||||
'kernel.install.titleFailed': 'Installation Failed',
|
||||
'kernel.install.waitingStop': 'Waiting for the download task to stop',
|
||||
'kernel.install.taskRunning': 'Installation task in progress',
|
||||
'kernel.install.cancellingShort': 'Canceling',
|
||||
'kernel.install.cancel': 'Cancel Download',
|
||||
'common.processing': 'Processing',
|
||||
'kernel.overview.coreStatus': 'Core Status',
|
||||
'kernel.overview.appVersion': 'App Version',
|
||||
'kernel.update.openGithub': 'Open the latest release on GitHub',
|
||||
'kernel.overview.coreVersion': 'Core Version',
|
||||
'kernel.control.title': 'Runtime Control',
|
||||
'kernel.control.installStatus': 'Installation Status',
|
||||
'kernel.control.installed': 'Installed',
|
||||
'kernel.control.runStatus': 'Runtime Status',
|
||||
'kernel.control.pid': 'Process PID',
|
||||
'kernel.control.notRunning': 'Not Running',
|
||||
'kernel.control.noPid': 'None',
|
||||
'kernel.control.allowLan': 'Allow LAN',
|
||||
'kernel.control.lanRestartHint': 'Changing this automatically restarts a running core',
|
||||
'kernel.control.allowLanAria': 'Allow LAN access',
|
||||
'kernel.control.port': 'Port',
|
||||
'kernel.control.portPlaceholder': 'Port number',
|
||||
'kernel.control.portRange': 'Port range: 1–65535',
|
||||
'kernel.notice.stopped': 'Core stopped',
|
||||
'kernel.notice.started': 'Core started',
|
||||
'kernel.notice.restarted': 'Core restarted',
|
||||
'kernel.control.refresh': 'Refresh Status',
|
||||
'kernel.versions.title': 'Core Version Management',
|
||||
'kernel.versions.offlineHint': 'If GitHub is unavailable, use Offline Install to install the bundled core.',
|
||||
'kernel.versions.current': 'Current Version',
|
||||
'kernel.versions.latest': 'Latest Version',
|
||||
'kernel.versions.bundled': 'Bundled Version',
|
||||
'kernel.versions.notIncluded': 'Not Included',
|
||||
'kernel.versions.platform': 'Platform',
|
||||
'kernel.versions.updateStatus': 'Update Status',
|
||||
'kernel.versions.check': 'Check for Updates',
|
||||
'kernel.versions.installVersion': 'Install {version}',
|
||||
'kernel.versions.installLatest': 'Install Latest',
|
||||
'kernel.versions.reinstallTitle': 'Reinstall the current version',
|
||||
'kernel.versions.reinstall': 'Reinstall',
|
||||
'kernel.versions.noBundled': 'This release does not include a bundled core',
|
||||
'kernel.versions.offlineInstall': 'Offline Install',
|
||||
'kernel.access.firstKey': 'First key',
|
||||
'kernel.access.noConfiguredKey': 'No key is configured; all keys are currently allowed by default',
|
||||
'kernel.access.connectable': 'Connectable',
|
||||
'kernel.access.waiting': 'Waiting for core startup',
|
||||
'kernel.access.localUrl': 'Local URL',
|
||||
'kernel.access.localCopied': '{name} local URL copied',
|
||||
'kernel.access.copyLocal': 'Copy {name} local URL',
|
||||
'kernel.access.lanUrl': 'LAN URL',
|
||||
'kernel.access.detectingIp': 'Detecting LAN IP',
|
||||
'kernel.access.noIp': 'No LAN IP detected',
|
||||
'kernel.access.lanCopied': '{name} LAN URL copied',
|
||||
'kernel.access.copyLan': 'Copy {name} LAN URL',
|
||||
'kernel.dialog.install': 'Core Installation',
|
||||
'kernel.dialog.phase': 'Current Phase',
|
||||
'kernel.dialog.unknownProgress': 'Progress Unknown',
|
||||
'kernel.access.openaiDescription': 'OpenAI-compatible format',
|
||||
'kernel.access.claudeDescription': 'Anthropic-compatible format',
|
||||
'kernel.access.geminiDescription': 'Gemini-compatible format',
|
||||
'authFiles.unnamed': 'Unnamed credential file',
|
||||
'management.notConfigured': 'Not configured',
|
||||
'management.error.upstream': 'Upstream request failed',
|
||||
'management.error.upstreamHttp': 'Upstream returned HTTP {status}',
|
||||
'model.error.invalidBaseUrl': 'Base URL is invalid. Enter a URL such as https://api.example.com',
|
||||
'model.error.unsupportedBaseUrl': 'Base URL must use http:// or https://',
|
||||
'model.error.baseUrlRequired': 'Enter a Base URL first',
|
||||
'model.error.noResponse': 'The models endpoint did not respond',
|
||||
'quota.service.relative.daysHours': 'in {days}d {hours}h',
|
||||
'quota.service.relative.days': 'in {days}d',
|
||||
'quota.service.relative.hoursMinutes': 'in {hours}h {minutes}m',
|
||||
'quota.service.relative.hours': 'in {hours}h',
|
||||
'quota.service.relative.minutes': 'in {minutes}m',
|
||||
'quota.service.duration.unknown': 'Unknown window',
|
||||
'quota.service.duration.days': '{count} days',
|
||||
'quota.service.duration.hours': '{count} hours',
|
||||
'quota.service.duration.minutes': '{count} minutes',
|
||||
'quota.service.duration.seconds': '{count} seconds',
|
||||
'quota.service.limit.fiveHours': '5-hour limit',
|
||||
'quota.service.limit.week': 'Weekly limit',
|
||||
'quota.service.limit.month': 'Monthly limit',
|
||||
'quota.service.limit.duration': '{duration} limit',
|
||||
'quota.service.codeReview': 'Code review',
|
||||
'quota.service.additional': 'Additional {index}',
|
||||
'quota.service.window.fiveHour': '5-hour window',
|
||||
'quota.service.window.sevenDay': '7-day window',
|
||||
'quota.service.window.sevenDayOAuth': '7-day OAuth apps window',
|
||||
'quota.service.window.sevenDayOpus': '7-day Opus window',
|
||||
'quota.service.window.sevenDaySonnet': '7-day Sonnet window',
|
||||
'quota.service.window.sevenDayCowork': '7-day Cowork window',
|
||||
'quota.service.window.duration': '{duration} window',
|
||||
'quota.service.extraUsage': 'Extra usage',
|
||||
'quota.service.usedOf': 'Used {used} / {limit}',
|
||||
'quota.service.weekly': 'Weekly quota',
|
||||
'quota.service.limit.numbered': 'Limit {index}',
|
||||
'quota.service.product.numbered': 'Product {index}',
|
||||
'quota.service.monthlyIncluded': 'Monthly included quota',
|
||||
'quota.service.onDemand': 'On-demand quota',
|
||||
'quota.service.quota': 'Quota',
|
||||
'quota.service.quota.numbered': 'Quota {index}',
|
||||
'quota.service.error.missingAuthIndex': 'Missing auth-index; quota cannot be queried',
|
||||
'quota.service.error.missingProject': 'Missing Antigravity project ID',
|
||||
'quota.service.error.antigravityEmpty': 'Antigravity succeeded but returned no recognizable quota groups',
|
||||
'quota.service.error.upstreamEmpty': 'Upstream returned empty quota data',
|
||||
'quota.service.error.noResponse': 'The quota endpoint did not respond',
|
||||
'quota.service.error.missingResetAuthIndex': 'Missing auth-index; reset credits cannot be queried',
|
||||
'quota.service.error.unsupportedProvider': 'Quota queries are not supported for this provider',
|
||||
'quota.service.error.unrecognized': 'Upstream returned quota data in an unrecognized format',
|
||||
'quota.service.error.codexResetOnly': 'Only Codex credentials support reset credits',
|
||||
'quota.service.error.missingConsumeAuthIndex': 'Missing auth-index; the reset credit cannot be used',
|
||||
};
|
||||
@@ -0,0 +1,930 @@
|
||||
export const zhCN = {
|
||||
'apiAccess.models.add': '添加自定义模型',
|
||||
'apiAccess.models.namePlaceholder': '模型名称,例如 gpt-4o',
|
||||
'apiAccess.models.aliasPlaceholder': '模型别名(可选)',
|
||||
'apiAccess.models.remove': '删除模型',
|
||||
'app.language': '语言',
|
||||
'app.theme.light': '日间模式',
|
||||
'app.theme.dark': '夜间模式',
|
||||
'app.theme.switchToLight': '切换到日间模式',
|
||||
'app.theme.switchToDark': '切换到夜间模式',
|
||||
'app.desktopConsole': '桌面控制台',
|
||||
'app.navigation': '主导航',
|
||||
'app.nav.home': '首页',
|
||||
'app.nav.versions': '版本管理',
|
||||
'app.nav.kernel': '内核',
|
||||
'app.nav.config': '高级设置',
|
||||
'app.nav.thinkingAliases': '模型别名',
|
||||
'app.nav.oauth': 'OAuth',
|
||||
'app.nav.api': 'API 接入',
|
||||
'app.nav.authFiles': '认证文件',
|
||||
'app.nav.quota': '额度查询',
|
||||
'app.nav.usageRecords': '使用记录',
|
||||
'app.nav.agents': '智能体配置',
|
||||
'app.coreRequired.title': '请先启动内核',
|
||||
'app.coreRequired.description': '启动 CPA 内核后,才能使用配置、OAuth、API 接入等功能。',
|
||||
'app.contact.title': '通过 QQ 联系我们',
|
||||
'app.contact.label': '联系我们',
|
||||
'app.close.title': '请选择后续操作',
|
||||
'app.close.description': '你可以退出程序,或让 EasyCLIProxyAPI 继续在系统托盘中运行。',
|
||||
'app.close.minimizing': '正在最小化...',
|
||||
'app.close.minimize': '最小化到托盘',
|
||||
'app.close.exiting': '正在退出...',
|
||||
'app.close.exit': '退出程序',
|
||||
'app.close.remember': '记住选项',
|
||||
'error.render.title': '页面渲染出现异常',
|
||||
'error.unknown': '未知错误',
|
||||
'error.reload': '重新加载页面',
|
||||
'quota.title': '额度查询',
|
||||
'quota.queryableCredentials.one': '{count} 个可查询凭据',
|
||||
'quota.queryableCredentials.other': '{count} 个可查询凭据',
|
||||
'quota.credentials.one': '{count} 个凭据',
|
||||
'quota.credentials.other': '{count} 个凭据',
|
||||
'quota.readList': '读取列表',
|
||||
'quota.refreshAll': '刷新全部',
|
||||
'quota.loadingFiles': '读取认证文件中',
|
||||
'quota.empty.title': '暂无可查询配额',
|
||||
'quota.empty.description': '先在认证文件中添加支持配额查询的凭据。',
|
||||
'quota.unknownProvider': '未知',
|
||||
'quota.reset': '重置额度',
|
||||
'quota.fileDisabled': '认证文件已停用',
|
||||
'quota.refresh': '获取/刷新额度',
|
||||
'quota.notFetched': '尚未获取额度',
|
||||
'quota.disabled': '已停用',
|
||||
'quota.fetch': '获取额度',
|
||||
'quota.querying': '查询中',
|
||||
'quota.resetCredits': '主动重置次数',
|
||||
'quota.earliestExpiry': '最早过期',
|
||||
'quota.remaining': '剩余 {percent}%',
|
||||
'quota.confirm.title': '确认重置“{name}”的 Codex 额度吗?',
|
||||
'quota.confirm.cost': '本操作将消耗 1 次主动重置机会。',
|
||||
'quota.confirm.available': '当前可用:{count} 次',
|
||||
'quota.confirm.expiry': '最早过期:{time}',
|
||||
'quota.confirm.warning': '只有点击“确定”后才会执行;点击“取消”不会消耗重置机会。',
|
||||
'oauth.title': 'OAuth 登录',
|
||||
'oauth.tabs.label': 'OAuth 管理子页面',
|
||||
'oauth.loginSuccess': '{provider} 登录成功',
|
||||
'oauth.priorityApplyFailed': 'OAuth 登录成功,但无法将新凭证的优先级设为 0:{error}',
|
||||
'oauth.authFailed': '认证失败',
|
||||
'oauth.loginFailed': '{provider} 登录失败{detail}',
|
||||
'oauth.missingState': '内核未返回 OAuth state',
|
||||
'oauth.missingStatePolling': '内核未返回 OAuth state,无法轮询登录状态',
|
||||
'oauth.openFailedDetail': '已获取登录链接,但自动打开浏览器失败:{error}',
|
||||
'oauth.openFailed': '已获取登录链接,但自动打开浏览器失败,请手动打开',
|
||||
'oauth.linkCopied': '登录链接已复制',
|
||||
'oauth.linkCopyFailed': '复制登录链接失败',
|
||||
'oauth.pasteCallback': '请粘贴回调 URL',
|
||||
'oauth.pasteXaiCallback': '请粘贴 xAI 返回的 code 或回调 URL',
|
||||
'oauth.invalidCallback': '回调内容无效',
|
||||
'oauth.invalidXaiCallback': '无法从输入内容构造 xAI 回调,请检查 code 与 state',
|
||||
'oauth.callbackSubmittedNotice': '回调已提交,正在等待认证完成',
|
||||
'oauth.loginAnother': '登录另一个账号',
|
||||
'oauth.loggingIn': '登录中…',
|
||||
'oauth.startLogin': '开始登录',
|
||||
'oauth.hint': '点击开始登录后,将自动打开浏览器完成授权;如未自动跳转,可手动打开链接',
|
||||
'oauth.authorizationLink': '授权链接',
|
||||
'oauth.copyLink': '复制链接',
|
||||
'oauth.openLink': '打开链接',
|
||||
'oauth.callbackPlaceholder': '粘贴完整回调 URL',
|
||||
'oauth.xaiCallbackPlaceholder': '粘贴 code 或完整回调 URL',
|
||||
'oauth.submitCallback': '提交回调',
|
||||
'oauth.callbackSubmitted': '回调已提交,等待认证完成',
|
||||
'oauth.callbackFailed': '回调提交失败{detail}',
|
||||
'oauth.openBrowser': '打开浏览器',
|
||||
'oauth.status.completed': '已完成',
|
||||
'oauth.status.failed': '失败',
|
||||
'oauth.status.waiting': '等待回调',
|
||||
'oauth.status.signedOut': '未登录',
|
||||
'usage.title': '使用记录',
|
||||
'usage.collector.collecting': '本地采集中',
|
||||
'usage.collector.error': '采集异常',
|
||||
'usage.collector.waiting': '等待内核',
|
||||
'usage.longTermRecords': '{count} 条长期记录',
|
||||
'usage.pageLabel': '使用记录页面',
|
||||
'usage.tab.overview': '总览',
|
||||
'usage.tab.analysis': '分析',
|
||||
'usage.tab.events': '请求明细',
|
||||
'usage.tab.pricing': '价格统计',
|
||||
'usage.filter.timeRange': '时间范围',
|
||||
'usage.range.4h': '最近 4 小时',
|
||||
'usage.range.24h': '最近 24 小时',
|
||||
'usage.range.today': '今天',
|
||||
'usage.range.7d': '最近 7 天',
|
||||
'usage.range.30d': '最近 30 天',
|
||||
'usage.range.all': '全部时间',
|
||||
'usage.range.custom': '自定义',
|
||||
'usage.filter.model': '模型',
|
||||
'usage.filter.allModels': '全部模型',
|
||||
'usage.filter.allProviders': '全部 Provider',
|
||||
'usage.filter.source': '来源',
|
||||
'usage.filter.allSources': '全部来源',
|
||||
'usage.filter.allKeys': '全部密钥',
|
||||
'usage.filter.result': '请求结果',
|
||||
'usage.filter.allResults': '全部结果',
|
||||
'usage.result.success': '成功',
|
||||
'usage.result.failed': '失败',
|
||||
'usage.filter.startTime': '开始时间',
|
||||
'usage.filter.endTime': '结束时间',
|
||||
'usage.filter.to': '至',
|
||||
'usage.loading': '正在读取本地 SQLite 使用记录',
|
||||
'usage.stat.requests': '请求总数',
|
||||
'usage.stat.requestMeta': '成功 {success} · 失败 {failed}',
|
||||
'usage.stat.tokens': 'Token 总量',
|
||||
'usage.stat.tokenMeta': '入 {input} · 出 {output}',
|
||||
'usage.stat.successRate': '成功率',
|
||||
'usage.stat.reasoningMeta': '思考 {tokens}',
|
||||
'usage.stat.averageLatency': '平均延迟',
|
||||
'usage.stat.tps': 'TPS',
|
||||
'usage.stat.performanceMeta': 'RPM {rpm} · {latency} ms',
|
||||
'usage.stat.cacheHitRate': '缓存命中率',
|
||||
'usage.stat.cacheHitMeta': '缓存 {hit}/{input}',
|
||||
'usage.stat.estimatedCost': '预估成本',
|
||||
'usage.stat.costMeta': '计价 {priced}/{total} · 估算',
|
||||
'usage.pricing.coverage': '已计价 {priced} / {total} 次请求 · 可用 {saved} 个模型价格',
|
||||
'usage.pricing.search': '搜索模型',
|
||||
'usage.pricing.add': '手动添加',
|
||||
'usage.pricing.sync': '同步价格',
|
||||
'usage.pricing.syncing': '同步中',
|
||||
'usage.pricing.syncResult': '价格更新完成:导入 {imported},保留手动价格 {skipped},未匹配 {unmatched}',
|
||||
'usage.pricing.syncFallback': '远程价格读取失败,已继续使用软件内置价格',
|
||||
'usage.pricing.model': '模型',
|
||||
'usage.pricing.calls': '请求数',
|
||||
'usage.pricing.cost': '预估成本',
|
||||
'usage.pricing.prompt': '输入价 ($/1M)',
|
||||
'usage.pricing.completion': '输出价 ($/1M)',
|
||||
'usage.pricing.cache': '兼容缓存价 ($/1M)',
|
||||
'usage.pricing.cacheRead': '缓存读取价 ($/1M)',
|
||||
'usage.pricing.cacheCreation': '缓存创建价 ($/1M)',
|
||||
'usage.pricing.actions': '操作',
|
||||
'usage.pricing.optional': '可选,留空使用回退价',
|
||||
'usage.pricing.modelRequired': '请输入模型名称',
|
||||
'usage.pricing.saving': '保存中',
|
||||
'usage.pricing.saved': '模型价格已保存,总览成本已更新',
|
||||
'usage.pricing.deleted': '模型价格已删除,总览成本已更新',
|
||||
'usage.pricing.deleteConfirm': '确定删除模型 {model} 的价格吗?',
|
||||
'usage.trend.title': '请求与 Token 趋势',
|
||||
'usage.trend.description': '按系统本地小时聚合',
|
||||
'usage.token.title': 'Token 构成',
|
||||
'usage.token.description': '缓存、思考与生成消耗',
|
||||
'usage.token.input': '输入',
|
||||
'usage.token.output': '输出',
|
||||
'usage.token.reasoning': '思考',
|
||||
'usage.token.cacheRead': '缓存读取',
|
||||
'usage.token.cacheCreation': '缓存创建',
|
||||
'usage.trend.aria': '请求趋势',
|
||||
'usage.analysis.models': '模型使用',
|
||||
'usage.analysis.sources': '请求来源',
|
||||
'usage.analysis.keys': '鉴权密钥',
|
||||
'usage.analysis.hours': '时段分布',
|
||||
'usage.analysis.sortedByTokens': '按 Token 排序',
|
||||
'usage.analysis.itemMeta': '{requests} 次 · {percent}% · {tokens} Token',
|
||||
'usage.events.total': '共 {count} 条记录',
|
||||
'usage.events.page': '第 {page} / {total} 页',
|
||||
'usage.column.time': '时间',
|
||||
'usage.column.model': '模型',
|
||||
'usage.column.source': '来源',
|
||||
'usage.column.result': '结果',
|
||||
'usage.column.latency': '总耗时',
|
||||
'usage.column.ttft': '首字延迟',
|
||||
'usage.column.input': '输入',
|
||||
'usage.column.output': '输出',
|
||||
'usage.column.reasoning': '思考',
|
||||
'usage.column.cache': '缓存',
|
||||
'usage.column.total': '总计',
|
||||
'usage.key.noRemark': '未备注',
|
||||
'usage.previous': '上一页',
|
||||
'usage.next': '下一页',
|
||||
'usage.empty': '当前筛选范围没有使用记录',
|
||||
'aliases.effort.low': '轻量',
|
||||
'aliases.effort.medium': '均衡',
|
||||
'aliases.effort.high': '深入',
|
||||
'aliases.effort.xhigh': '超高',
|
||||
'aliases.effort.max': '最大',
|
||||
'aliases.source.openAiCompatible': 'OpenAI 兼容',
|
||||
'aliases.source.other': '其他来源',
|
||||
'aliases.source.available': '内核当前可用模型',
|
||||
'aliases.error.selectModel': '请先选择原模型',
|
||||
'aliases.error.emptyAlias': '别名模型不能为空',
|
||||
'aliases.error.emptyEffort': '思考强度不能为空',
|
||||
'aliases.error.emptyOptions': '请至少设置思考强度或启用 Fast',
|
||||
'aliases.error.unsupportedEffort': '该模型不支持设置思考强度,只能使用 Fast 选项',
|
||||
'aliases.error.unsupportedFast': '该模型当前不支持 Fast 选项',
|
||||
'aliases.created': '已创建 {alias},思考强度固定为 {effort}',
|
||||
'aliases.createdCombined': '已创建 {alias},思考强度为 {effort},同时启用 Fast',
|
||||
'aliases.deleteConfirm': '确定删除别名模型“{alias}”吗?\n将同时移除对应的思考强度和 Fast 覆盖规则。',
|
||||
'aliases.deleted': '已删除 {alias}',
|
||||
'aliases.create.title': '创建模型别名',
|
||||
'aliases.create.description': '思考强度和 Fast 速度层级可独立设置,也可以同时启用',
|
||||
'aliases.originalModel': '原模型',
|
||||
'aliases.loadingModels': '正在读取模型',
|
||||
'aliases.searchModel': '搜索并选择原模型',
|
||||
'aliases.availableModels': '可用原模型',
|
||||
'aliases.noMatch': '没有匹配的模型或来源',
|
||||
'aliases.noModels': '当前没有可选模型',
|
||||
'aliases.sourceLabel': '来源:{source}',
|
||||
'aliases.sourceHint': '同名模型会按 Codex OAuth、Codex API 和 OpenAI 兼容来源分别列出',
|
||||
'aliases.effort.title': '思考强度',
|
||||
'aliases.effort.description': '可单独设置,也可以与 Fast 同时启用',
|
||||
'aliases.effort.none': '不设置',
|
||||
'aliases.effort.noneHint': '不覆盖思考强度,仅使用其他已启用的选项',
|
||||
'aliases.effort.unsupported': '这个模型没有可用的思考强度,已切换为仅启用 Fast。',
|
||||
'aliases.fast.title': '速度选项',
|
||||
'aliases.fast.description': '固定使用 Fast 请求层级(service_tier=priority)',
|
||||
'aliases.fast.enabled': '已启用',
|
||||
'aliases.fast.disabled': '未启用',
|
||||
'aliases.customLevel': '自定义等级:{effort}',
|
||||
'aliases.customLevelHint': '输入模型提供商支持的其他思考等级',
|
||||
'aliases.custom': '自定义',
|
||||
'aliases.customPlaceholder': '例如 minimal、auto、ultra 或厂商等级',
|
||||
'aliases.collapseCustom': '收起自定义输入',
|
||||
'aliases.customHelp': '仅填写目标模型实际支持的等级名称',
|
||||
'aliases.aliasName.title': '别名模型名称',
|
||||
'aliases.aliasName.description': '由你填写,仅新增入口,不会改名原模型',
|
||||
'aliases.aliasName.autoDescription': '根据原模型和已选选项自动生成,可按需修改;不会改名原模型',
|
||||
'aliases.aliasName.example': '例如 {model}-{effort}',
|
||||
'aliases.aliasName.selectFirst': '先选择原模型,再填写客户端使用的别名',
|
||||
'aliases.notSelected': '未选择模型',
|
||||
'aliases.enterAlias': '请输入别名模型名称',
|
||||
'aliases.notSet': '未填写',
|
||||
'aliases.creating': '创建中',
|
||||
'aliases.create': '创建别名',
|
||||
'aliases.createdList.title': '已创建别名',
|
||||
'aliases.createdList.description': '每个别名会同时显示已设置的思考强度和 Fast 状态',
|
||||
'aliases.loadingConfig': '读取配置中',
|
||||
'aliases.empty.title': '尚未创建模型别名',
|
||||
'aliases.empty.description': '从左侧选择模型和别名选项即可创建',
|
||||
'aliases.unboundEffort': '未绑定强度',
|
||||
'aliases.delete': '删除 {alias}',
|
||||
'speedAliases.created': '已创建 {alias},速度层级固定为 Fast',
|
||||
'speedAliases.deleted': '已删除 {alias}',
|
||||
'speedAliases.deleteConfirm': '确定删除速度别名模型“{alias}”吗?\n将同时移除对应的 Fast 覆盖规则。',
|
||||
'speedAliases.fast.title': 'Fast',
|
||||
'speedAliases.aliasName.example': '例如 {model}-fast',
|
||||
'speedAliases.delete': '删除 {alias}',
|
||||
'config.routing.roundRobin': '轮询',
|
||||
'config.routing.fillFirst': '优先填充',
|
||||
'config.tabs.label': '高级设置子页面',
|
||||
'config.tabs.general': '基础设置',
|
||||
'config.tabs.network': '网络与路由',
|
||||
'config.tabs.software': '软件设置',
|
||||
'config.error.emptyKey': '鉴权密钥不能为空',
|
||||
'config.error.invalidKey': '只能使用 ASCII 可见字符,且不能包含空格',
|
||||
'config.error.duplicateKey': '该鉴权密钥已经存在',
|
||||
'config.error.remarkTooLong': '备注不能超过 80 个字符',
|
||||
'config.error.portRange': '端口必须是 1–65535 之间的整数',
|
||||
'config.error.networkRestartFailed': '设置已保存,但内核重启失败:{error}',
|
||||
'config.error.saveFailed': '保存失败:{error}',
|
||||
'config.notice.keyAdded': '鉴权密钥已新增',
|
||||
'config.notice.keyUpdated': '鉴权密钥已更新',
|
||||
'config.notice.keyDeleted': '鉴权密钥已删除',
|
||||
'config.notice.keyCopied': '鉴权密钥已复制',
|
||||
'config.notice.keyCopyFailed': '复制鉴权密钥失败',
|
||||
'config.notice.routingUpdated': '路由策略已更新',
|
||||
'config.notice.networkUpdated': '网络与会话路由设置已保存',
|
||||
'config.notice.networkRestarted': '网络设置已保存,内核已重启',
|
||||
'config.notice.networkNextStart': '网络设置已保存,将在下次启动内核时生效',
|
||||
'config.notice.proxyUpdated': '代理 URL 已保存',
|
||||
'config.notice.sessionAffinityEnabled': '会话粘性路由已启用',
|
||||
'config.notice.sessionAffinityDisabled': '会话粘性路由已停用',
|
||||
'config.notice.sessionTtlUpdated': '会话粘性 TTL 已保存',
|
||||
'config.notice.softwareUpdated': '软件设置已保存',
|
||||
'config.software.title': '软件设置',
|
||||
'config.software.autostart': '开机自启',
|
||||
'config.software.autostartDescription': '登录系统后自动启动 EasyCLIProxyAPI',
|
||||
'config.software.silentStart': '静默启动',
|
||||
'config.software.silentStartDescription': '启动应用时不显示主窗口,直接在系统托盘运行',
|
||||
'config.software.closeBehavior': '关闭行为',
|
||||
'config.software.closeBehaviorDescription': '决定关闭窗口时是退出程序还是继续在系统托盘运行',
|
||||
'config.software.behavior.ask': '每次询问',
|
||||
'config.software.behavior.minimize': '最小化到托盘',
|
||||
'config.software.behavior.exit': '退出程序',
|
||||
'config.webuiKey.title': 'WebUI 密钥',
|
||||
'config.webuiKey.configured': '已配置',
|
||||
'config.webuiKey.unconfigured': '未配置',
|
||||
'config.webuiKey.heading': '管理接口访问凭据',
|
||||
'config.webuiKey.description': '用于登录 CPA WebUI 和调用管理接口,与上方 API 鉴权密钥用途不同。',
|
||||
'config.webuiKey.securityHint': '现有密钥不会显示;如果忘记请直接重新设置密钥',
|
||||
'config.webuiKey.newKey': '新 WebUI 密钥',
|
||||
'config.webuiKey.confirmKey': '确认新密钥',
|
||||
'config.webuiKey.placeholder': '输入新的明文密钥',
|
||||
'config.webuiKey.confirmPlaceholder': '再次输入新密钥',
|
||||
'config.webuiKey.generate': '生成安全密钥',
|
||||
'config.webuiKey.save': '保存密钥',
|
||||
'config.webuiKey.open': '打开 WebUI',
|
||||
'config.webuiKey.error.empty': 'WebUI 密钥不能为空',
|
||||
'config.webuiKey.error.legacyDefault': '不能继续使用旧版默认密钥 123456',
|
||||
'config.webuiKey.error.tooLong': 'WebUI 密钥不能超过 512 个字符',
|
||||
'config.webuiKey.error.invalid': 'WebUI 密钥不能包含控制字符',
|
||||
'config.webuiKey.error.mismatch': '两次输入的 WebUI 密钥不一致',
|
||||
'config.webuiKey.error.openFailed': '无法打开 WebUI:{error}',
|
||||
'config.webuiKey.notice.updated': 'WebUI 密钥已更新',
|
||||
'config.keys.title': '鉴权密钥',
|
||||
'config.keys.count': '密钥数量',
|
||||
'config.keys.add': '新增鉴权密钥',
|
||||
'config.unavailable': '配置不可用',
|
||||
'common.retry': '重试',
|
||||
'config.keys.noRemark': '未填写备注',
|
||||
'config.keys.copy': '复制鉴权密钥',
|
||||
'config.keys.copyNth': '复制第 {number} 个鉴权密钥',
|
||||
'config.keys.edit': '编辑鉴权密钥',
|
||||
'config.keys.editNth': '编辑第 {number} 个鉴权密钥',
|
||||
'config.keys.delete': '删除鉴权密钥',
|
||||
'config.keys.deleteNth': '删除第 {number} 个鉴权密钥',
|
||||
'config.keys.empty': '暂无鉴权密钥',
|
||||
'common.loading': '读取中',
|
||||
'common.unavailable': '不可用',
|
||||
'common.enabled': '已启用',
|
||||
'common.disabled': '已停用',
|
||||
'config.routing.title': '路由策略',
|
||||
'config.network.title': '网络与会话路由',
|
||||
'config.network.networkSection': '网络设置',
|
||||
'config.network.routingSection': '会话路由',
|
||||
'config.network.saving': '正在保存',
|
||||
'config.network.saved': '已保存',
|
||||
'config.network.unsaved': '有未保存修改',
|
||||
'config.network.confirmSave': '确认保存',
|
||||
'config.network.port': '端口',
|
||||
'config.network.portHint': '范围 1–65535;内核运行时修改会自动重启',
|
||||
'config.network.allowLan': '允许局域网',
|
||||
'config.network.allowLanHint': '启用后将监听0.0.0.0',
|
||||
'config.network.proxyUrl': '代理 URL',
|
||||
'config.network.proxyPlaceholder': '例如 socks5://127.0.0.1:7890',
|
||||
'config.network.proxyHint': '用于内核和软件本体访问外部服务的全局代理;留空表示不使用',
|
||||
'config.network.sessionAffinity': '会话粘性路由',
|
||||
'config.network.sessionAffinityHint': '同一会话优先复用已选择的凭据',
|
||||
'config.network.sessionTtl': '会话粘性 TTL',
|
||||
'config.network.sessionTtlHint': '例如 1h;留空使用内核默认值',
|
||||
'config.keys.addTitle': '新增鉴权密钥',
|
||||
'config.keys.editTitle': '编辑鉴权密钥',
|
||||
'common.close': '关闭',
|
||||
'config.keys.label': '鉴权密钥',
|
||||
'config.keys.hide': '隐藏密钥',
|
||||
'config.keys.show': '显示密钥',
|
||||
'config.keys.remark': '备注',
|
||||
'config.keys.remarkPlaceholder': '例如:开发环境、张三的密钥',
|
||||
'config.keys.generate': '生成密钥',
|
||||
'config.keys.adding': '正在新增',
|
||||
'common.add': '新增',
|
||||
'config.keys.deleteTitle': '删除鉴权密钥',
|
||||
'config.keys.deleteAllWarning': '删除所有密钥将会导致默认放行所有密钥',
|
||||
'common.cancel': '取消',
|
||||
'common.deleting': '正在删除',
|
||||
'common.delete': '删除',
|
||||
'authFiles.unknownProvider': '未知提供商',
|
||||
'authFiles.status.disabled': '已停用',
|
||||
'authFiles.status.unavailable': '不可用',
|
||||
'authFiles.status.ready': '就绪',
|
||||
'authFiles.quota.loading': '额度查询中',
|
||||
'authFiles.quota.failed': '额度获取失败',
|
||||
'authFiles.quota.aria': '额度信息',
|
||||
'authFiles.quota.empty': '暂无额度数据',
|
||||
'authFiles.quota.resets': '主动重置 {count} 次',
|
||||
'authFiles.quota.expiry': '最早过期 {time}',
|
||||
'authFiles.models.noneForProvider': '该提供商没有可配置的模型定义',
|
||||
'authFiles.models.updated': '{provider} 开放模型已更新',
|
||||
'authFiles.priority.button': '优先级 {priority}',
|
||||
'authFiles.priority.title': '修改凭证优先级',
|
||||
'authFiles.priority.label': '优先级',
|
||||
'authFiles.priority.placeholder': '例如 10 或 -1',
|
||||
'authFiles.priority.hint': '仅支持整数;数值越大优先级越高。留空或填写 0 恢复默认。',
|
||||
'authFiles.priority.invalid': '请输入有效的整数优先级',
|
||||
'authFiles.priority.updated': '{name} 的优先级已更新',
|
||||
'authFiles.uploaded': '已上传 {count} 个认证文件',
|
||||
'authFiles.uploadFailed': '{count} 个文件上传失败:{errors}',
|
||||
'authFiles.notice.enabled': '认证文件已启用',
|
||||
'authFiles.notice.disabled': '认证文件已停用',
|
||||
'authFiles.runtimeDeleteError': '运行时认证文件不能从磁盘删除',
|
||||
'authFiles.deleteConfirm': '确定删除“{name}”吗?',
|
||||
'authFiles.deleted': '认证文件已删除',
|
||||
'common.copyFailed': '复制失败',
|
||||
'authFiles.title': '认证文件',
|
||||
'authFiles.summary': '{files} 个文件 · {disabled} 个停用',
|
||||
'common.refresh': '刷新',
|
||||
'common.upload': '上传',
|
||||
'authFiles.import': '导入',
|
||||
'authFiles.searchPlaceholder': '搜索文件名、账号或提供商',
|
||||
'authFiles.filter.allProviders': '全部提供商',
|
||||
'authFiles.filter.allStatuses': '全部状态',
|
||||
'authFiles.filter.enabled': '已启用',
|
||||
'authFiles.filter.disabled': '已停用',
|
||||
'authFiles.filter.runtime': '仅运行时',
|
||||
'authFiles.loading': '读取认证文件中',
|
||||
'authFiles.empty.filtered': '没有匹配的认证文件',
|
||||
'authFiles.empty.none': '暂无认证文件',
|
||||
'authFiles.empty.tryFilter': '换个筛选条件试试',
|
||||
'authFiles.empty.upload': '上传 JSON 认证文件后会显示在这里',
|
||||
'authFiles.unknownSize': '大小未知',
|
||||
'authFiles.runtime': '运行时',
|
||||
'authFiles.quota.querying': '查询中',
|
||||
'authFiles.quota.refresh': '刷新额度',
|
||||
'authFiles.quota.fetch': '获取额度',
|
||||
'authFiles.models.settings': '设置开放模型',
|
||||
'authFiles.models.button': '模型',
|
||||
'authFiles.copyName': '复制文件名',
|
||||
'authFiles.openDirectory': '打开文件夹',
|
||||
'common.enable': '启用',
|
||||
'common.disable': '停用',
|
||||
'authFiles.runtimeFootnote': '{count} 个凭据来自运行时存储,只能在对应提供商或认证源中管理。',
|
||||
'authFiles.models.title': '开放模型',
|
||||
'authFiles.models.description': '{provider} · 默认全部开放,可取消不需要的模型',
|
||||
'authFiles.models.search': '搜索模型名称',
|
||||
'authFiles.models.summary': '共 {total} 个 · 已开放 {open} 个',
|
||||
'authFiles.models.closeVisible': '关闭当前',
|
||||
'authFiles.models.openVisible': '开放当前',
|
||||
'authFiles.models.openAll': '全部开放',
|
||||
'authFiles.models.closeAll': '全部关闭',
|
||||
'authFiles.models.loading': '正在读取模型定义',
|
||||
'authFiles.models.loadFailed': '读取模型失败',
|
||||
'authFiles.models.noMatch': '没有匹配的模型',
|
||||
'authFiles.models.empty': '暂无模型定义',
|
||||
'common.saving': '保存中',
|
||||
'authFiles.models.save': '保存(开放 {count} 个)',
|
||||
'agents.description.claudeCode': '使用 CPA 的 Anthropic 兼容接口',
|
||||
'agents.description.claudeDesktop': '使用 CPA 3P 推理网关,支持 Windows、macOS 和 Linux Beta',
|
||||
'agents.description.codex': '使用 CPA Responses 接口',
|
||||
'agents.description.opencode': '使用 CPA OpenAI Compatible provider',
|
||||
'agents.description.openclaw': '使用 CPA 模型供应商和默认模型',
|
||||
'agents.description.hermes': '使用 CPA custom provider',
|
||||
'agents.description.pi': '使用 Pi CLI 和 CLIProxyAPI provider 动态加载模型',
|
||||
'agents.list.detecting': '正在检测',
|
||||
'agents.list.unsupported': '当前平台不支持',
|
||||
'agents.list.notInstalled': '未检测到安装',
|
||||
'agents.list.modified': '已修改 · {model}',
|
||||
'agents.list.installedVersion': '已安装 · {version}',
|
||||
'agents.list.installed': '已安装 · 保持原配置',
|
||||
'agents.list.pluginNotInstalled': 'Pi provider 插件未安装',
|
||||
'agents.list.piInstalled': 'Pi 插件已安装',
|
||||
'agents.list.configured': '已配置 · EasyCLIProxyAPI',
|
||||
'agents.model.loading': '正在读取模型',
|
||||
'agents.model.loadFailed': '获取模型失败',
|
||||
'agents.model.select': '选择模型',
|
||||
'agents.model.none': '模型列表为空',
|
||||
'agents.model.search': '搜索模型名称或别名',
|
||||
'agents.model.clearSearch': '清空搜索',
|
||||
'agents.model.refresh': '刷新模型',
|
||||
'agents.model.fetching': '正在获取模型',
|
||||
'agents.model.noMatch': '没有匹配的模型',
|
||||
'agents.model.unavailable': '模型列表为空',
|
||||
'agents.model.tryKeywords': '尝试其他关键词',
|
||||
'agents.model.connectFirst': '请先接入可用模型后刷新',
|
||||
'agents.model.available': '可用模型',
|
||||
'agents.model.count': '{count} 个可用模型',
|
||||
'agents.model.stale': '刷新失败,显示上次结果',
|
||||
'agents.error.modelsLoading': '模型列表仍在加载,请稍后再试',
|
||||
'agents.error.noModels': '当前没有可选模型,无法应用配置修改',
|
||||
'agents.error.selectionGone': '当前选择已不在可用模型列表中,请刷新模型后重新选择',
|
||||
'agents.error.mappingSelectionGone': 'Claude Desktop 模型映射包含不可用模型,请刷新模型后重新选择',
|
||||
'agents.error.applyFirst': '模型选择尚未应用,请先点击“应用配置修改”',
|
||||
'agents.status.detecting': '检测中',
|
||||
'agents.status.unsupported': '平台不支持',
|
||||
'agents.status.notInstalled': '未安装',
|
||||
'agents.status.conflict': '配置冲突',
|
||||
'agents.status.modified': '已修改配置',
|
||||
'agents.status.original': '保持原配置',
|
||||
'agents.status.invalid': '配置无效',
|
||||
'agents.status.applied': '已应用',
|
||||
'agents.status.unconfigured': '尚未配置',
|
||||
'agents.title': '智能体配置',
|
||||
'agents.redetect': '重新检测',
|
||||
'agents.tabs.label': '智能体配置子页面',
|
||||
'agents.tabs.core': '核心配置',
|
||||
'agents.tabs.sessions': '会话管理',
|
||||
'agents.sessions.title': 'Codex 会话管理',
|
||||
'agents.sessions.description': '扫描本地 Codex 会话,并安全地恢复历史会话、备份或删除。',
|
||||
'agents.sessions.refresh': '刷新',
|
||||
'agents.sessions.currentPage': '当前页',
|
||||
'agents.sessions.active': '未归档',
|
||||
'agents.sessions.archived': '已归档',
|
||||
'agents.sessions.database': '数据库来源',
|
||||
'agents.sessions.databaseMissing': '未发现会话数据库',
|
||||
'agents.sessions.repairTitle': '恢复历史会话',
|
||||
'agents.sessions.repairDescription': '将其他渠道的会话同步到当前会话',
|
||||
'agents.sessions.repairNow': '立即恢复',
|
||||
'agents.sessions.repairing': '正在恢复',
|
||||
'agents.sessions.loadFailed': '读取 Codex 会话失败:{error}',
|
||||
'agents.sessions.localSessions': '本地会话',
|
||||
'agents.sessions.pageDescription': '第 {page} 页 · 每页最多 {size} 条',
|
||||
'agents.sessions.selected': '已选 {count} 条',
|
||||
'agents.sessions.selectAll': '全选本页',
|
||||
'agents.sessions.clearSelection': '清空选择',
|
||||
'agents.sessions.deleteSelected': '删除所选',
|
||||
'agents.sessions.multiSelect': '多选',
|
||||
'agents.sessions.loading': '正在扫描本地会话…',
|
||||
'agents.sessions.empty': '没有找到可显示的 Codex 会话。',
|
||||
'agents.sessions.selectSession': '选择会话 {title}',
|
||||
'agents.sessions.untitled': '未命名会话',
|
||||
'agents.sessions.noProject': '无项目路径',
|
||||
'agents.sessions.noProvider': '未知 Provider',
|
||||
'agents.sessions.noTime': '更新时间未知',
|
||||
'agents.sessions.activeBadge': '未归档',
|
||||
'agents.sessions.archivedBadge': '已归档',
|
||||
'agents.sessions.previousPage': '上一页',
|
||||
'agents.sessions.nextPage': '下一页',
|
||||
'agents.sessions.pageNumber': '第 {page} 页',
|
||||
'agents.sessions.deleteOneTitle': '删除这个会话?',
|
||||
'agents.sessions.deleteManyTitle': '删除 {count} 个会话?',
|
||||
'agents.sessions.deleteOneDescription': '将先创建备份,再删除“{title}”及其已知关联记录。此操作不提供界面内撤销。',
|
||||
'agents.sessions.deleteManyDescription': '将先创建备份,再删除以下 {count} 个会话及其已知关联记录:{preview}{extra}',
|
||||
'agents.sessions.deleteMore': ',另有 {count} 个',
|
||||
'agents.sessions.confirmDelete': '备份并删除',
|
||||
'agents.sessions.deleteResult': '已删除 {deleted} 个,失败 {failed} 个。{details}{backup}',
|
||||
'agents.sessions.deleteFailed': '删除会话失败:{error}',
|
||||
'agents.sessions.backupAt': '备份位置:{path}',
|
||||
'agents.sessions.repairResult': '历史会话已恢复到 {provider}:迁移 {files} 个 rollout 文件、更新 {rows} 条数据库记录、跳过 {skipped} 个占用文件。',
|
||||
'agents.sessions.repairFailed': '恢复历史会话失败:{error}',
|
||||
'agents.sessions.progressCount': '{processed}/{total}',
|
||||
'agents.sessions.progress.scanning': '正在扫描会话',
|
||||
'agents.sessions.progress.backingUp': '正在创建备份',
|
||||
'agents.sessions.progress.rewriting': '正在迁移历史会话',
|
||||
'agents.sessions.progress.updatingDatabase': '正在同步数据库',
|
||||
'agents.sessions.progress.complete': '恢复完成',
|
||||
'agents.sessions.scanIndex': '检查无效会话记录',
|
||||
'agents.sessions.scanningIndex': '正在检查无效记录',
|
||||
'agents.sessions.cleanupNoCandidates': '未发现可安全清理的无效会话记录。',
|
||||
'agents.sessions.cleanupPreviewFailed': '检查无效会话记录失败:{error}',
|
||||
'agents.sessions.cleanupTitle': '可清理的无效索引',
|
||||
'agents.sessions.cleanupDescription': '发现 {count} 条只存在于 session_index.jsonl、但已没有真实会话来源的记录。默认不选择;确认 Codex 和 ChatGPT 已退出后,再逐项清理。未知格式记录会保留。',
|
||||
'agents.sessions.cleanupSelectAll': '选择全部候选',
|
||||
'agents.sessions.cleanupConfirm': '清理所选 {count} 条',
|
||||
'agents.sessions.cleanupResult': '已清理 {count} 条无效会话索引。{backup}',
|
||||
'agents.sessions.cleanupFailed': '清理无效会话索引失败:{error}',
|
||||
'agents.localClients': '本机客户端',
|
||||
'agents.selectClient': '选择需要管理的智能体',
|
||||
'agents.installStatus': '安装状态',
|
||||
'agents.clientDetected': '已检测到客户端',
|
||||
'agents.clientNotDetected': '未检测到客户端',
|
||||
'agents.clientVersion': '客户端版本',
|
||||
'agents.cliVersion': 'CLI 版本',
|
||||
'agents.appVersion': 'App 版本',
|
||||
'agents.pluginVersion': '插件版本',
|
||||
'agents.notFetched': '未获取',
|
||||
'agents.useModel': '使用模型',
|
||||
'agents.pending': '尚未应用',
|
||||
'agents.claudeDesktopMapping.title': 'Claude Desktop 模型 → 上游模型',
|
||||
'agents.claudeDesktopMapping.description': '选择每个 Claude Desktop 模型实际调用的上游模型。',
|
||||
'agents.claudeCodeMapping.title': 'Claude Code 模型 → 上游模型',
|
||||
'agents.claudeCodeMapping.description': '为不同任务角色选择使用的上游模型。',
|
||||
'agents.claudeDesktopMapping.customMapping': '自定义映射',
|
||||
'agents.claudeDesktopMapping.customMappingHint': '开启后只显示别名模型;关闭后只显示未设置别名的原始模型。',
|
||||
'agents.claudeDesktopMapping.opus': 'Claude Opus 5 映射到',
|
||||
'agents.claudeDesktopMapping.sonnet': 'Claude Sonnet 4.6 映射到',
|
||||
'agents.claudeDesktopMapping.haiku': 'Claude Haiku 4.5 映射到',
|
||||
'agents.model.readingAvailable': '正在读取可用模型',
|
||||
'agents.model.current': '当前已应用:{model}',
|
||||
'agents.model.firstSelection': '{count} 个可用模型,首次默认选择第一项',
|
||||
'agents.modify.title': '配置修改',
|
||||
'agents.modify.oauthConfiguration': 'OAuth登录配置方式',
|
||||
'agents.oauthLoginRequired.title': '需要登录 Codex',
|
||||
'agents.oauthLoginRequired.enableDescription': '未检测到有效的 Codex 登录凭据,无法开启 OAuth 配置模式。请先在 Codex 应用中登录 ChatGPT 账号,如果 Codex 应用无法触发登录,请先使用',
|
||||
'agents.oauthLoginRequired.enableClearConfiguration': '清空配置',
|
||||
'agents.oauthLoginRequired.enableDescriptionSuffix': '功能。',
|
||||
'agents.oauthLoginRequired.applyDescription': '无法应用 OAuth 配置:未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再应用配置。',
|
||||
'agents.oauthLoginRequired.confirm': '我知道了',
|
||||
'agents.modify.apply': '应用配置修改',
|
||||
'agents.modify.update': '更新配置',
|
||||
'agents.modify.close': '关闭配置修改',
|
||||
'agents.modify.default': '默认配置',
|
||||
'agents.modify.clear': '清空配置',
|
||||
'agents.modify.invalid': '当前配置无法解析。点击“应用配置修改”会重建最小合法配置;“默认配置”会重置整个文件。',
|
||||
'agents.pi.installTitle': 'Pi CLIProxyAPI provider',
|
||||
'agents.pi.installDescription': '安装或更新插件,并修复 CPA 凭据、默认 provider 与默认模型',
|
||||
'agents.pi.install': '安装 Pi 插件',
|
||||
'agents.pi.update': '更新 Pi 插件',
|
||||
'agents.pi.updateAvailable': '发现新版本 {version}',
|
||||
'agents.pi.updating': '正在更新插件',
|
||||
'agents.pi.installing': '正在安装插件',
|
||||
'agents.pi.repair': '应用配置',
|
||||
'agents.pi.repairing': '正在应用配置',
|
||||
'agents.pi.uninstall': '卸载 Pi 插件',
|
||||
'agents.pi.uninstalling': '正在卸载插件',
|
||||
'agents.default.title': '重置为默认配置',
|
||||
'agents.default.description': '这会使用当前选中模型重写 {name} 的全部相关配置文件,并删除其中其他用户内容。该操作没有备份且无法恢复。',
|
||||
'agents.default.confirm': '确认写入默认配置',
|
||||
'agents.clear.title': '清空 Codex 配置',
|
||||
'agents.clear.description': '这会永久删除 Codex 配置目录中的 auth.json 和 config.toml,并重置软件记录的应用状态。该操作没有备份且无法恢复。',
|
||||
'agents.clear.confirm': '确认清空配置',
|
||||
'agents.clear.success': 'Codex 配置已清空。',
|
||||
'apiAccess.compatibleName': 'OpenAI 兼容 {number}',
|
||||
'apiAccess.error.headerMissingColon': '自定义请求头第 {number} 行缺少冒号',
|
||||
'apiAccess.error.headerInvalid': '自定义请求头第 {number} 行格式无效',
|
||||
'apiAccess.error.partialLoad': '部分接入读取失败:{errors}',
|
||||
'apiAccess.error.requiredAll': '备注、Base URL 和密钥不能为空',
|
||||
'apiAccess.error.requiredBaseKey': 'Base URL 和 API 密钥不能为空',
|
||||
'apiAccess.error.requiredKey': 'API 密钥不能为空',
|
||||
'apiAccess.error.baseRequired': '{provider} 接入必须填写 Base URL',
|
||||
'apiAccess.error.noModels': '没有发现可放行的模型,请确认 Base URL 和 API 密钥',
|
||||
'apiAccess.error.stale': '接入配置已被其他操作修改,请刷新后重试',
|
||||
'apiAccess.error.duplicate': '相同的接入配置已经存在',
|
||||
'apiAccess.error.remarkInvalid': '备注不能超过 80 个字符,且不能包含换行或控制字符',
|
||||
'apiAccess.notice.updated': '接入已更新',
|
||||
'apiAccess.notice.added': '接入已新增',
|
||||
'apiAccess.deleteConfirm': '确定删除“{remark}”吗?',
|
||||
'apiAccess.notice.deleted': '接入已删除',
|
||||
'apiAccess.notice.enabled': '接入已启用',
|
||||
'apiAccess.notice.disabled': '接入已停用',
|
||||
'apiAccess.title': 'API 接入',
|
||||
'apiAccess.count': '{count} 个接入',
|
||||
'apiAccess.add': '新增',
|
||||
'apiAccess.matches': '{count} 个匹配接入',
|
||||
'apiAccess.search': '搜索备注、密钥或地址',
|
||||
'apiAccess.loading': '读取配置中',
|
||||
'apiAccess.empty.filtered': '没有匹配的接入',
|
||||
'apiAccess.empty.none': '暂无接入配置',
|
||||
'apiAccess.empty.tryKeyword': '换个关键词试试',
|
||||
'apiAccess.empty.addFirst': '点击右上角“新增”添加第一个接入',
|
||||
'apiAccess.keys.count': '{count} 个密钥',
|
||||
'apiAccess.keys.summary': '{key} · 共 {count} 个密钥',
|
||||
'apiAccess.remarkValue': '备注:{remark}',
|
||||
'apiAccess.defaultUrl': '使用默认地址',
|
||||
'apiAccess.models.summary': '模型 {count} 个 · {models}',
|
||||
'apiAccess.priorityValue': '优先级 {priority}',
|
||||
'apiAccess.enable': '启用接入',
|
||||
'apiAccess.disable': '停用接入',
|
||||
'apiAccess.status.disabled': '已停用',
|
||||
'apiAccess.status.enabled': '已启用',
|
||||
'apiAccess.toggleAria': '{remark} 接入{action}开关',
|
||||
'apiAccess.health.action': '健康检测',
|
||||
'apiAccess.health.title': '模型健康检测',
|
||||
'apiAccess.health.description': '{provider}',
|
||||
'apiAccess.health.warning': '检测会对每个模型发送一次最小流式请求,收到模型首字后立即停止,并可能产生少量费用。',
|
||||
'apiAccess.health.search': '搜索模型名称或别名',
|
||||
'apiAccess.health.loadingModels': '正在获取模型列表…',
|
||||
'apiAccess.health.modelLoadFailed': '模型列表刷新失败:{error}',
|
||||
'apiAccess.health.noMatch': '没有匹配的模型',
|
||||
'apiAccess.health.notChecked': '未检测',
|
||||
'apiAccess.health.checkOne': '检测',
|
||||
'apiAccess.health.retryOne': '重新检测',
|
||||
'apiAccess.health.checkAll': '检测全部模型',
|
||||
'apiAccess.health.checkingProgress': '检测中 {checked}/{total}',
|
||||
'apiAccess.health.summary': '已检测 {checked}/{total} · 健康 {healthy} · 失败 {failed}',
|
||||
'apiAccess.health.checking': '检测中…',
|
||||
'apiAccess.health.healthy': '健康',
|
||||
'apiAccess.health.failed': '失败',
|
||||
'apiAccess.health.noModel': '没有可用于健康检测的模型',
|
||||
'apiAccess.health.timeout': '请求已超时',
|
||||
'apiAccess.health.missingDirectKey': '缺少可直接使用的明文 API 密钥,无法测量首字延迟',
|
||||
'apiAccess.health.firstTokenInline': '首字 {latency} ms',
|
||||
'apiAccess.health.firstTokenLatencyResult': '首字延迟:{latency} ms',
|
||||
'common.edit': '编辑',
|
||||
'apiAccess.error.enterBaseUrl': '请先填写 Base URL',
|
||||
'apiAccess.error.noAvailableModels': '未发现可用模型',
|
||||
'apiAccess.error.baseBeforeModels': '请先填写 Base URL,再获取模型',
|
||||
'apiAccess.models.selected': '已选择 {count} 个模型',
|
||||
'apiAccess.models.restricted': '已配置模型限制',
|
||||
'apiAccess.models.autoAll': '保存时默认开放全部模型',
|
||||
'apiAccess.models.upstreamDefault': '使用上游默认模型',
|
||||
'apiAccess.models.hiddenHint': '未勾选的模型不会出现在开放列表',
|
||||
'apiAccess.models.autoHint': '保存接入时会自动获取模型,也可提前取消不需要的项目',
|
||||
'apiAccess.models.allHint': '当前开放上游全部模型',
|
||||
'apiAccess.dialog.edit': '编辑 API 接入',
|
||||
'apiAccess.dialog.add': '新增 API 接入',
|
||||
'apiAccess.field.keysMany': 'API 密钥(每行一个)',
|
||||
'apiAccess.field.key': 'API 密钥',
|
||||
'apiAccess.field.remark': '备注',
|
||||
'apiAccess.remarkPlaceholder': '例如:生产环境、团队主账号',
|
||||
'apiAccess.baseRequiredPlaceholder': '必填,例如 https://api.example.com',
|
||||
'apiAccess.baseOptionalPlaceholder': '可选,留空使用默认地址',
|
||||
'apiAccess.preset.title': 'OpenAI 兼容预设',
|
||||
'apiAccess.preset.description': '已预填 DeepSeek 官方地址,保存时自动发现并开放全部模型',
|
||||
'apiAccess.thinking.builtIn': '内置思考等级',
|
||||
'apiAccess.thinking.builtInDescription': '自动应用到当前开放的全部模型',
|
||||
'apiAccess.thinking.title': '思考等级',
|
||||
'apiAccess.thinking.description': '按上游支持情况自行添加',
|
||||
'apiAccess.thinking.placeholder': '例如 low、medium 或自定义等级',
|
||||
'apiAccess.thinking.add': '添加',
|
||||
'apiAccess.thinking.delete': '删除 {level}',
|
||||
'apiAccess.thinking.deleteAria': '删除思考等级 {level}',
|
||||
'apiAccess.thinking.empty': '未添加时不写入 thinking.levels',
|
||||
'apiAccess.models.title': '模型',
|
||||
'apiAccess.models.description': '勾选表示当前开放,取消勾选后将从模型列表隐藏',
|
||||
'apiAccess.models.fetch': '获取模型',
|
||||
'apiAccess.field.priority': '优先级',
|
||||
'common.optional': '可选',
|
||||
'apiAccess.advanced': '高级设置',
|
||||
'apiAccess.field.prefix': '模型前缀',
|
||||
'apiAccess.prefixPlaceholder': '可选,例如 team-a',
|
||||
'apiAccess.field.headers': '自定义请求头(每行 Name: Value)',
|
||||
'apiAccess.field.excludedModels': '排除模型(每行一个,支持通配符)',
|
||||
'apiAccess.field.testModel': '测试模型',
|
||||
'apiAccess.cloak.mode': 'Claude 伪装模式',
|
||||
'apiAccess.cloak.default': '默认(auto)',
|
||||
'apiAccess.cloak.words': '伪装敏感词(每行一个)',
|
||||
'apiAccess.cloak.strict': '严格模式',
|
||||
'apiAccess.cloak.strictDescription': '仅保留 Claude Code 系统提示',
|
||||
'apiAccess.cloak.enableStrict': '启用严格模式',
|
||||
'apiAccess.cloak.cacheUser': '缓存用户标识',
|
||||
'apiAccess.cloak.cacheUserDescription': '复用伪装后的用户标识',
|
||||
'apiAccess.websocket.description': '为该 Codex 接入启用 WebSocket',
|
||||
'apiAccess.websocket.enable': '启用 WebSocket',
|
||||
'apiAccess.cooling.title': '禁用冷却',
|
||||
'apiAccess.cooling.description': '上游限流后仍允许继续选择该接入',
|
||||
'common.save': '保存',
|
||||
'apiAccess.modelDialog.title': '选择模型',
|
||||
'apiAccess.modelDialog.summary': '找到 {found} 个 · 已选择 {selected} 个',
|
||||
'apiAccess.modelDialog.deselectAll': '取消全选',
|
||||
'apiAccess.modelDialog.selectVisible': '全选当前',
|
||||
'common.clear': '清空',
|
||||
'apiAccess.modelDialog.fetching': '正在获取模型',
|
||||
'apiAccess.modelDialog.fetchFailed': '获取模型失败',
|
||||
'apiAccess.modelDialog.noMatch': '没有匹配的模型',
|
||||
'apiAccess.modelDialog.none': '未发现模型',
|
||||
'apiAccess.modelDialog.tryKeyword': '换个关键词试试',
|
||||
'apiAccess.modelDialog.checkCredentials': '请检查 Base URL 和 API 密钥',
|
||||
'apiAccess.modelDialog.apply': '应用选择({count})',
|
||||
'kernel.install.completed': '{version} 安装完成',
|
||||
'kernel.error.progressListener': '监听下载进度失败: {error}',
|
||||
'kernel.action.start': '启动',
|
||||
'kernel.action.stop': '关闭',
|
||||
'kernel.action.restart': '重启',
|
||||
'kernel.notice.actionSuccess': '内核{action}成功',
|
||||
'kernel.notice.actionFailed': '内核{action}失败:{error}',
|
||||
'kernel.notice.networkRestarted': '网络设置已保存,内核已重启',
|
||||
'kernel.notice.networkRestartFailed': '网络设置已保存,但内核重启失败',
|
||||
'kernel.notice.networkNextStart': '网络设置已保存,下次启动内核时生效',
|
||||
'kernel.notice.networkSaveFailed': '保存网络设置失败:{error}',
|
||||
'kernel.error.port': '端口必须在 1 到 65535 之间',
|
||||
'kernel.error.installTask': '读取安装任务失败: {error}',
|
||||
'kernel.install.installingVersion': '正在安装 {version}',
|
||||
'kernel.phase.preparingDownload': '准备下载',
|
||||
'kernel.phase.downloading': '下载中',
|
||||
'kernel.phase.extracting': '解压中',
|
||||
'kernel.phase.completed': '安装完成',
|
||||
'kernel.install.installingBundled': '正在安装内置内核 {version}',
|
||||
'kernel.phase.preparingBundled': '准备内置内核',
|
||||
'kernel.install.bundledCompleted': '{version} 内置内核安装完成',
|
||||
'kernel.phase.failed': '安装失败',
|
||||
'kernel.phase.cancelled': '已取消',
|
||||
'kernel.install.cancelling': '正在取消下载',
|
||||
'kernel.notice.copyFailed': '复制接入信息失败',
|
||||
'kernel.error.openUpdate': '打开更新页面失败:{error}',
|
||||
'kernel.progress.completed': '已完成',
|
||||
'kernel.progress.extracting': '正在解压文件',
|
||||
'kernel.progress.waiting': '等待进度信息',
|
||||
'kernel.status.running': '运行中',
|
||||
'kernel.status.stopped': '已停止',
|
||||
'kernel.status.notInstalled': '未安装',
|
||||
'common.detectionFailed': '检测失败',
|
||||
'common.detecting': '检测中',
|
||||
'kernel.update.failedRetry': '检查失败,重试',
|
||||
'kernel.update.toVersion': '更新至 {version}',
|
||||
'kernel.update.checking': '检查中',
|
||||
'kernel.update.failed': '检查失败',
|
||||
'kernel.update.notChecked': '未检查',
|
||||
'kernel.update.statusChecking': '正在检查',
|
||||
'kernel.update.notYetChecked': '尚未检查',
|
||||
'kernel.update.latest': '已是最新版本',
|
||||
'kernel.update.available': '请在首页中关闭运行后安装新版本',
|
||||
'appUpdate.eyebrow': '软件升级',
|
||||
'appUpdate.title': '软件版本管理',
|
||||
'appUpdate.current': '当前版本',
|
||||
'appUpdate.latest': '最新版本',
|
||||
'appUpdate.status': '升级状态',
|
||||
'appUpdate.manualFallback': '如果检测失败,请手动到GitHub页面更新',
|
||||
'appUpdate.upToDate': '已是最新版本',
|
||||
'appUpdate.available': '发现新版本 {version}',
|
||||
'appUpdate.check': '检查更新',
|
||||
'appUpdate.checking': '正在检查',
|
||||
'appUpdate.catalog.label': 'Codex 模型目录',
|
||||
'appUpdate.catalog.description': '启动时自动从 GitHub 静默同步',
|
||||
'appUpdate.catalog.update': '同步模型目录',
|
||||
'appUpdate.catalog.updateHint': '从 GitHub 检查并更新 Codex 模型目录',
|
||||
'appUpdate.catalog.updating': '正在同步模型目录',
|
||||
'appUpdate.catalog.updated': '模型目录已从 GitHub 同步',
|
||||
'appUpdate.catalog.unchanged': '模型目录已是最新版本',
|
||||
'appUpdate.installNow': '立即升级',
|
||||
'appUpdate.openRelease': '打开下载页面',
|
||||
'appUpdate.confirmTitle': '安装软件更新',
|
||||
'appUpdate.confirmDescription': '将下载并安装 {version}。应用会自动重启,正在运行的托管内核会短暂停止并恢复。',
|
||||
'appUpdate.progressTitle': '正在升级软件',
|
||||
'appUpdate.cancelDownload': '取消下载',
|
||||
'appUpdate.badgeAvailable': '发现软件新版本 {version}',
|
||||
'appUpdate.phase.idle': '等待操作',
|
||||
'appUpdate.phase.checking': '检查更新',
|
||||
'appUpdate.phase.available': '可升级',
|
||||
'appUpdate.phase.downloading': '正在下载',
|
||||
'appUpdate.phase.verifying': '正在校验',
|
||||
'appUpdate.phase.staging': '准备更新',
|
||||
'appUpdate.phase.waitingForExit': '等待程序退出',
|
||||
'appUpdate.phase.restarting': '正在重启',
|
||||
'appUpdate.phase.completed': '升级完成',
|
||||
'appUpdate.phase.cancelled': '下载已取消',
|
||||
'appUpdate.phase.failed': '升级失败',
|
||||
'kernel.install.inProgress': '正在安装',
|
||||
'kernel.install.githubFailed': 'Github连接失败,请使用“离线安装”',
|
||||
'kernel.install.titleCancelling': '正在取消下载',
|
||||
'kernel.install.titleInstalling': '正在安装内核',
|
||||
'kernel.install.titleCompleted': '安装完成',
|
||||
'kernel.install.titleCancelled': '下载已取消',
|
||||
'kernel.install.titleFailed': '安装失败',
|
||||
'kernel.install.waitingStop': '正在等待下载任务停止',
|
||||
'kernel.install.taskRunning': '安装任务正在进行',
|
||||
'kernel.install.cancellingShort': '正在取消',
|
||||
'kernel.install.cancel': '取消下载',
|
||||
'common.processing': '处理中',
|
||||
'kernel.overview.coreStatus': '内核状态',
|
||||
'kernel.overview.appVersion': '软件版本',
|
||||
'kernel.update.openGithub': '打开 GitHub 最新版本页面',
|
||||
'kernel.overview.coreVersion': '内核版本',
|
||||
'kernel.control.title': '运行控制',
|
||||
'kernel.control.installStatus': '安装状态',
|
||||
'kernel.control.installed': '已安装',
|
||||
'kernel.control.runStatus': '运行状态',
|
||||
'kernel.control.pid': '进程 PID',
|
||||
'kernel.control.notRunning': '未运行',
|
||||
'kernel.control.noPid': '无',
|
||||
'kernel.control.allowLan': '允许局域网',
|
||||
'kernel.control.lanRestartHint': '切换后会自动重启正在运行的内核',
|
||||
'kernel.control.allowLanAria': '允许局域网访问',
|
||||
'kernel.control.port': '端口',
|
||||
'kernel.control.portPlaceholder': '端口号',
|
||||
'kernel.control.portRange': '端口范围 1-65535',
|
||||
'kernel.notice.stopped': '内核已关闭',
|
||||
'kernel.notice.started': '内核已启动',
|
||||
'kernel.notice.restarted': '内核已重启',
|
||||
'kernel.control.refresh': '刷新状态',
|
||||
'kernel.versions.title': '内核版本管理',
|
||||
'kernel.versions.offlineHint': 'GitHub 无法连接时,可使用离线安装使用内置内核。',
|
||||
'kernel.versions.current': '当前版本',
|
||||
'kernel.versions.latest': '最新版本',
|
||||
'kernel.versions.bundled': '内置版本',
|
||||
'kernel.versions.notIncluded': '未包含',
|
||||
'kernel.versions.platform': '运行平台',
|
||||
'kernel.versions.updateStatus': '更新状态',
|
||||
'kernel.versions.check': '检查更新',
|
||||
'kernel.versions.installVersion': '安装 {version}',
|
||||
'kernel.versions.installLatest': '安装最新版',
|
||||
'kernel.versions.reinstallTitle': '重新安装当前版本',
|
||||
'kernel.versions.reinstall': '重新安装',
|
||||
'kernel.versions.noBundled': '当前发行包未包含内置内核',
|
||||
'kernel.versions.offlineInstall': '离线安装',
|
||||
'kernel.access.firstKey': '第一个密钥',
|
||||
'kernel.access.noConfiguredKey': '未设置密钥,当前默认放行所有密钥',
|
||||
'kernel.access.connectable': '可连接',
|
||||
'kernel.access.waiting': '等待内核启动',
|
||||
'kernel.access.localUrl': '本机 URL',
|
||||
'kernel.access.localCopied': '{name} 本机 URL 已复制',
|
||||
'kernel.access.copyLocal': '复制 {name} 本机 URL',
|
||||
'kernel.access.lanUrl': '局域网 URL',
|
||||
'kernel.access.detectingIp': '正在检测局域网 IP',
|
||||
'kernel.access.noIp': '未检测到局域网 IP',
|
||||
'kernel.access.lanCopied': '{name} 局域网 URL 已复制',
|
||||
'kernel.access.copyLan': '复制 {name} 局域网 URL',
|
||||
'kernel.dialog.install': '内核安装',
|
||||
'kernel.dialog.phase': '当前阶段',
|
||||
'kernel.dialog.unknownProgress': '进度未知',
|
||||
'kernel.access.openaiDescription': 'OpenAI兼容格式',
|
||||
'kernel.access.claudeDescription': 'Anthropic兼容格式',
|
||||
'kernel.access.geminiDescription': 'Gemini兼容格式',
|
||||
'authFiles.unnamed': '未命名认证文件',
|
||||
'management.notConfigured': '未配置',
|
||||
'management.error.upstream': '上游请求失败',
|
||||
'management.error.upstreamHttp': '上游返回 HTTP {status}',
|
||||
'model.error.invalidBaseUrl': 'Base URL 不是有效地址,请输入类似 https://api.example.com 的 URL',
|
||||
'model.error.unsupportedBaseUrl': 'Base URL 只支持 http:// 或 https:// 地址',
|
||||
'model.error.baseUrlRequired': '请先输入 Base URL',
|
||||
'model.error.noResponse': '模型接口无响应',
|
||||
'quota.service.relative.daysHours': '{days} 天 {hours} 小时后',
|
||||
'quota.service.relative.days': '{days} 天后',
|
||||
'quota.service.relative.hoursMinutes': '{hours} 小时 {minutes} 分钟后',
|
||||
'quota.service.relative.hours': '{hours} 小时后',
|
||||
'quota.service.relative.minutes': '{minutes} 分钟后',
|
||||
'quota.service.duration.unknown': '未知周期',
|
||||
'quota.service.duration.days': '{count} 天',
|
||||
'quota.service.duration.hours': '{count} 小时',
|
||||
'quota.service.duration.minutes': '{count} 分钟',
|
||||
'quota.service.duration.seconds': '{count} 秒',
|
||||
'quota.service.limit.fiveHours': '5 小时限额',
|
||||
'quota.service.limit.week': '周限额',
|
||||
'quota.service.limit.month': '月限额',
|
||||
'quota.service.limit.duration': '{duration}限额',
|
||||
'quota.service.codeReview': '代码审查',
|
||||
'quota.service.additional': '附加 {index}',
|
||||
'quota.service.window.fiveHour': '5 小时窗口',
|
||||
'quota.service.window.sevenDay': '7 天窗口',
|
||||
'quota.service.window.sevenDayOAuth': '7 天 OAuth 应用窗口',
|
||||
'quota.service.window.sevenDayOpus': '7 天 Opus 窗口',
|
||||
'quota.service.window.sevenDaySonnet': '7 天 Sonnet 窗口',
|
||||
'quota.service.window.sevenDayCowork': '7 天 Cowork 窗口',
|
||||
'quota.service.window.duration': '{duration}窗口',
|
||||
'quota.service.extraUsage': '额外用量',
|
||||
'quota.service.usedOf': '已用 {used} / {limit}',
|
||||
'quota.service.weekly': '每周额度',
|
||||
'quota.service.limit.numbered': '限制 {index}',
|
||||
'quota.service.product.numbered': '产品 {index}',
|
||||
'quota.service.monthlyIncluded': '月度包含额度',
|
||||
'quota.service.onDemand': '按量付费额度',
|
||||
'quota.service.quota': '配额',
|
||||
'quota.service.quota.numbered': '配额 {index}',
|
||||
'quota.service.error.missingAuthIndex': '缺少 auth-index,无法查询配额',
|
||||
'quota.service.error.missingProject': '缺少 Antigravity project ID',
|
||||
'quota.service.error.antigravityEmpty': 'Antigravity 返回成功,但没有可识别的配额分组',
|
||||
'quota.service.error.upstreamEmpty': '上游返回了空配额数据',
|
||||
'quota.service.error.noResponse': '配额接口无响应',
|
||||
'quota.service.error.missingResetAuthIndex': '缺少 auth-index,无法查询主动重置次数',
|
||||
'quota.service.error.unsupportedProvider': '暂不支持该提供商的配额查询',
|
||||
'quota.service.error.unrecognized': '上游返回了配额数据,但无法识别',
|
||||
'quota.service.error.codexResetOnly': '只有 Codex 凭据支持使用重置额度',
|
||||
'quota.service.error.missingConsumeAuthIndex': '缺少 auth-index,无法使用重置额度',
|
||||
} as const;
|
||||
|
||||
export type MessageKey = keyof typeof zhCN;
|
||||
+4
-1860
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user