Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions .github/workflows/pr-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ jobs:

- name: Build frontend
working-directory: apps/codex-plus-manager
run: npm run vite:build
shell: bash
run: |
./node_modules/.bin/vite build
test -d dist

- name: Rust tests
run: cargo test --workspace
Expand Down Expand Up @@ -116,7 +119,10 @@ jobs:

- name: Build frontend
working-directory: apps/codex-plus-manager
run: npm run vite:build
shell: bash
run: |
./node_modules/.bin/vite build
test -d dist

- name: Build release binaries
run: cargo build --release --target ${{ matrix.target }}
Expand Down
3 changes: 3 additions & 0 deletions apps/codex-plus-launcher/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ async fn activate_existing_codex_app(options: &LaunchOptions) -> anyhow::Result<
hooks.start_helper(options.helper_port).await?;
}
let process_ids = codex_plus_core::watcher::find_codex_processes();
#[cfg(not(windows))]
let activated = false;
#[cfg(windows)]
let mut activated = false;
#[cfg(windows)]
{
Expand Down
54 changes: 40 additions & 14 deletions apps/codex-plus-manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,7 @@ const defaultSettings: BackendSettings = {
codexAppUpstreamWorktreeCreate: true,
codexAppNativeMenuPlacement: true,
codexAppNativeMenuLocalization: true,
codexAppServiceTierControls: false,
codexAppServiceTierControls: true,
codexAppImageOverlayEnabled: false,
codexAppImageOverlayPath: "",
codexAppImageOverlayOpacity: 35,
Expand Down Expand Up @@ -1544,6 +1544,16 @@ export function App() {
targetRelayName: targetBeforeSnapshot.name,
targetRelayMode: targetBeforeSnapshot.relayMode,
});
const switchSettingsWithSnapshot = await snapshotActiveRelayFilesBeforeSwitch(switchSettings, previousActiveRelayId);
if (!switchSettingsWithSnapshot) {
logDiagnostic("switchRelayProfile.snapshot_failed", {
currentRelayId: previousActiveRelayId,
targetRelayId: switchSettings.activeRelayId,
});
return;
}
switchSettings = switchSettingsWithSnapshot;

const selectedBeforeSave = activeRelayProfile(switchSettings);
const validationError = relayProfileSwitchValidation(selectedBeforeSave);
if (validationError) {
Expand All @@ -1555,8 +1565,7 @@ export function App() {
showNotice("供应商配置可能不正确", validationError, "failed");
return;
}
switchSettings = await snapshotActiveRelayFilesBeforeSwitch(switchSettings, previousActiveRelayId);
const selectedAfterSave = activeRelayProfile(switchSettings);
const selectedAfterSave = selectedBeforeSave;
const command = relayProfileSwitchCommand(selectedAfterSave);

logDiagnostic("switchRelayProfile.apply_start", {
Expand Down Expand Up @@ -1618,21 +1627,38 @@ export function App() {
const snapshotActiveRelayFilesBeforeSwitch = async (
next: BackendSettings,
previousActiveRelayId: string,
): Promise<BackendSettings> => {
const profileId = previousActiveRelayId.trim();
if (!profileId) return next;
): Promise<BackendSettings | null> => {
const current = activeRelayProfile({ ...settingsForm, activeRelayId: previousActiveRelayId });
const selected = activeRelayProfile(next);
if (current.id === selected.id) return next;

logDiagnostic("snapshotActiveRelayFilesBeforeSwitch.start", {
currentRelayId: current.id,
currentRelayName: current.name,
selectedRelayId: selected.id,
selectedRelayName: selected.name,
});
const result = await run(() =>
call<SettingsBackfillResult>("backfill_relay_profile_from_live", {
request: { settings: next, profileId },
request: { settings: next, profileId: current.id },
}),
);
if (!result) return next;
const normalized = normalizeSettings(result.settings);
if (!isSuccessStatus(result.status)) {
showNotice("供应商切换", result.message, result.status);
return next;
if (!result || !isSuccessStatus(result.status)) {
logDiagnostic("snapshotActiveRelayFilesBeforeSwitch.failed", {
currentRelayId: current.id,
selectedRelayId: selected.id,
status: result?.status,
message: result?.message,
});
showNotice("供应商切换", result?.message ?? "读取当前配置文件失败,已停止切换以避免覆盖用户改动。", result?.status ?? "failed");
return null;
}
return normalized;

logDiagnostic("snapshotActiveRelayFilesBeforeSwitch.ok", {
currentRelayId: current.id,
selectedRelayId: selected.id,
});
return syncLegacyRelayFields(normalizeSettings(result.settings));
};

const copyText = async (text: string, message: string) => {
Expand Down Expand Up @@ -2733,7 +2759,7 @@ function EnhanceScreen({
<FeatureToggle title="特殊插件强制安装" detail="解除 App unavailable / 应用不可用导致的前端安装禁用。" checked={form.codexAppForcePluginInstall} disabled={!masterEnabled || !patchMode} onChange={(value) => setEnhanceFlag("codexAppForcePluginInstall", value)} />
<FeatureToggle title="插件列表全量展示" detail="进入插件页后自动连续展开“更多”,尽量一次显示完整插件列表。" checked={form.codexAppPluginAutoExpand} disabled={!masterEnabled} onChange={(value) => setEnhanceFlag("codexAppPluginAutoExpand", value)} />
<FeatureToggle title="模型白名单解锁" detail="从环境变量和 config.toml 的 /v1/models 拉取模型并补进模型列表。" checked={form.codexAppModelWhitelistUnlock} disabled={!masterEnabled} onChange={(value) => setEnhanceFlag("codexAppModelWhitelistUnlock", value)} />
<FeatureToggle title="Fast 按钮" detail="显示服务模式切换按钮;Fast 仅支持 gpt-5.4 / gpt-5.5,其他模型按 Standard 发送。" checked={form.codexAppServiceTierControls} disabled={!masterEnabled} onChange={(value) => setEnhanceFlag("codexAppServiceTierControls", value)} />
<FeatureToggle title="系统 Fast 开关" detail="是否开启系统 Fast 开关:已默认开启,API Key 登录复用 Codex 原生速度选项与标识;具体 Fast / Standard 在 Codex 界面选择。" checked={true} disabled onChange={() => {}} />
<FeatureToggle title="会话删除" detail="在会话列表悬停显示删除按钮,并支持撤销。" checked={form.codexAppSessionDelete} disabled={!masterEnabled} onChange={(value) => setEnhanceFlag("codexAppSessionDelete", value)} />
<FeatureToggle title="Markdown 导出" detail="在会话列表显示导出按钮,导出带时间戳的 Markdown。" checked={form.codexAppMarkdownExport} disabled={!masterEnabled} onChange={(value) => setEnhanceFlag("codexAppMarkdownExport", value)} />
<FeatureToggle title="粘贴修复" detail="从 Word 等富文本粘贴到 Codex composer 时只保留纯文本,避免被识别为图片/文件附件。需重启 Codex 才生效。" checked={form.codexAppPasteFix} disabled={!masterEnabled} onChange={(value) => setEnhanceFlag("codexAppPasteFix", value)} />
Expand Down
13 changes: 6 additions & 7 deletions assets/inject/renderer-inject.js
Original file line number Diff line number Diff line change
Expand Up @@ -915,7 +915,7 @@
}

function defaultCodexPlusSettings() {
return { pluginMarketplaceUnlock: true, forcePluginInstall: true, pluginAutoExpand: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, pasteFix: false, projectMove: true, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, serviceTierControls: false };
return { pluginMarketplaceUnlock: true, forcePluginInstall: true, pluginAutoExpand: true, modelWhitelistUnlock: true, sessionDelete: true, markdownExport: true, pasteFix: false, projectMove: true, threadIdBadge: false, conversationView: false, conversationViewMaxWidth: conversationViewDefaultWidth, threadScrollRestore: true, zedRemoteOpen: true, upstreamWorktreeCreate: true, nativeMenuPlacement: true, serviceTierControls: true };
}

const codexPlusBackendSettingMap = {
Expand Down Expand Up @@ -974,13 +974,15 @@
settings.pluginMarketplaceUnlock = false;
settings.forcePluginInstall = false;
}
settings.serviceTierControls = true;
return settings;
} catch {
const settings = { ...defaultCodexPlusSettings(), ...backendCodexPlusSettings() };
if (relayPatchDisabled) {
settings.pluginMarketplaceUnlock = false;
settings.forcePluginInstall = false;
}
settings.serviceTierControls = true;
return settings;
}
}
Expand Down Expand Up @@ -1736,10 +1738,7 @@
function applyCodexServiceTierRequestOverride(method, params, threadIdHint = "") {
const override = codexServiceTierOverrideForRequest(method, params, threadIdHint);
if (!override) return params;
const nextParams = { ...(params || {}), serviceTier: override.serviceTier };
if (Object.prototype.hasOwnProperty.call(nextParams, "service_tier") || override.fastBlocked) {
nextParams.service_tier = override.serviceTier;
}
const nextParams = { ...(params || {}), serviceTier: override.serviceTier, service_tier: override.serviceTier };
sendCodexPlusDiagnostic("service_tier_request_override_applied", {
method,
threadId: override.threadId || "",
Expand Down Expand Up @@ -2163,8 +2162,8 @@
<button type="button" class="codex-plus-toggle" data-codex-plus-setting="modelWhitelistUnlock"><span></span></button>
</div>
<div class="codex-plus-row">
<div><div class="codex-plus-row-title">Fast 按钮</div><div class="codex-plus-row-description">显示服务模式切换按钮;Fast 仅支持 ${codexServiceTierFastModelListLabel()},其他模型按 Standard 发送。</div></div>
<button type="button" class="codex-plus-toggle" data-codex-plus-setting="serviceTierControls"><span></span></button>
<div><div class="codex-plus-row-title">系统 Fast 开关</div><div class="codex-plus-row-description">是否开启系统 Fast 开关:已默认开启,API Key 登录复用 Codex 原生速度选项与标识;具体 Fast / Standard 在 Codex 界面选择,Fast 仅支持 ${codexServiceTierFastModelListLabel()}。</div></div>
<button type="button" class="codex-plus-toggle" data-codex-plus-setting="serviceTierControls" disabled><span></span></button>
</div>
<div class="codex-plus-row" data-codex-service-tier-controls="true">
<div><div class="codex-plus-row-title">服务模式</div><div class="codex-plus-row-description">继承使用 config.toml 的 service tier;全局模式覆盖全部 thread;自定义允许按 thread 覆盖。</div></div>
Expand Down
140 changes: 136 additions & 4 deletions crates/codex-plus-core/src/launcher.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::env;
use std::ffi::OsString;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::Stdio;
Expand Down Expand Up @@ -481,6 +483,81 @@ fn helper_bind_host() -> String {
.unwrap_or_else(|| "127.0.0.1".to_string())
}

struct ServiceTierPreloadEnv {
node_options: String,
}

fn prepare_service_tier_preload(
settings: &BackendSettings,
) -> anyhow::Result<Option<ServiceTierPreloadEnv>> {
if settings.enhancements_enabled && settings.codex_app_service_tier_controls {
let preload_path = crate::service_tier_preload::ensure_service_tier_preload()
.context("failed to prepare service tier preload")?;
let node_options = crate::service_tier_preload::node_options_with_service_tier_preload(
env::var("NODE_OPTIONS").ok().as_deref(),
&preload_path.to_string_lossy(),
);
let _ = crate::diagnostic_log::append_diagnostic_log(
"launcher.service_tier_preload_enabled",
serde_json::json!({
"preload_path": preload_path.to_string_lossy(),
"node_options": node_options,
}),
);
Ok(Some(ServiceTierPreloadEnv { node_options }))
} else {
let _ = crate::diagnostic_log::append_diagnostic_log(
"launcher.service_tier_preload_disabled",
serde_json::json!({
"enhancements_enabled": settings.enhancements_enabled,
"service_tier_controls": settings.codex_app_service_tier_controls,
}),
);
Ok(None)
}
}

struct ScopedEnvVar {
key: &'static str,
previous: Option<OsString>,
}

impl ScopedEnvVar {
fn set(key: &'static str, value: &str) -> Self {
let previous = env::var_os(key);
unsafe {
env::set_var(key, value);
}
Self { key, previous }
}
}

impl Drop for ScopedEnvVar {
fn drop(&mut self) {
unsafe {
if let Some(previous) = &self.previous {
env::set_var(self.key, previous);
} else {
env::remove_var(self.key);
}
}
}
}

fn apply_service_tier_preload_env(command: &mut Command, preload: &ServiceTierPreloadEnv) {
command.env("NODE_OPTIONS", &preload.node_options);
if env::var_os("HOME").is_none()
&& let Some(home) = service_tier_preload_home_dir()
{
command.env("HOME", home);
}
}

fn service_tier_preload_home_dir() -> Option<PathBuf> {
let app_state_dir = crate::paths::default_app_state_dir();
app_state_dir.parent().map(Path::to_path_buf)
}

#[async_trait(?Send)]
impl LaunchHooks for DefaultLaunchHooks {
fn resolve_app_dir(
Expand Down Expand Up @@ -640,6 +717,7 @@ impl LaunchHooks for DefaultLaunchHooks {
let native_menu_localization_enabled = settings.codex_app_native_menu_localization;
let native_menu_inspector_port =
native_menu_localization_enabled.then(|| select_native_menu_inspector_port(debug_port));
let service_tier_preload = prepare_service_tier_preload(settings)?;
if cfg!(windows) {
let activation = if let Some(inspector_port) = native_menu_inspector_port {
build_packaged_activation_with_native_menu_inspector(
Expand All @@ -652,6 +730,49 @@ impl LaunchHooks for DefaultLaunchHooks {
build_packaged_activation(app_dir, debug_port, extra_args)
};
if let Some(activation) = activation {
if let Some(preload) = &service_tier_preload {
let command = if let Some(inspector_port) = native_menu_inspector_port {
build_codex_command_with_native_menu_inspector(
app_dir,
debug_port,
inspector_port,
extra_args,
)
} else {
build_codex_command(app_dir, debug_port, extra_args)
};
let executable = command
.first()
.ok_or_else(|| anyhow::anyhow!("Codex command is empty"))?;
let mut child_command = Command::new(executable);
child_command
.args(&command[1..])
.stdout(Stdio::null())
.stderr(Stdio::null());
apply_service_tier_preload_env(&mut child_command, preload);
#[cfg(windows)]
child_command.creation_flags(crate::windows_integration::CREATE_NO_WINDOW);
let child = child_command.spawn().with_context(|| {
format!("failed to launch packaged Codex executable {executable}")
})?;
*self.child.lock().await = Some(child);
let _ = crate::diagnostic_log::append_diagnostic_log(
"launcher.service_tier_preload_direct_packaged_launch",
serde_json::json!({
"app_dir": app_dir.to_string_lossy(),
"debug_port": debug_port,
"command": &command,
}),
);
if let Some(inspector_port) = native_menu_inspector_port {
start_native_menu_localizer(inspector_port);
}
return Ok(CodexLaunch::Process {
command,
wait_strategy: ProcessWaitStrategy::TrackedChild,
macos_cleanup_policy: None,
});
}
let CodexLaunch::PackagedActivation {
app_user_model_id,
arguments,
Expand All @@ -660,6 +781,9 @@ impl LaunchHooks for DefaultLaunchHooks {
else {
unreachable!();
};
let _node_options_guard = service_tier_preload
.as_ref()
.map(|preload| ScopedEnvVar::set("NODE_OPTIONS", &preload.node_options));
let process_id = activate_packaged_app(app_user_model_id, arguments).await?;
if let Some(inspector_port) = native_menu_inspector_port {
start_native_menu_localizer(inspector_port);
Expand Down Expand Up @@ -697,11 +821,16 @@ impl LaunchHooks for DefaultLaunchHooks {
};
let executable = command
.first()
.ok_or_else(|| anyhow::anyhow!("macOS open command is empty"))?;
let child = Command::new(executable)
.ok_or_else(|| anyhow::anyhow!("macOS Codex command is empty"))?;
let mut child_command = Command::new(executable);
child_command
.args(&command[1..])
.stdout(Stdio::null())
.stderr(Stdio::null())
.stderr(Stdio::null());
if let Some(preload) = &service_tier_preload {
apply_service_tier_preload_env(&mut child_command, preload);
}
let child = child_command
.spawn()
.context("failed to launch macOS Codex app")?;
*self.child.lock().await = Some(child);
Expand All @@ -710,7 +839,7 @@ impl LaunchHooks for DefaultLaunchHooks {
}
return Ok(CodexLaunch::Process {
command,
wait_strategy: ProcessWaitStrategy::ExternalWaitCommand,
wait_strategy: ProcessWaitStrategy::TrackedChild,
macos_cleanup_policy: Some(cleanup_policy),
});
}
Expand All @@ -733,6 +862,9 @@ impl LaunchHooks for DefaultLaunchHooks {
.args(&command[1..])
.stdout(Stdio::null())
.stderr(Stdio::null());
if let Some(preload) = &service_tier_preload {
apply_service_tier_preload_env(&mut child_command, preload);
}
#[cfg(windows)]
child_command.creation_flags(crate::windows_integration::CREATE_NO_WINDOW);
let child = child_command
Expand Down
1 change: 1 addition & 0 deletions crates/codex-plus-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub mod relay_rotation;
pub mod relay_switch;
pub mod routes;
pub mod script_market;
pub mod service_tier_preload;
pub mod settings;
pub mod status;
pub mod update;
Expand Down
Loading