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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# 更新日志

- 会话侧边栏新增“用当前 API 继续”:通过 Codex app-server 原生 `thread/fork` 从本地历史创建独立新会话,允许切换到纯 API 后继续原项目上下文,同时保留原账号会话不变。

## 1.2.22 - 2026-06-28

- 修复启动 Codex 时会自动应用当前供应商配置的问题;现在只有手动点击“使用/切换供应商”才会切换供应商配置。
Expand Down
89 changes: 84 additions & 5 deletions apps/codex-plus-launcher/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,7 @@ async fn main() -> Result<()> {
Ok(())
}

async fn launcher_main(
args: Vec<String>,
helper_only: bool,
options: LaunchOptions,
) -> Result<()> {
async fn launcher_main(args: Vec<String>, helper_only: bool, options: LaunchOptions) -> Result<()> {
if helper_only {
let hooks = LauncherHooks::default();
hooks.start_helper(options.helper_port).await?;
Expand Down Expand Up @@ -686,6 +682,89 @@ impl BridgeDataService for LauncherDataService {
.await
.map_err(|error| anyhow::anyhow!("Remote Control session recovery task failed: {error}"))?
}

async fn fork_session(
&self,
thread_id: String,
model: Option<String>,
model_provider: Option<String>,
source_title: Option<String>,
) -> anyhow::Result<Value> {
if thread_id.trim().is_empty() {
anyhow::bail!("缺少要继续的本地会话 ID");
}
let settings = codex_plus_core::settings::SettingsStore::default()
.load()
.unwrap_or_default();
let configured_cli = settings.weixin_connect_codex_path.trim();
let npm_cli = std::env::var_os("APPDATA")
.map(PathBuf::from)
.map(|dir| {
dir.join("npm")
.join(if cfg!(windows) { "codex.cmd" } else { "codex" })
})
.filter(|path| path.is_file());
let bundled_cli = codex_plus_core::app_paths::resolve_codex_app_dir_with_saved(
None,
Some(settings.codex_app_path.as_str()),
)
.map(|dir| {
dir.join("resources")
.join(if cfg!(windows) { "codex.exe" } else { "codex" })
})
.filter(|path| path.is_file());
let executable = if !configured_cli.is_empty() {
configured_cli.to_string()
} else if let Some(path) = npm_cli {
path.to_string_lossy().into_owned()
} else if let Some(path) = bundled_cli {
path.to_string_lossy().into_owned()
} else {
"codex".to_string()
};
let work_dir = std::env::current_dir()
.unwrap_or_else(|_| codex_plus_core::codex_sqlite::default_codex_home_dir());
let mut server = codex_plus_core::connect::app_server::CodexAppServer::start(
codex_plus_core::connect::app_server::AppServerConfig {
executable,
work_dir,
model: model.clone().unwrap_or_default(),
sandbox: "read-only".to_string(),
},
)
.await?;
let result = server
.fork_thread(&thread_id, model.as_deref(), model_provider.as_deref())
.await;
let next_thread_id = result?;
let source_title = source_title.unwrap_or_default();
let trimmed_title = source_title.trim();
let short_id = next_thread_id.chars().take(8).collect::<String>();
let base_title = if trimmed_title.is_empty() {
"新会话"
} else {
trimmed_title.strip_prefix("new-").unwrap_or(trimmed_title)
};
let new_title = format!("new-{base_title}-{short_id}")
.chars()
.take(160)
.collect::<String>();
let rename_result = server.set_thread_name(&next_thread_id, &new_title).await;
server.close().await;
rename_result?;
let visible_thread_id = next_thread_id.clone();
let codex_home = codex_plus_core::codex_sqlite::default_codex_home_dir();
tokio::task::spawn_blocking(move || {
codex_plus_core::codex_sqlite::mark_thread_visible(&codex_home, &visible_thread_id)
})
.await
.map_err(|error| anyhow::anyhow!("更新新会话可见状态失败: {error}"))??;
Ok(json!({
"status": "forked",
"thread": { "id": next_thread_id, "name": new_title },
"sourceThreadId": thread_id
}))
}
}

impl LauncherDataService {
Expand Down
15 changes: 13 additions & 2 deletions apps/codex-plus-manager/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,17 @@ function providerSyncTargetLabel(target: ProviderSyncTargetOption): string {
return [...labels, ...current].join(" / ") || t("发现");
}

function providerSyncActionText(targetProvider: string, active: boolean): string {
if (active) return t("正在修复…");
return targetProvider === "openai" ? t("显示 API 登录历史") : t("修复历史会话");
}

function providerSyncHelpText(targetProvider: string): string {
return targetProvider === "openai"
? t("将 JOJO Code/custom 等 API 登录创建的本地历史归到官方登录可见。")
: t("启动 Codex 前整理旧对话的归属标记。");
}

function syncMarketInstalledState(current: ScriptMarketResult | null, userScripts: UserScriptInventory): ScriptMarketResult | null {
if (!current) return current;
const installed = new Map(
Expand Down Expand Up @@ -5764,7 +5775,7 @@ function SessionsScreen({
/>
<span>
<strong>{t("启动前自动修复历史会话")}</strong>
<small>{t("启动 Codex 前整理旧对话的归属标记。")}</small>
<small>{providerSyncHelpText(selectedProviderSyncTarget)}</small>
</span>
<ToggleVisual />
</label>
Expand All @@ -5776,7 +5787,7 @@ function SessionsScreen({
</Button>
<Button disabled={providerSyncProgress.active} onClick={() => void actions.syncProvidersNow()} variant="outline">
<Wrench className="h-4 w-4" />
{providerSyncProgress.active ? t("正在修复…") : t("修复历史会话")}
{providerSyncActionText(selectedProviderSyncTarget, providerSyncProgress.active)}
</Button>
<Button onClick={() => void actions.saveSettings()}>
<Save className="h-4 w-4" />
Expand Down
13 changes: 13 additions & 0 deletions apps/codex-plus-manager/src/renderer-inject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,19 @@ describe("renderer injection header compatibility", () => {
assert.match(renderer, /codexPlusIsNodeTestHarness/);
});

it("offers a non-destructive local-history fork for the current API", async () => {
const renderer = await readFile(new URL("../../../assets/inject/renderer-inject.js", import.meta.url), "utf8");

assert.match(renderer, /postJson\("\/fork-session",\s*\{/);
assert.match(renderer, /sourceTitle: String\(ref\?\.title \|\| ""\)\.trim\(\)/);
assert.match(renderer, /result\?\.status === "failed"/);
assert.match(renderer, /正在刷新侧边栏/);
assert.match(renderer, /\.\.\.\(modelProvider \? \{ modelProvider \} : \{\}\)/);
assert.match(renderer, /\.\.\.\(model \? \{ model \} : \{\}\)/);
assert.match(renderer, /用当前 API 继续/);
assert.match(renderer, /原历史会话保持不变/);
});

it("initializes renderer styles without unresolved template identifiers", async () => {
const renderer = await readFile(new URL("../../../assets/inject/renderer-inject.js", import.meta.url), "utf8");

Expand Down
82 changes: 75 additions & 7 deletions assets/inject/renderer-inject.js
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@
const codexPlusMenuFloatingClass = "codex-plus-menu-floating";
const codexDeleteVersion = "7";
const codexExportVersion = "1";
const codexActionGroupVersion = "5";
const codexActionGroupVersion = "6";
const codexArchiveRowActionsVersion = "1";
const codexArchiveDeleteAllVersion = "2";
const codexConversationViewVersion = "1";
Expand Down Expand Up @@ -7702,6 +7702,74 @@
showToast(result.message || "导出失败", null);
}

function forkedThreadId(result) {
const candidates = [
result?.thread?.id,
result?.threadId,
result?.id,
result?.data?.thread?.id,
result?.data?.threadId,
result?.data?.id,
];
return candidates.map((value) => validThreadScrollSessionKey(value)).find(Boolean) || "";
}

function openForkedThread(row, sourceThreadId, nextThreadId) {
const href = rowHref(row);
if (!href) return false;
try {
const url = new URL(href, window.location.href);
const encodedSource = encodeURIComponent(sourceThreadId);
const encodedFork = encodeURIComponent(nextThreadId);
const nextHref = url.href.includes(encodedSource)
? url.href.replace(encodedSource, encodedFork)
: url.href.replace(sourceThreadId, nextThreadId);
if (nextHref === url.href) return false;
window.location.assign(nextHref);
return true;
} catch {
return false;
}
}

async function continueSessionWithCurrentApi(row, ref) {
const threadId = validThreadScrollSessionKey(ref?.session_id);
if (!threadId) {
showToast("无法继续:未找到有效的本地会话 ID", null);
return;
}
showToast("正在从本地历史创建可继续的新会话…", null);
try {
await loadCodexModelCatalog();
const modelProvider = codexRemoteSessionTargetProvider();
const model = String(codexModelCatalog?.model || codexModelCatalog?.default_model || "").trim();
const result = await postJson("/fork-session", {
threadId,
sourceTitle: String(ref?.title || "").trim(),
...(modelProvider ? { modelProvider } : {}),
...(model ? { model } : {}),
});
if (result?.status === "failed") {
throw new Error(result.message || "Codex++ 后端创建新会话失败");
}
const nextThreadId = forkedThreadId(result);
if (!nextThreadId) throw new Error("Codex app-server 未返回新会话 ID");
await refreshRecentConversationsForHost();
showToast("已创建新会话,原历史会话保持不变", null);
if (!openForkedThread(row, threadId, nextThreadId)) {
showToast(`已创建新会话:${nextThreadId.slice(0, 8)},正在刷新侧边栏`, null);
window.setTimeout(() => window.location.reload(), 800);
}
} catch (error) {
sendCodexPlusDiagnostic("session_fork_failed", {
threadId,
errorName: error?.name || "",
errorMessage: error?.message || String(error),
});
showToast(`无法从本地历史继续:${error?.message || String(error)}`, null);
}
}

function installDeleteButtonEventDelegation() {
document.removeEventListener("click", window.__codexSessionDeleteDocumentDeleteHandler, true);
const handler = (event) => {
Expand Down Expand Up @@ -7946,16 +8014,11 @@

function attachButton(row) {
const settings = codexPlusSettings();
if (!settings.sessionDelete && !settings.markdownExport) {
removeActionGroups(row);
row.dataset.codexDeleteRow = "false";
return;
}
const existingGroup = actionGroupFromRow(row);
const existingDeleteButton = existingGroup?.querySelector(`.${buttonClass}`);
const existingMoreButton = existingGroup?.querySelector(`.${moreButtonClass}`);
const existingExportButton = existingGroup?.querySelector(`.${exportButtonClass}`);
const needsMoreMenu = settings.markdownExport;
const needsMoreMenu = true;
const hasUnexpectedDelete = !settings.sessionDelete && !!existingDeleteButton;
const hasUnexpectedMore = !needsMoreMenu && !!existingMoreButton;
const hasUnexpectedExport = !!existingExportButton;
Expand Down Expand Up @@ -7985,6 +8048,11 @@
moreMenu.className = moreMenuClass;
moreMenu.setAttribute("role", "menu");
moreMenu.hidden = true;
moreMenu.appendChild(createSessionMoreMenuItem("用当前 API 继续", "↪", (event) => {
stopActionButtonEvent(row, moreButton, event);
closeSessionMoreMenus();
continueSessionWithCurrentApi(row, ref);
}));
if (settings.markdownExport) {
moreMenu.appendChild(createSessionMoreMenuItem("导出", "⇩", (event) => {
stopActionButtonEvent(row, moreButton, event);
Expand Down
26 changes: 26 additions & 0 deletions crates/codex-plus-core/src/codex_sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,32 @@ pub fn codex_session_db_paths_from_home(home: &Path) -> Vec<PathBuf> {
codex_session_db_paths_in_home(&sqlite_home)
}

pub fn mark_thread_visible(home: &Path, thread_id: &str) -> anyhow::Result<usize> {
let thread_id = thread_id.trim();
if thread_id.is_empty() {
anyhow::bail!("缺少要显示的会话 ID");
}
let mut updated = 0usize;
for path in codex_session_db_paths_from_home(home) {
if !path.is_file() || !sqlite_has_table(&path, "threads") {
continue;
}
let db = Connection::open(&path)?;
let has_user_event = db
.prepare("PRAGMA table_info(threads)")?
.query_map([], |row| row.get::<_, String>(1))?
.filter_map(Result::ok)
.any(|column| column == "has_user_event");
if has_user_event {
updated += db.execute(
"UPDATE threads SET has_user_event = 1 WHERE id = ?1 AND COALESCE(has_user_event, 0) <> 1",
[thread_id],
)?;
}
}
Ok(updated)
}

fn codex_session_db_paths_in_home(home: &Path) -> Vec<PathBuf> {
let mut paths = codex_sqlite_dir_session_dbs(home);
let legacy = legacy_state_db_path(home);
Expand Down
61 changes: 59 additions & 2 deletions crates/codex-plus-core/src/connect/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,24 @@ impl CodexAppServer {
} else {
config.executable.trim()
};
let mut command = Command::new(executable);
let is_windows_script = cfg!(windows)
&& matches!(
std::path::Path::new(executable)
.extension()
.and_then(|value| value.to_str()),
Some(extension) if extension.eq_ignore_ascii_case("cmd")
|| extension.eq_ignore_ascii_case("bat")
);
let mut command = if is_windows_script {
let mut command = Command::new("cmd.exe");
command.args(["/d", "/s", "/c", executable, "app-server"]);
command
} else {
let mut command = Command::new(executable);
command.arg("app-server");
command
};
command
.arg("app-server")
.current_dir(&config.work_dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
Expand Down Expand Up @@ -107,6 +122,48 @@ impl CodexAppServer {
.with_context(|| format!("Codex app-server {method} 未返回 thread id"))
}

pub async fn fork_thread(
&mut self,
thread_id: &str,
model: Option<&str>,
model_provider: Option<&str>,
) -> anyhow::Result<String> {
let thread_id = thread_id.trim();
if thread_id.is_empty() {
bail!("缺少要继续的本地会话 ID");
}
let mut params = json!({
"threadId": thread_id,
"persistExtendedHistory": true
});
if let Some(model) = model.map(str::trim).filter(|value| !value.is_empty()) {
params["model"] = Value::String(model.to_string());
}
if let Some(provider) = model_provider
.map(str::trim)
.filter(|value| !value.is_empty())
{
params["modelProvider"] = Value::String(provider.to_string());
}
let result = self.request("thread/fork", params, REQUEST_TIMEOUT).await?;
extract_thread_id(&result).context("Codex app-server thread/fork 未返回 thread id")
}

pub async fn set_thread_name(&mut self, thread_id: &str, name: &str) -> anyhow::Result<()> {
let thread_id = thread_id.trim();
let name = name.trim();
if thread_id.is_empty() || name.is_empty() {
bail!("重命名会话需要有效的会话 ID 和名称");
}
self.request(
"thread/name/set",
json!({ "threadId": thread_id, "name": name }),
REQUEST_TIMEOUT,
)
.await?;
Ok(())
}

pub async fn run_turn(
&mut self,
thread_id: &str,
Expand Down
Loading