From d35dbe193ac4b271cd12ac3d5464e02db90edd94 Mon Sep 17 00:00:00 2001 From: Ghibli1024 Date: Sun, 28 Jun 2026 18:51:30 +0800 Subject: [PATCH 1/4] feat: add Stepwise suggestions panel --- apps/codex-plus-launcher/src/main.rs | 30 + apps/codex-plus-manager/src/App.tsx | 108 + apps/codex-plus-manager/src/styles.css | 11 + assets/inject/renderer-inject.js | 50 +- assets/inject/stepwise-inject.js | 1917 +++++++++++++++++ crates/codex-plus-core/src/assets.rs | 8 +- crates/codex-plus-core/src/lib.rs | 1 + crates/codex-plus-core/src/routes.rs | 31 + crates/codex-plus-core/src/settings.rs | 284 +++ crates/codex-plus-core/src/stepwise.rs | 452 ++++ crates/codex-plus-core/tests/bridge_routes.rs | 95 + crates/codex-plus-core/tests/cdp_bridge.rs | 37 + 12 files changed, 3018 insertions(+), 6 deletions(-) create mode 100644 assets/inject/stepwise-inject.js create mode 100644 crates/codex-plus-core/src/stepwise.rs diff --git a/apps/codex-plus-launcher/src/main.rs b/apps/codex-plus-launcher/src/main.rs index fab54fa92..92d5186b7 100644 --- a/apps/codex-plus-launcher/src/main.rs +++ b/apps/codex-plus-launcher/src/main.rs @@ -648,6 +648,36 @@ impl BridgeRuntimeService for LauncherRuntimeService { &payload, )) } + + async fn stepwise_settings(&self) -> anyhow::Result { + let settings = codex_plus_core::settings::SettingsStore::default() + .load() + .unwrap_or_default(); + Ok(json!({ + "status": "ok", + "settings": codex_plus_core::stepwise::public_settings(&settings), + })) + } + + async fn stepwise_generate(&self, payload: Value) -> anyhow::Result { + let settings = codex_plus_core::settings::SettingsStore::default() + .load() + .unwrap_or_default(); + let request = payload.get("request").cloned().unwrap_or(payload); + let request = serde_json::from_value::(request) + .unwrap_or_default(); + codex_plus_core::stepwise::generate(request, &settings).await + } + + async fn stepwise_test(&self, payload: Value) -> anyhow::Result { + let settings = codex_plus_core::stepwise::settings_with_payload( + codex_plus_core::settings::SettingsStore::default() + .load() + .unwrap_or_default(), + &payload, + ); + codex_plus_core::stepwise::test_connection(&settings).await + } } async fn inject_with_context( diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index e6014c8f8..5a7a8977d 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -142,6 +142,16 @@ type BackendSettings = { codexAppNativeMenuPlacement: boolean; codexAppNativeMenuLocalization: boolean; codexAppServiceTierControls: boolean; + codexAppStepwiseEnabled: boolean; + codexAppStepwiseDirectSend: boolean; + codexAppStepwiseBaseUrl: string; + codexAppStepwiseApiKey: string; + codexAppStepwiseApiKeyEnv: string; + codexAppStepwiseModel: string; + codexAppStepwiseMaxItems: number; + codexAppStepwiseMaxInputChars: number; + codexAppStepwiseMaxOutputTokens: number; + codexAppStepwiseTimeoutMs: number; codexAppImageOverlayEnabled: boolean; codexAppImageOverlayPath: string; codexAppImageOverlayOpacity: number; @@ -646,6 +656,16 @@ const defaultSettings: BackendSettings = { codexAppNativeMenuPlacement: true, codexAppNativeMenuLocalization: true, codexAppServiceTierControls: false, + codexAppStepwiseEnabled: false, + codexAppStepwiseDirectSend: false, + codexAppStepwiseBaseUrl: "", + codexAppStepwiseApiKey: "", + codexAppStepwiseApiKeyEnv: "CODEX_STEPWISE_API_KEY", + codexAppStepwiseModel: "", + codexAppStepwiseMaxItems: 6, + codexAppStepwiseMaxInputChars: 6000, + codexAppStepwiseMaxOutputTokens: 500, + codexAppStepwiseTimeoutMs: 8000, codexAppImageOverlayEnabled: false, codexAppImageOverlayPath: "", codexAppImageOverlayOpacity: 35, @@ -2734,6 +2754,8 @@ function EnhanceScreen({ setEnhanceFlag("codexAppPluginAutoExpand", value)} /> setEnhanceFlag("codexAppModelWhitelistUnlock", value)} /> setEnhanceFlag("codexAppServiceTierControls", value)} /> + setEnhanceFlag("codexAppStepwiseEnabled", value)} /> + setEnhanceFlag("codexAppStepwiseDirectSend", value)} /> setEnhanceFlag("codexAppSessionDelete", value)} /> setEnhanceFlag("codexAppMarkdownExport", value)} /> setEnhanceFlag("codexAppPasteFix", value)} /> @@ -3471,6 +3493,88 @@ function SettingsScreen({ onChange={(event) => onFormChange({ ...form, cliWrapperApiKey: event.currentTarget.value })} /> +
+
Stepwise API
+
+ + onFormChange({ ...form, codexAppStepwiseBaseUrl: event.currentTarget.value })} + placeholder="https://api.example.com/v1" + /> + + + onFormChange({ ...form, codexAppStepwiseModel: event.currentTarget.value })} + placeholder="例如 gpt-5.4-mini" + /> + +
+
+ + onFormChange({ ...form, codexAppStepwiseApiKey: event.currentTarget.value })} + /> + + + onFormChange({ ...form, codexAppStepwiseApiKeyEnv: event.currentTarget.value })} + /> + +
+
+ + + onFormChange({ ...form, codexAppStepwiseMaxItems: clampNumber(Number(event.currentTarget.value), 0, 6) }) + } + /> + + + + onFormChange({ ...form, codexAppStepwiseTimeoutMs: clampNumber(Number(event.currentTarget.value), 1000, 60000) }) + } + /> + +
+
+ + + onFormChange({ ...form, codexAppStepwiseMaxInputChars: clampNumber(Number(event.currentTarget.value), 1000, 24000) }) + } + /> + + + + onFormChange({ ...form, codexAppStepwiseMaxOutputTokens: clampNumber(Number(event.currentTarget.value), 100, 4000) }) + } + /> + +
+ + + +
+
+
Stepwise
在当前 Codex 页面显示可拖动的下一步建议浮层,可在设置页配置模型和直接发送。
+ +
服务模式
继承使用 config.toml 的 service tier;全局模式覆盖全部 thread;自定义允许按 thread 覆盖。
@@ -2404,7 +2444,7 @@ } const toggle = target?.closest("[data-codex-plus-setting]"); if (toggle) { - if (toggle.disabled) return; + if (toggle.disabled || toggle.dataset.pending === "true") return; const key = toggle.getAttribute("data-codex-plus-setting"); setCodexPlusSetting(key, !codexPlusSettings()[key]); return; diff --git a/assets/inject/stepwise-inject.js b/assets/inject/stepwise-inject.js new file mode 100644 index 000000000..3a81f82c1 --- /dev/null +++ b/assets/inject/stepwise-inject.js @@ -0,0 +1,1917 @@ +(() => { + "use strict"; + + const API_KEY = "__codexStepwisePanel"; + const STYLE_ID = "codex-stepwise-panel-style"; + const ROOT_ATTR = "data-codex-stepwise-root"; + const PAYLOAD_ATTR = "data-codex-stepwise-payload"; + const SCRIPT_VERSION = "1.0.0-core"; + const PAGE_BRIDGE = "__codexSessionDeleteBridge"; + const POSITION_KEY = "codex-stepwise-float-position-v1"; + const DIAGNOSTICS_KEY = "codex-stepwise-diagnostics-v1"; + const SCAN_DELAY_MS = 220; + const STREAM_IDLE_MS = 1300; + const BRIDGE_TIMEOUT_MS = 26000; + const MAX_TEXT_LENGTH = 12000; + const MAX_STEPWISE_ITEMS = 6; + const MAX_PROMPT_LENGTH = 420; + const MAX_DIAGNOSTICS = 80; + const EDITABLE_SUBMIT_DELAY_MS = 120; + const SUBMIT_RETRY_DELAY_MS = 50; + const SUBMIT_RETRY_LIMIT = 600; + const INSTANCE_ID = `${SCRIPT_VERSION}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + let codexAppActionsPromise = null; + let settingsPromise = null; + let startupPromise = null; + + const previous = window[API_KEY]; + if (previous && typeof previous.destroy === "function") previous.destroy(); + document.querySelectorAll?.(`[${ROOT_ATTR}="true"]`).forEach((node) => node.remove()); + document.getElementById(STYLE_ID)?.remove(); + + const state = { + observer: null, + themeObserver: null, + timer: 0, + root: null, + fab: null, + popover: null, + open: false, + activeTab: "next", + position: null, + drag: null, + lastAssistantHash: "", + lastAssistantAt: 0, + currentHash: "", + bridgeCache: new Map(), + bridgePendingHash: "", + bridgeStatus: "idle", + bridgeError: "", + prompts: [], + settings: null, + settingsStatus: "", + theme: "dark", + themeMode: "auto", + scans: 0, + destroyed: false, + diagnostics: readDiagnostics(), + }; + + function isCurrentInstance() { + return !state.destroyed && window[API_KEY]?.instanceId === INSTANCE_ID; + } + + function normalizeText(value) { + return String(value || "") + .replace(/\u00a0/g, " ") + .replace(/[ \t]+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/[ \t]{2,}/g, " ") + .trim(); + } + + function shortText(value, limit = MAX_TEXT_LENGTH) { + const text = normalizeText(value); + return text.length > limit ? text.slice(text.length - limit) : text; + } + + function hashText(value) { + const text = shortText(value, 4000); + let hash = 2166136261; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); + } + + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + + function rectSummary(node) { + const rect = visibleRect(node); + if (!rect) return null; + return { + left: Math.round(rect.left), + top: Math.round(rect.top), + right: Math.round(rect.right), + bottom: Math.round(rect.bottom), + width: Math.round(rect.width), + height: Math.round(rect.height), + }; + } + + function readDiagnostics() { + try { + const parsed = JSON.parse(sessionStorage.getItem(DIAGNOSTICS_KEY) || "[]"); + return Array.isArray(parsed) ? parsed.slice(-MAX_DIAGNOSTICS) : []; + } catch { + return []; + } + } + + function writeDiagnostics() { + try { + sessionStorage.setItem(DIAGNOSTICS_KEY, JSON.stringify(state.diagnostics.slice(-MAX_DIAGNOSTICS))); + } catch {} + } + + function pushDiagnostic(event, details = {}) { + state.diagnostics.push({ + at: new Date().toISOString(), + instanceId: INSTANCE_ID, + event, + details, + }); + if (state.diagnostics.length > MAX_DIAGNOSTICS) { + state.diagnostics.splice(0, state.diagnostics.length - MAX_DIAGNOSTICS); + } + writeDiagnostics(); + } + + function visibleRect(node) { + if (!(node instanceof Element)) return null; + const rect = node.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return null; + return rect; + } + + function visibleElement(node) { + const rect = visibleRect(node); + return Boolean(rect && rect.width > 20 && rect.height > 10 && rect.bottom > 0 && rect.top < window.innerHeight); + } + + function parseRgb(color) { + const match = String(color || "").match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/i); + if (!match) return null; + return { + r: Number(match[1]), + g: Number(match[2]), + b: Number(match[3]), + a: match[4] === undefined ? 1 : Number(match[4]), + }; + } + + function luminance(rgb) { + if (!rgb) return 0; + return 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b; + } + + function detectCodexTheme() { + const rootClass = document.documentElement.classList; + if (rootClass.contains("electron-dark") || rootClass.contains("theme-dark")) return "dark"; + if (rootClass.contains("electron-light") || rootClass.contains("theme-light")) return "light"; + + const bodyClass = document.body?.classList; + if (bodyClass?.contains("electron-dark") || bodyClass?.contains("theme-dark")) return "dark"; + if (bodyClass?.contains("electron-light") || bodyClass?.contains("theme-light")) return "light"; + + const explicitTokens = [ + document.documentElement.getAttribute("data-theme"), + document.documentElement.getAttribute("color-scheme"), + document.body?.getAttribute("data-theme"), + getComputedStyle(document.documentElement).colorScheme, + ].join(" "); + if (/\bdark\b/i.test(explicitTokens)) return "dark"; + if (/\blight\b/i.test(explicitTokens)) return "light"; + + const candidates = [ + document.querySelector(".thread-scroll-container"), + document.querySelector("main"), + document.body, + document.documentElement, + ].filter(Boolean); + for (const node of candidates) { + const color = getComputedStyle(node).backgroundColor; + const rgb = parseRgb(color); + if (rgb && rgb.a > 0.05 && luminance(rgb) > 5) return luminance(rgb) < 128 ? "dark" : "light"; + } + return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + + function syncTheme() { + localStorage.removeItem("codex-stepwise-theme-mode-v1"); + state.themeMode = "auto"; + state.theme = detectCodexTheme(); + state.root?.setAttribute("data-theme", state.theme); + state.root?.setAttribute("data-theme-mode", state.themeMode); + } + + function appActionModuleCandidates() { + const candidates = new Set(); + const add = (value) => { + if (!value) return; + try { + const url = new URL(value, location.href); + if (/\/assets\/rpc-[^/]+\.js$/.test(url.pathname)) candidates.add(`.${url.pathname}`); + } catch {} + }; + + document.querySelectorAll("script[src],link[href]").forEach((node) => { + add(node.getAttribute("src") || node.getAttribute("href")); + }); + const resources = performance.getEntriesByType?.("resource") || []; + resources.forEach((entry) => add(entry.name)); + return Array.from(candidates); + } + + async function getCodexAppActions() { + if (!codexAppActionsPromise) { + codexAppActionsPromise = (async () => { + const errors = []; + for (const candidate of appActionModuleCandidates()) { + try { + const module = await import(candidate); + const appActions = module?.n?.appActions || module?.appServices?.appActions; + if (typeof appActions?.runInPrimaryWindow === "function") return appActions; + errors.push(`${candidate}: missing appActions`); + } catch (error) { + errors.push(`${candidate}: ${error.message}`); + } + } + throw new Error(`Codex app actions unavailable (${errors.join("; ")})`); + })(); + } + + try { + return await codexAppActionsPromise; + } catch (error) { + codexAppActionsPromise = null; + throw error; + } + } + + async function setCodexThemeMode(theme) { + if (theme !== "light" && theme !== "dark") return; + const appActions = await getCodexAppActions(); + await appActions.runInPrimaryWindow({ + action: { type: "app.appearance.set_mode", mode: theme }, + }); + } + + function toggleCodexTheme() { + const nextTheme = detectCodexTheme() === "dark" ? "light" : "dark"; + setCodexThemeMode(nextTheme) + .then(() => { + const before = `${state.themeMode}:${state.theme}`; + syncTheme(); + if (state.open && before !== `${state.themeMode}:${state.theme}`) renderFloat(); + }) + .catch((error) => { + console.warn("[Codex++ Stepwise] Failed to switch Codex theme", error); + }); + } + + function themeLabel() { + return state.theme === "dark" ? "Switch to light theme" : "Switch to dark theme"; + } + + function iconSvg(name) { + const common = `fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"`; + if (name === "settings") { + return ``; + } + if (name === "moon") { + return ``; + } + if (name === "sun") { + return ``; + } + return ``; + } + + function themeIcon() { + return state.theme === "dark" ? iconSvg("sun") : iconSvg("moon"); + } + + function installThemeObserver() { + if (state.themeObserver) return; + + let frame = 0; + const update = () => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + const before = `${state.themeMode}:${state.theme}`; + syncTheme(); + if (state.open && before !== `${state.themeMode}:${state.theme}`) renderFloat(); + }); + }; + + state.themeObserver = new MutationObserver(update); + [document.documentElement, document.body].filter(Boolean).forEach((node) => { + state.themeObserver.observe(node, { + attributes: true, + attributeFilter: ["class", "style", "data-theme", "color-scheme"], + }); + }); + } + + function stripOwnUi(clone) { + clone.querySelectorAll?.(`[${ROOT_ATTR}], [${PAYLOAD_ATTR}]`).forEach((item) => item.remove()); + return clone; + } + + function elementText(node) { + if (!(node instanceof Element)) return normalizeText(node?.textContent || ""); + return normalizeText(stripOwnUi(node.cloneNode(true)).textContent || ""); + } + + function directText(node) { + if (!(node instanceof Element)) return ""; + const clone = stripOwnUi(node.cloneNode(true)); + clone.querySelectorAll?.("button,[role='button'],svg").forEach((item) => item.remove()); + return normalizeText(clone.textContent || ""); + } + + function installStyle() { + if (document.getElementById(STYLE_ID)) return; + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + [${ROOT_ATTR}="true"] { + --csw-bg: rgba(250, 250, 249, 0.98); + --csw-border: rgba(20, 20, 20, 0.12); + --csw-text: rgba(20, 20, 19, 0.94); + --csw-muted: rgba(20, 20, 19, 0.62); + --csw-soft: rgba(20, 20, 19, 0.065); + --csw-row: rgba(255, 255, 255, 0.72); + --csw-input: rgba(255, 255, 255, 0.82); + --csw-fab-bg: rgba(250, 250, 249, 0.98); + --csw-fab-fg: rgba(20, 20, 19, 0.94); + --csw-fab-border: rgba(20, 20, 20, 0.16); + --csw-fab-shadow: 0 10px 26px rgba(0, 0, 0, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.78); + --csw-badge-bg: rgba(86, 86, 84, 0.98); + --csw-badge-fg: rgba(255, 255, 255, 0.96); + --csw-badge-border: rgba(255, 255, 255, 0.28); + --csw-popover-shadow: 0 18px 48px rgba(0, 0, 0, 0.16); + color: var(--csw-text); + font: 13px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Hiragino Sans GB", "Segoe UI", sans-serif; + inset: 0; + letter-spacing: 0; + pointer-events: none; + position: fixed; + z-index: 2147483000; + } + + [${ROOT_ATTR}="true"][data-theme="dark"] { + --csw-bg: rgba(31, 31, 30, 0.98); + --csw-border: rgba(255, 255, 255, 0.13); + --csw-text: rgba(247, 247, 246, 0.94); + --csw-muted: rgba(247, 247, 246, 0.62); + --csw-soft: rgba(255, 255, 255, 0.08); + --csw-row: rgba(255, 255, 255, 0.06); + --csw-input: rgba(255, 255, 255, 0.07); + --csw-fab-bg: linear-gradient(180deg, rgba(49, 49, 48, 0.98), rgba(25, 25, 24, 0.99)); + --csw-fab-fg: rgba(255, 255, 255, 0.92); + --csw-fab-border: rgba(255, 255, 255, 0.18); + --csw-fab-shadow: 0 12px 30px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.08); + --csw-badge-bg: rgba(71, 71, 69, 0.98); + --csw-badge-fg: rgba(255, 255, 255, 0.96); + --csw-badge-border: rgba(255, 255, 255, 0.2); + --csw-popover-shadow: 0 18px 52px rgba(0, 0, 0, 0.38); + color: var(--csw-text); + } + + [${PAYLOAD_ATTR}="true"], + [${PAYLOAD_ATTR}="block"] { + display: none !important; + } + + .csw-fab { + align-items: center; + appearance: none; + background: var(--csw-fab-bg); + border: 1px solid var(--csw-fab-border); + border-radius: 999px; + box-shadow: var(--csw-fab-shadow); + color: var(--csw-fab-fg); + cursor: grab; + display: flex; + height: 43px; + justify-content: center; + padding: 0; + pointer-events: auto; + position: fixed; + transition: background-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; + user-select: none; + width: 43px; + } + + .csw-fab:active { + cursor: grabbing; + transform: scale(0.98); + } + + .csw-fab:hover { + box-shadow: var(--csw-fab-shadow), 0 0 0 4px rgba(127, 127, 127, 0.08); + } + + .csw-fab-mark { + align-items: center; + display: block; + font-size: 23px; + font-weight: 650; + line-height: 1; + margin-left: 1px; + transform: translateY(-1px); + } + + .csw-fab-badge { + align-items: center; + background: var(--csw-badge-bg); + border: 1px solid var(--csw-badge-border); + border-radius: 999px; + color: var(--csw-badge-fg); + display: flex; + font-size: 11px; + font-weight: 700; + height: 18px; + justify-content: center; + min-width: 18px; + padding: 0 4px; + position: absolute; + right: -4px; + top: -5px; + } + + .csw-fab[data-count="0"] .csw-fab-badge { + display: none; + } + + .csw-popover { + background: var(--csw-bg); + border: 1px solid var(--csw-border); + border-radius: 8px; + box-shadow: var(--csw-popover-shadow); + box-sizing: border-box; + display: none; + max-height: calc(100vh - 28px); + overflow: hidden; + pointer-events: auto; + position: fixed; + width: min(380px, calc(100vw - 28px)); + } + + .csw-popover[data-open="true"] { + display: block; + } + + .csw-head { + align-items: center; + border-bottom: 1px solid var(--csw-border); + display: flex; + gap: 8px; + justify-content: space-between; + padding: 9px 10px 9px 12px; + } + + .csw-title { + font-size: 13px; + font-weight: 700; + } + + .csw-tabs { + align-items: center; + display: flex; + gap: 2px; + } + + .csw-icon { + align-items: center; + appearance: none; + background: transparent; + border: 0; + border-radius: 7px; + color: var(--csw-muted); + cursor: pointer; + display: inline-flex; + font: 600 12px/1 -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; + height: 30px; + justify-content: center; + padding: 0 8px; + width: 30px; + } + + .csw-icon[data-active="true"], + .csw-icon:hover { + background: var(--csw-soft); + color: var(--csw-text); + } + + .csw-icon svg { + display: block; + height: 18px; + width: 18px; + } + + .csw-icon[data-action="close"] { + font-size: 17px; + font-weight: 500; + } + + .csw-body { + max-height: calc(100vh - 78px); + overflow: auto; + padding: 10px; + } + + .csw-list { + display: grid; + gap: 6px; + } + + .csw-row { + appearance: none; + background: var(--csw-row); + border: 1px solid var(--csw-border); + border-radius: 7px; + color: inherit; + cursor: pointer; + display: block; + min-height: 0; + padding: 8px 9px; + text-align: left; + width: 100%; + } + + .csw-row:hover, + .csw-row:focus-visible { + background: var(--csw-soft); + outline: none; + } + + .csw-row-label { + color: var(--csw-text); + display: block; + font-size: 12px; + font-weight: 700; + margin-bottom: 3px; + } + + .csw-row-prompt { + color: var(--csw-muted); + display: -webkit-box; + font-size: 12px; + line-height: 1.42; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + } + + .csw-row:hover .csw-row-prompt, + .csw-row:focus-visible .csw-row-prompt { + -webkit-line-clamp: 5; + } + + .csw-empty { + background: var(--csw-row); + border: 1px solid var(--csw-border); + border-radius: 7px; + color: var(--csw-muted); + padding: 12px; + } + + .csw-form { + display: grid; + gap: 10px; + } + + .csw-switch { + align-items: center; + background: var(--csw-row); + border: 1px solid var(--csw-border); + border-radius: 7px; + box-sizing: border-box; + cursor: pointer; + display: flex; + gap: 10px; + justify-content: space-between; + min-height: 40px; + padding: 8px 9px; + } + + .csw-switch input { + height: 1px; + opacity: 0; + position: absolute; + width: 1px; + } + + .csw-switch-text { + display: grid; + gap: 2px; + } + + .csw-switch-title { + color: var(--csw-text); + font-size: 12px; + font-weight: 750; + line-height: 1.25; + } + + .csw-switch-note { + color: var(--csw-muted); + font-size: 11px; + line-height: 1.35; + } + + .csw-switch-control { + background: var(--csw-soft); + border: 1px solid var(--csw-border); + border-radius: 999px; + box-sizing: border-box; + flex: 0 0 auto; + height: 22px; + padding: 2px; + transition: background 140ms ease, border-color 140ms ease; + width: 38px; + } + + .csw-switch-control::before { + background: var(--csw-muted); + border-radius: 999px; + content: ""; + display: block; + height: 16px; + transition: transform 140ms ease, background 140ms ease; + width: 16px; + } + + .csw-switch input:checked + .csw-switch-control { + background: var(--csw-text); + border-color: var(--csw-text); + } + + .csw-switch input:checked + .csw-switch-control::before { + background: var(--csw-bg); + transform: translateX(16px); + } + + .csw-grid { + display: grid; + gap: 8px; + grid-template-columns: 1fr 1fr; + } + + .csw-section { + display: grid; + gap: 8px; + } + + .csw-section-title { + color: var(--csw-text); + font-size: 11px; + font-weight: 750; + line-height: 1.2; + } + + .csw-field { + display: grid; + gap: 4px; + } + + .csw-field label { + color: var(--csw-muted); + font-size: 11px; + font-weight: 600; + } + + .csw-field input { + background: var(--csw-input); + border: 1px solid var(--csw-border); + border-radius: 6px; + box-sizing: border-box; + color: var(--csw-text); + font: inherit; + height: 32px; + padding: 0 8px; + width: 100%; + } + + .csw-field[data-disabled="true"] { + opacity: .48; + } + + .csw-field input:disabled { + cursor: not-allowed; + } + + .csw-check { + align-items: center; + background: var(--csw-row); + border: 1px solid var(--csw-border); + border-radius: 7px; + box-sizing: border-box; + cursor: pointer; + display: flex; + gap: 8px; + min-height: 34px; + padding: 8px 9px; + } + + .csw-check input { + accent-color: var(--csw-text); + flex: 0 0 auto; + height: 14px; + margin: 0; + width: 14px; + } + + .csw-check span { + color: var(--csw-text); + font-size: 12px; + font-weight: 650; + line-height: 1.35; + } + + .csw-actions { + display: flex; + gap: 7px; + padding-top: 2px; + } + + .csw-primary, + .csw-secondary { + appearance: none; + border-radius: 6px; + cursor: pointer; + font: 700 12px/1 -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; + height: 31px; + padding: 0 11px; + } + + .csw-primary { + background: var(--csw-text); + border: 0; + color: var(--csw-bg); + } + + .csw-secondary { + background: transparent; + border: 1px solid var(--csw-border); + color: var(--csw-text); + } + + .csw-primary:disabled, + .csw-secondary:disabled { + cursor: not-allowed; + opacity: .46; + } + + .csw-status { + color: var(--csw-muted); + font-size: 11px; + min-height: 16px; + } + + `; + document.head.appendChild(style); + } + + function defaultPosition() { + return clampPosition({ + x: window.innerWidth - 76, + y: window.innerHeight - 174, + }); + } + + function savedPosition() { + try { + const parsed = JSON.parse(localStorage.getItem(POSITION_KEY) || "null"); + if (Number.isFinite(parsed?.x) && Number.isFinite(parsed?.y)) return clampPosition(parsed); + } catch {} + return defaultPosition(); + } + + function clampPosition(position) { + const margin = 12; + const size = 44; + return { + x: clamp(Number(position?.x) || 0, margin, Math.max(margin, window.innerWidth - size - margin)), + y: clamp(Number(position?.y) || 0, margin, Math.max(margin, window.innerHeight - size - margin)), + }; + } + + function savePosition(position) { + state.position = clampPosition(position); + localStorage.setItem(POSITION_KEY, JSON.stringify(state.position)); + applyPosition(); + } + + function applyPosition() { + if (!state.fab || !state.position) return; + state.position = clampPosition(state.position); + state.fab.style.left = `${state.position.x}px`; + state.fab.style.top = `${state.position.y}px`; + positionPopover(); + } + + function positionPopover() { + if (!state.popover || !state.position) return; + const width = Math.min(380, window.innerWidth - 28); + const measuredHeight = state.popover.offsetHeight || 260; + const height = Math.min(measuredHeight, window.innerHeight - 28); + const margin = 14; + const leftSide = state.position.x > window.innerWidth / 2; + const x = leftSide ? state.position.x - width - 12 : state.position.x + 56; + const y = state.position.y > window.innerHeight / 2 ? state.position.y - height + 44 : state.position.y; + state.popover.style.left = `${clamp(x, margin, Math.max(margin, window.innerWidth - width - margin))}px`; + state.popover.style.top = `${clamp(y, margin, Math.max(margin, window.innerHeight - height - margin))}px`; + } + + function installFloat() { + if (!isCurrentInstance()) return; + document.querySelectorAll?.(`[${ROOT_ATTR}="true"]`).forEach((node) => { + if (node !== state.root) node.remove(); + }); + if (state.root && document.body.contains(state.root)) return; + + state.position = savedPosition(); + state.root = document.createElement("div"); + state.root.setAttribute(ROOT_ATTR, "true"); + + state.fab = document.createElement("button"); + state.fab.className = "csw-fab"; + state.fab.type = "button"; + state.fab.title = "Stepwise"; + state.fab.innerHTML = `0`; + + state.popover = document.createElement("div"); + state.popover.className = "csw-popover"; + + state.root.append(state.fab, state.popover); + document.body.appendChild(state.root); + + state.fab.addEventListener("pointerdown", onFabPointerDown); + state.fab.addEventListener("click", onFabClick); + window.addEventListener("resize", onResize); + installThemeObserver(); + syncTheme(); + applyPosition(); + renderFloat(); + } + + function onResize() { + if (!state.position) return; + state.position = clampPosition(state.position); + applyPosition(); + } + + function onFabPointerDown(event) { + if (event.button !== 0) return; + state.drag = { + id: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: state.position.x, + originY: state.position.y, + moved: false, + }; + state.fab.setPointerCapture?.(event.pointerId); + state.fab.addEventListener("pointermove", onFabPointerMove); + state.fab.addEventListener("pointerup", onFabPointerUp, { once: true }); + state.fab.addEventListener("pointercancel", onFabPointerUp, { once: true }); + } + + function onFabPointerMove(event) { + const drag = state.drag; + if (!drag || drag.id !== event.pointerId) return; + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + if (Math.abs(dx) + Math.abs(dy) > 4) drag.moved = true; + if (!drag.moved) return; + event.preventDefault(); + savePosition({ x: drag.originX + dx, y: drag.originY + dy }); + } + + function onFabPointerUp(event) { + const drag = state.drag; + state.fab.removeEventListener("pointermove", onFabPointerMove); + state.fab.releasePointerCapture?.(event.pointerId); + window.setTimeout(() => { + if (state.drag === drag) state.drag = null; + }, 0); + } + + function onFabClick(event) { + if (state.drag?.moved) { + event.preventDefault(); + event.stopPropagation(); + return; + } + state.open = !state.open; + renderFloat(); + } + + function renderFloat() { + if (!isCurrentInstance()) return; + installStyle(); + installFloat(); + if (!state.fab || !state.popover) return; + syncTheme(); + const count = state.prompts.length; + state.fab.dataset.count = String(count); + state.fab.querySelector(".csw-fab-badge").textContent = String(count); + state.popover.dataset.open = state.open ? "true" : "false"; + if (!state.open) return; + + state.popover.innerHTML = ` +
+
${state.activeTab === "settings" ? "Settings" : "Stepwise"}
+
+ + + +
+
+
${state.activeTab === "settings" ? settingsHtml() : nextHtml()}
+ `; + + state.popover.querySelector("[data-action='settings-toggle']")?.addEventListener("click", () => { + state.activeTab = state.activeTab === "settings" ? "next" : "settings"; + if (state.activeTab === "settings") void loadSettings(); + renderFloat(); + }); + state.popover.querySelector("[data-action='close']")?.addEventListener("click", () => { + state.open = false; + renderFloat(); + }); + state.popover.querySelector("[data-action='theme']")?.addEventListener("click", toggleCodexTheme); + + if (state.activeTab === "settings") attachSettingsEvents(); + else attachNextEvents(); + positionPopover(); + } + + function nextHtml() { + if (state.bridgeStatus === "pending") { + return `
生成中...
`; + } + if (!state.prompts.length) { + const text = state.bridgeError || "当前没有可用建议"; + return `
${escapeHtml(text)}
`; + } + return `
${state.prompts.map((item, index) => ` + + `).join("")}
`; + } + + function attachNextEvents() { + state.popover.querySelectorAll(".csw-row").forEach((button) => { + button.addEventListener("click", () => void selectPrompt(button)); + }); + } + + async function selectPrompt(button) { + const item = state.prompts[Number(button.dataset.index)]; + if (!item?.prompt) return; + if (state.settings) { + fillSelectedPrompt(item.prompt, state.settings); + return; + } + + pushDiagnostic("settings:missing-before-click", {}); + const settings = await ensureSettings(); + if (!isCurrentInstance()) return; + fillSelectedPrompt(item.prompt, settings); + } + + function fillSelectedPrompt(prompt, settings) { + pushDiagnostic("settings:click-mode", { + directSend: settings?.directSend === true, + }); + fillComposer(prompt, settings?.directSend === true); + state.open = false; + renderFloat(); + } + + function settingsHtml() { + const settings = state.settings; + if (!settings) return `
读取中...
`; + return ` +
+
${escapeHtml(statusLine(settings))}
+
+
配置摘要
+
Model: ${escapeHtml(settings.model || "未配置")}
+
最多建议: ${escapeHtml(settings.maxItems ?? 6)}
+
Direct Send: ${settings.directSend ? "开启" : "关闭"}
+
+
+ + + +
+
${escapeHtml(state.settingsStatus || "")}
+
+ `; + } + + function statusLine(settings) { + if (settings.enabled !== true) return "Stepwise 已关闭,请在 Codex++ Manager 里开启。"; + if (!settings.baseUrlConfigured || !settings.model) return "Stepwise 已开启,但 Base URL 或 Model 未配置。"; + if (!settings.apiKeyConfigured) return `Stepwise 已开启,但 API Key 未配置;可填写密钥或设置 ${settings.apiKeyEnv || "环境变量"}。`; + return `Stepwise 已开启 · ${settings.model || ""}`.replace(/\s+·\s+$/, ""); + } + + function attachSettingsEvents() { + state.popover.querySelector("[data-action='open-manager']")?.addEventListener("click", () => void openManager()); + state.popover.querySelector("[data-action='test-settings']")?.addEventListener("click", () => void testSettings()); + state.popover.querySelector("[data-action='reset-position']")?.addEventListener("click", () => { + localStorage.removeItem(POSITION_KEY); + state.position = defaultPosition(); + applyPosition(); + state.settingsStatus = "位置已重置"; + renderFloat(); + }); + } + + async function loadSettings() { + const payload = await bridgeCall("/stepwise/settings", {}); + if (!isCurrentInstance()) return null; + if (payload?.settings) { + state.settings = payload.settings; + state.settingsStatus = statusLine(payload.settings); + } else { + state.settingsStatus = payload?.error || "Bridge 未就绪"; + } + if (state.activeTab === "settings" && state.open) renderFloat(); + return state.settings; + } + + async function ensureSettings() { + if (state.settings) return state.settings; + if (!settingsPromise) { + settingsPromise = loadSettings().finally(() => { + settingsPromise = null; + }); + } + return settingsPromise; + } + + async function testSettings() { + state.settingsStatus = "测试中..."; + renderFloat(); + const payload = await bridgeCall("/stepwise/test", {}); + if (!isCurrentInstance()) return; + const count = Array.isArray(payload?.items) ? payload.items.length : 0; + state.settingsStatus = payload?.error || (payload?.disabled ? "已关闭" : `测试通过 · ${count} 条`); + renderFloat(); + } + + async function openManager() { + state.settingsStatus = "正在打开 Codex++ Manager..."; + renderFloat(); + const payload = await bridgeCall("/manager/open", {}); + if (!isCurrentInstance()) return; + state.settingsStatus = payload?.status === "ok" ? "已打开 Manager" : payload?.message || "打开失败"; + renderFloat(); + } + + function bridgeCall(path, payload) { + if (typeof window[PAGE_BRIDGE] !== "function") { + return Promise.resolve({ error: "page bridge is not installed", items: [] }); + } + let timer = 0; + const timeout = new Promise((resolve) => { + timer = window.setTimeout(() => resolve({ error: "page bridge timed out", items: [] }), BRIDGE_TIMEOUT_MS); + }); + const request = Promise.resolve(window[PAGE_BRIDGE](path, payload || {})); + return Promise.race([request, timeout]).finally(() => window.clearTimeout(timer)); + } + + function roleFromElement(node) { + if (!(node instanceof Element)) return ""; + const explicit = node.getAttribute("data-message-author-role"); + if (explicit) return explicit.toLowerCase(); + + const text = elementText(node); + if (/^(assistant|codex|assistant\s+said)\b/i.test(text)) return "assistant"; + if (/^(user|you)\b/i.test(text)) return "user"; + return ""; + } + + function chatRoot() { + return Array.from(document.querySelectorAll(".thread-scroll-container")) + .find((node) => visibleElement(node)) || null; + } + + function composerCandidates() { + return Array.from( + document.querySelectorAll( + [ + "textarea", + "[contenteditable='true']", + "[role='textbox']", + "div.ProseMirror", + ].join(",") + ) + ).filter((node) => { + if (!(node instanceof HTMLElement)) return false; + if (state.root?.contains(node)) return false; + const rect = node.getBoundingClientRect(); + if (rect.width < 120 || rect.height < 20) return false; + if (rect.bottom < window.innerHeight * 0.35) return false; + return true; + }); + } + + function buttonLabel(node) { + return normalizeText(node.getAttribute("aria-label") || node.getAttribute("title") || node.textContent || ""); + } + + function sendButtonLabel(label) { + return /^(send message|send|发送消息|发送|提交)$/i.test(label); + } + + function stopButtonLabel(label) { + return /^(stop|停止)$/i.test(label); + } + + function iconPathData(node) { + return Array.from(node.querySelectorAll?.("svg path") || []) + .map((path) => path.getAttribute("d") || "") + .join("\n"); + } + + function stopButtonIcon(node) { + const data = iconPathData(node); + return /H14\.25C14\.9404 4\.5 15\.5 5\.05964 15\.5 5\.75V14\.25C15\.5 14\.9404/.test(data); + } + + function stopButton(node) { + return stopButtonLabel(buttonLabel(node)) || stopButtonIcon(node); + } + + function disabledButton(node) { + return Boolean(node.disabled || node.getAttribute("aria-disabled") === "true" || node.dataset.disabled === "true"); + } + + function submitButtonCandidate(button, containerRect) { + const label = buttonLabel(button); + if (stopButton(button)) return false; + if (sendButtonLabel(label)) return true; + if (label) return false; + + const rect = visibleRect(button); + if (!rect || !containerRect) return false; + const className = String(button.className || ""); + const compactIcon = rect.width >= 24 && rect.width <= 48 && rect.height >= 24 && rect.height <= 48; + const composerIcon = className.includes("size-token-button-composer") || className.includes("bg-token-foreground"); + const lowerRight = rect.left > containerRect.left + containerRect.width * 0.58 && + rect.top > containerRect.top + containerRect.height * 0.42; + return compactIcon && composerIcon && lowerRight; + } + + function nearbySubmitButton(target, options = {}) { + const includeDisabled = options.includeDisabled === true; + let current = target?.parentElement || null; + for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) { + if (current === document.body || current === document.documentElement) break; + if (state.root?.contains(current)) return null; + const buttons = Array.from(current.querySelectorAll("button,[role='button']")) + .filter((node) => node instanceof HTMLElement && !state.root?.contains(node) && visibleElement(node) && (includeDisabled || !disabledButton(node))); + + const labeled = buttons.find((button) => sendButtonLabel(buttonLabel(button))); + if (labeled) return labeled; + + const rect = visibleRect(current); + if (rect && rect.width > 260 && rect.height > 52) { + const lowerRight = buttons + .filter((button) => !stopButton(button)) + .filter((button) => submitButtonCandidate(button, rect)) + .sort((a, b) => b.getBoundingClientRect().right - a.getBoundingClientRect().right); + if (lowerRight.length) return lowerRight[0]; + } + } + return null; + } + + function chatSurfaceReady() { + if (!chatRoot()) return false; + if (!composerCandidates().length) return false; + return !chatBusy(); + } + + function chatBusy() { + const root = chatRoot(); + if (!root) return false; + + return Array.from(root.querySelectorAll("button,[role='button']")).some((node) => { + if (!visibleElement(node)) return false; + const label = normalizeText(node.getAttribute("aria-label") || node.textContent || ""); + return /^(停止|stop)$/i.test(label); + }); + } + + function composerBusy(target) { + let current = target?.parentElement || null; + for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) { + if (current === document.body || current === document.documentElement) break; + if (state.root?.contains(current)) return false; + const buttons = Array.from(current.querySelectorAll("button,[role='button']")); + if (buttons.some((node) => { + if (!visibleElement(node)) return false; + return stopButton(node); + })) return true; + } + return false; + } + + function messageCandidates() { + const root = chatRoot(); + if (!root) return []; + + const selectors = [ + "[data-message-author-role]", + "[data-thread-find-target]", + "[data-testid*='message' i]", + "[data-test-id*='message' i]", + "article", + ].join(","); + + return Array.from(root.querySelectorAll(selectors)) + .filter(visibleElement) + .map((node) => ({ + node, + role: roleFromElement(node), + text: elementText(node), + })) + .filter((item) => item.text.length > 8); + } + + function actionButton(node) { + const label = normalizeText(node.getAttribute("aria-label") || node.textContent || ""); + return /^(复制|喜欢|不喜欢|从此处开始分叉|挂钩|copy|like|dislike|fork)/i.test(label); + } + + function actionRowForMessage(root) { + const buttons = Array.from(root.querySelectorAll("button,[role='button']")).filter(actionButton); + for (const button of buttons) { + let current = button.parentElement; + for (let depth = 0; current && depth < 5; depth += 1, current = current.parentElement) { + const rect = visibleRect(current); + if (!rect || rect.height > 96) continue; + const count = Array.from(current.querySelectorAll("button,[role='button']")).filter(actionButton).length; + if (count >= 3) return current; + } + } + return null; + } + + function containsActionRow(node) { + return Boolean(node && actionRowForMessage(node)); + } + + function assistantContainerForActionRow(actionRow) { + let current = actionRow?.parentElement; + + for (let depth = 0; current && depth < 7; depth += 1, current = current.parentElement) { + const text = directText(current); + if (text.length < 24) continue; + if (text.length > MAX_TEXT_LENGTH) continue; + if (!containsActionRow(current)) continue; + return current; + } + + return null; + } + + function allActionRows() { + const root = chatRoot(); + if (!root) return []; + + const rows = []; + const seen = new Set(); + const buttons = Array.from(root.querySelectorAll("button,[role='button']")).filter(actionButton); + + for (const button of buttons) { + let current = button.parentElement; + for (let depth = 0; current && depth < 5; depth += 1, current = current.parentElement) { + if (seen.has(current)) continue; + if (!visibleElement(current)) continue; + const rect = visibleRect(current); + if (!rect || rect.height > 96) continue; + const count = Array.from(current.querySelectorAll("button,[role='button']")).filter(actionButton).length; + if (count < 3) continue; + seen.add(current); + rows.push(current); + break; + } + } + + return rows; + } + + function findLatestAssistantMessage() { + const rows = allActionRows(); + for (let index = rows.length - 1; index >= 0; index -= 1) { + const node = assistantContainerForActionRow(rows[index]); + const text = elementText(node); + if (text.length > 8) return { node, role: "assistant", text }; + } + + const fallback = messageCandidates().filter((item) => item.role === "assistant"); + return fallback[fallback.length - 1] || null; + } + + function findPreviousUserText(assistantNode) { + const candidates = messageCandidates(); + const before = candidates.filter((item) => { + if (item.node === assistantNode) return false; + if (!(item.node instanceof Node) || !(assistantNode instanceof Node)) return false; + return Boolean(item.node.compareDocumentPosition(assistantNode) & Node.DOCUMENT_POSITION_FOLLOWING); + }); + + for (let cursor = before.length - 1; cursor >= 0; cursor -= 1) { + const item = before[cursor]; + if (item.role === "user") return shortText(item.text, 2000); + if (/^(user|you)\b/i.test(item.text)) return shortText(item.text, 2000); + } + return ""; + } + + function hideStepwisePayload(root) { + if (!(root instanceof Element)) return; + + const blocks = Array.from(root.querySelectorAll("pre, code")).filter((node) => { + if (!(node instanceof Element)) return false; + return /"codex_stepwise"\s*:\s*true/.test(node.textContent || ""); + }); + + for (const block of blocks) { + const container = block.closest("[class*='_codeBlock_'], pre") || block; + container.setAttribute(PAYLOAD_ATTR, "true"); + } + } + + function uniquePrompts(items) { + const seen = new Set(); + const result = []; + for (const item of items) { + const prompt = normalizeText(typeof item === "string" ? item : item.prompt).replace(/\s+/g, " "); + if (!prompt || seen.has(prompt)) continue; + seen.add(prompt); + result.push({ + label: normalizeText(typeof item === "string" ? labelForPrompt(prompt) : item.label || labelForPrompt(prompt)), + prompt, + }); + if (result.length >= MAX_STEPWISE_ITEMS) break; + } + return result; + } + + function labelForPrompt(prompt) { + const text = normalizeText(prompt); + const rules = [ + [/diff|风险分级|改动.*总结/i, "查看 diff"], + [/commit|提交/i, "整理 commit"], + [/截图验证|遮挡|浮球|面板/i, "验证界面"], + [/设置|配置|Bridge|API/i, "检查配置"], + [/Codex\+\+|用户脚本|reload|生效/i, "检查脚本"], + [/只读验证|确认.*生效|验证步骤/i, "验证生效"], + [/错误|失败|最小复现|排查/i, "继续排查"], + [/P0|P1|P2|执行顺序/i, "分级排序"], + [/维护成本|长期稳定性|审查/i, "重新审查"], + [/文件路径|当前状态|继续追踪/i, "列出路径"], + [/下一步|改哪些文件/i, "继续下一步"], + [/遗漏的风险|回滚方式/i, "风险回滚"], + ]; + + for (const [pattern, label] of rules) { + if (pattern.test(text)) return label; + } + + return text + .replace(/^(帮我|请|把|给我|继续|检查|执行一次|基于刚才的)/, "") + .replace(/[,。,.].*$/, "") + .trim() + .slice(0, 10) || "继续"; + } + + function parseStepwiseJson(text) { + const blocks = Array.from(text.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) + .map((match) => match[1]) + .filter((block) => /"codex_stepwise"\s*:\s*true/.test(block)); + + for (const block of blocks.reverse()) { + const parsed = parsePayloadCandidate(block); + if (parsed) return parsed; + } + return parsePayloadCandidate(extractJsonObject(text)); + } + + function parsePayloadCandidate(value) { + const text = normalizeText(value) + .replace(/^```(?:json)?/i, "") + .replace(/```$/i, "") + .replace(/^json\s+/i, "") + .trim(); + + if (!/"codex_stepwise"\s*:\s*true/.test(text)) return null; + + try { + const parsed = JSON.parse(text); + return parsed && parsed.codex_stepwise === true ? parsed : null; + } catch { + return null; + } + } + + function extractJsonObject(text) { + const source = String(text || ""); + const marker = source.search(/"codex_stepwise"\s*:\s*true/); + if (marker < 0) return ""; + + const start = source.lastIndexOf("{", marker); + if (start < 0) return ""; + + let depth = 0; + let inString = false; + let escaped = false; + + for (let index = start; index < source.length; index += 1) { + const char = source[index]; + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === "\"") { + inString = !inString; + continue; + } + if (inString) continue; + if (char === "{") depth += 1; + if (char === "}") depth -= 1; + if (depth === 0) return source.slice(start, index + 1); + } + + return ""; + } + + function stripStepwisePayloadText(text) { + const withoutFence = String(text || "").replace(/```(?:json)?\s*[\s\S]*?"codex_stepwise"\s*:\s*true[\s\S]*?```/gi, ""); + const payloadObject = extractJsonObject(withoutFence); + return normalizeText(payloadObject ? withoutFence.replace(payloadObject, "") : withoutFence); + } + + function payloadFromDom(root) { + if (!(root instanceof Element)) return null; + const blocks = Array.from(root.querySelectorAll("pre, code")) + .filter((node) => /"codex_stepwise"\s*:\s*true/.test(node.textContent || "")); + + for (const block of blocks.reverse()) { + const parsed = parsePayloadCandidate(block.textContent || ""); + if (parsed) return parsed; + } + + return null; + } + + function payloadPrompts(payload) { + if (!payload || !Array.isArray(payload.items)) return []; + const items = payload.items + .slice(0, MAX_STEPWISE_ITEMS) + .map((item) => { + const prompt = shortText(item?.prompt || "", MAX_PROMPT_LENGTH).replace(/\s+/g, " "); + const label = shortText(item?.label || "", 36).replace(/\s+/g, " "); + return prompt ? { label: label || labelForPrompt(prompt), prompt } : null; + }) + .filter(Boolean); + return uniquePrompts(items); + } + + function extractStepwisePayload(message) { + const text = elementText(message.node); + const payload = payloadFromDom(message.node) || parseStepwiseJson(text); + return { + payload, + prompts: payloadPrompts(payload), + textWithoutPayload: stripStepwisePayloadText(text), + }; + } + + function bridgeRequestKey(userText, assistantText) { + return hashText(`${shortText(userText, 2400)}\n\n--- assistant ---\n\n${shortText(assistantText, 5200)}`); + } + + function requestBridgeStepwise(key, userText, assistantText) { + if (!key || state.bridgePendingHash === key || state.bridgeCache.has(key)) return; + + state.bridgePendingHash = key; + state.bridgeStatus = "pending"; + state.bridgeError = ""; + renderFloat(); + + bridgeCall( + "/stepwise/generate", + { + request: { + lastUserMessage: userText, + lastAssistantMessage: assistantText, + threadTitle: document.title || "", + pageUrl: location.href, + }, + } + ) + .then((payload) => { + if (!isCurrentInstance()) return; + const prompts = payload?.disabled || payload?.error ? [] : payloadPrompts(payload); + state.bridgeCache.set(key, { + disabled: Boolean(payload?.disabled), + error: normalizeText(payload?.error || ""), + prompts, + }); + state.bridgeStatus = payload?.disabled ? "disabled" : payload?.error ? "failed" : "ok"; + state.bridgeError = normalizeText(payload?.error || ""); + }) + .catch((error) => { + if (!isCurrentInstance()) return; + state.bridgeCache.set(key, { disabled: true, error: error.message, prompts: [] }); + state.bridgeStatus = "failed"; + state.bridgeError = error.message; + }) + .finally(() => { + if (!isCurrentInstance()) return; + if (state.bridgePendingHash === key) state.bridgePendingHash = ""; + scheduleScan(0); + }); + } + + function setNativeValue(element, value) { + const prototype = Object.getPrototypeOf(element); + const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); + if (descriptor && typeof descriptor.set === "function") descriptor.set.call(element, value); + else element.value = value; + } + + function composerText(target) { + if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return normalizeText(target.value); + return normalizeText(target?.textContent || ""); + } + + function pressEnter(target) { + target.focus(); + const base = { + key: "Enter", + code: "Enter", + keyCode: 13, + which: 13, + bubbles: true, + cancelable: true, + composed: true, + }; + const down = target.dispatchEvent(new KeyboardEvent("keydown", base)); + target.dispatchEvent(new KeyboardEvent("keypress", base)); + target.dispatchEvent(new KeyboardEvent("keyup", base)); + pushDiagnostic("submit:enter-fallback", { defaultAllowed: down }); + return true; + } + + function submitComposer(target, allowFallback = false) { + if (!(target instanceof HTMLElement)) return false; + if (composerBusy(target)) { + pushDiagnostic("submit:blocked-local-stop", { attemptFallback: allowFallback }); + return false; + } + + const button = nearbySubmitButton(target); + if (button) { + pushDiagnostic("submit:button-click", { + label: buttonLabel(button), + disabled: disabledButton(button), + rect: rectSummary(button), + className: String(button.className || "").slice(0, 160), + composerTextLength: composerText(target).length, + iconPath: iconPathData(button).slice(0, 160), + }); + button.click(); + return true; + } + + const pendingButton = nearbySubmitButton(target, { includeDisabled: true }); + if (pendingButton && disabledButton(pendingButton)) { + pushDiagnostic("submit:button-disabled", { + label: buttonLabel(pendingButton), + rect: rectSummary(pendingButton), + className: String(pendingButton.className || "").slice(0, 160), + composerTextLength: composerText(target).length, + iconPath: iconPathData(pendingButton).slice(0, 160), + }); + return false; + } + + const form = target.closest("form"); + if (form && allowFallback) { + pushDiagnostic("submit:form-fallback", { rect: rectSummary(form) }); + try { + form.requestSubmit(); + } catch { + pushDiagnostic("submit:form-fallback-failed", {}); + return false; + } + return true; + } + + if (allowFallback) return pressEnter(target); + pushDiagnostic("submit:no-button-yet", { allowFallback }); + return false; + } + + function submitComposerWhenReady(target, expectedText = "", attempt = 0) { + if (!(target instanceof HTMLElement)) return false; + if (!document.contains(target)) { + pushDiagnostic("submit:target-detached", { attempt }); + return false; + } + if (normalizeText(expectedText) && composerText(target) !== normalizeText(expectedText)) { + pushDiagnostic("submit:composer-changed", { + attempt, + expectedLength: normalizeText(expectedText).length, + actualLength: composerText(target).length, + }); + return false; + } + if (composerBusy(target)) { + if (attempt === 0 || attempt % 10 === 0 || attempt >= SUBMIT_RETRY_LIMIT) { + pushDiagnostic("submit:blocked-local-stop", { + attempt, + retrying: attempt < SUBMIT_RETRY_LIMIT, + targetRect: rectSummary(target), + }); + } + if (attempt >= SUBMIT_RETRY_LIMIT) { + pushDiagnostic("submit:blocked-local-stop-timeout", { attempt, targetRect: rectSummary(target) }); + return false; + } + window.setTimeout(() => submitComposerWhenReady(target, expectedText, attempt + 1), SUBMIT_RETRY_DELAY_MS); + return false; + } + if (submitComposer(target, attempt >= SUBMIT_RETRY_LIMIT)) return true; + if (attempt >= SUBMIT_RETRY_LIMIT) return false; + window.setTimeout(() => submitComposerWhenReady(target, expectedText, attempt + 1), SUBMIT_RETRY_DELAY_MS); + return false; + } + + function setEditableText(target, prompt) { + target.focus(); + const selection = window.getSelection?.(); + const range = document.createRange(); + range.selectNodeContents(target); + selection?.removeAllRanges(); + selection?.addRange(range); + + let inserted = false; + try { + inserted = document.execCommand?.("insertText", false, prompt) === true; + } catch { + inserted = false; + } + if (!inserted) target.textContent = prompt; + } + + function fillComposer(prompt, submit = false) { + const candidates = composerCandidates(); + const target = candidates[candidates.length - 1]; + pushDiagnostic("fill:start", { + submit, + candidateCount: candidates.length, + targetTag: target?.tagName || "", + targetRole: target?.getAttribute?.("role") || "", + targetClass: String(target?.className || "").slice(0, 120), + targetRect: rectSummary(target), + promptLength: normalizeText(prompt).length, + }); + if (!target) { + window.prompt("Copy Stepwise prompt", prompt); + return false; + } + + target.focus(); + if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) { + setNativeValue(target, prompt); + target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: prompt })); + target.dispatchEvent(new Event("change", { bubbles: true })); + pushDiagnostic("fill:text-control", { valueLength: normalizeText(target.value).length }); + if (submit) submitComposerWhenReady(target, prompt); + return true; + } + + if (target.isContentEditable || target.getAttribute("role") === "textbox") { + setEditableText(target, prompt); + target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: prompt })); + pushDiagnostic("fill:editable", { valueLength: normalizeText(target.textContent).length }); + if (submit) window.setTimeout(() => submitComposerWhenReady(target, prompt), EDITABLE_SUBMIT_DELAY_MS); + return true; + } + + window.prompt("Copy Stepwise prompt", prompt); + return false; + } + + function scan() { + if (!isCurrentInstance()) return; + state.timer = 0; + state.scans += 1; + installStyle(); + installFloat(); + + if (!chatSurfaceReady()) { + renderFloat(); + return; + } + + const message = findLatestAssistantMessage(); + if (!message) { + renderFloat(); + return; + } + + const stepwisePayload = extractStepwisePayload(message); + hideStepwisePayload(message.node); + + const assistantText = shortText(stepwisePayload.textWithoutPayload || message.text); + const hash = hashText(assistantText); + const now = Date.now(); + + if (hash !== state.lastAssistantHash) { + state.lastAssistantHash = hash; + state.lastAssistantAt = now; + scheduleScan(STREAM_IDLE_MS + 120); + return; + } + + if (now - state.lastAssistantAt < STREAM_IDLE_MS) { + scheduleScan(STREAM_IDLE_MS); + return; + } + + const userText = findPreviousUserText(message.node); + const bridgeKey = bridgeRequestKey(userText, assistantText); + const bridgeResult = state.bridgeCache.get(bridgeKey); + const prompts = bridgeResult?.prompts?.length ? bridgeResult.prompts : stepwisePayload.prompts; + + if (!bridgeResult) requestBridgeStepwise(bridgeKey, userText, assistantText); + + const nextHash = hashText(prompts.map((item) => `${item.label}\n${item.prompt}`).join("\n\n")); + if (state.currentHash !== `${hash}:${nextHash}`) { + state.currentHash = `${hash}:${nextHash}`; + state.prompts = prompts; + renderFloat(); + } + } + + function scheduleScan(delay = SCAN_DELAY_MS) { + if (!isCurrentInstance()) return; + if (state.timer) window.clearTimeout(state.timer); + state.timer = window.setTimeout(scan, delay); + } + + function installObserver() { + if (!isCurrentInstance()) return false; + const root = document.body || document.documentElement; + if (!root) return false; + + state.observer = new MutationObserver((mutations) => { + const relevant = mutations.some((mutation) => { + if (state.root?.contains(mutation.target)) return false; + return mutation.addedNodes.length || mutation.type === "characterData"; + }); + if (relevant) scheduleScan(); + }); + state.observer.observe(root, { + childList: true, + subtree: true, + characterData: true, + }); + return true; + } + + function stopRuntime() { + if (state.timer) window.clearTimeout(state.timer); + state.timer = 0; + window.removeEventListener("resize", onResize); + state.observer?.disconnect(); + state.observer = null; + state.themeObserver?.disconnect(); + state.themeObserver = null; + state.root?.remove(); + state.root = null; + state.fab = null; + state.popover = null; + document.getElementById(STYLE_ID)?.remove(); + state.open = false; + } + + function activateRuntime() { + installStyle(); + installFloat(); + if (!state.observer && !installObserver()) { + document.addEventListener( + "DOMContentLoaded", + () => { + if (!isCurrentInstance()) return; + installObserver(); + installFloat(); + void ensureSettings(); + scheduleScan(0); + }, + { once: true } + ); + } + scheduleScan(0); + } + + async function syncSettings(patch = {}) { + if (!isCurrentInstance()) return null; + if (patch && typeof patch === "object") { + state.settings = { ...(state.settings || {}), ...patch }; + } + if (patch?.enabled === false) { + stopRuntime(); + settingsPromise = null; + startupPromise = null; + const settings = await loadSettings(); + if (!isCurrentInstance()) return null; + if (settings?.enabled) activateRuntime(); + else pushDiagnostic("settings:disabled-sync", {}); + return settings; + } + if (patch?.enabled === true) { + pushDiagnostic("settings:enabled-sync", {}); + activateRuntime(); + return state.settings; + } + settingsPromise = null; + startupPromise = null; + const settings = await loadSettings(); + if (!isCurrentInstance()) return null; + if (!settings?.enabled) { + pushDiagnostic("settings:disabled-sync", {}); + stopRuntime(); + return settings; + } + pushDiagnostic("settings:enabled-sync", {}); + activateRuntime(); + return settings; + } + + function destroy() { + state.destroyed = true; + stopRuntime(); + if (window[API_KEY]?.instanceId === INSTANCE_ID) delete window[API_KEY]; + } + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function escapeAttr(value) { + return escapeHtml(value); + } + + async function start() { + if (startupPromise) return startupPromise; + startupPromise = (async () => { + const settings = await ensureSettings(); + if (!isCurrentInstance()) return; + if (!settings?.enabled) { + pushDiagnostic("startup:disabled", {}); + startupPromise = null; + return; + } + activateRuntime(); + })(); + return startupPromise; + } + + window[API_KEY] = { + version: SCRIPT_VERSION, + instanceId: INSTANCE_ID, + state, + scan, + start, + destroy, + loadSettings, + syncSettings, + renderFloat, + diagnostics: () => state.diagnostics.slice(), + }; + + void start(); +})(); diff --git a/crates/codex-plus-core/src/assets.rs b/crates/codex-plus-core/src/assets.rs index a87ab5a6b..64c33f7ce 100644 --- a/crates/codex-plus-core/src/assets.rs +++ b/crates/codex-plus-core/src/assets.rs @@ -6,6 +6,7 @@ use std::path::Path; use crate::settings::BackendSettings; const RENDERER_SCRIPT: &str = include_str!("../../../assets/inject/renderer-inject.js"); +const STEPWISE_SCRIPT: &str = include_str!("../../../assets/inject/stepwise-inject.js"); const SPONSOR_ALIPAY: &[u8] = include_bytes!("../../../assets/images/sponsor-alipay.jpg"); const SPONSOR_WECHAT: &[u8] = include_bytes!("../../../assets/images/sponsor-wechat.jpg"); pub const DIAGNOSTIC_BUILD_ID: &str = "diag-20260518-1"; @@ -14,6 +15,10 @@ pub fn renderer_script() -> &'static str { RENDERER_SCRIPT } +pub fn stepwise_script() -> &'static str { + STEPWISE_SCRIPT +} + pub fn sponsor_image_data_uris() -> Value { json!({ "alipay": image_data_uri("image/jpeg", SPONSOR_ALIPAY), @@ -32,7 +37,7 @@ pub fn injection_script_with_settings(helper_port: u16, settings: &BackendSettin let plugin_marketplaces = local_plugin_marketplaces(); let paste_fix = paste_fix_enabled_config(settings); format!( - "window.__CODEX_SESSION_DELETE_HELPER__ = {};\nwindow.__CODEX_PLUS_SPONSOR_IMAGES__ = {};\nwindow.__CODEX_PLUS_VERSION__ = {};\nwindow.__CODEX_PLUS_BUILD__ = {};\nwindow.__CODEX_PLUS_IMAGE_OVERLAY__ = {};\nwindow.__CODEX_PLUS_PLUGIN_MARKETPLACES__ = {};\nwindow.__CODEX_PLUS_PASTE_FIX__ = {};\n{}", + "window.__CODEX_SESSION_DELETE_HELPER__ = {};\nwindow.__CODEX_PLUS_SPONSOR_IMAGES__ = {};\nwindow.__CODEX_PLUS_VERSION__ = {};\nwindow.__CODEX_PLUS_BUILD__ = {};\nwindow.__CODEX_PLUS_IMAGE_OVERLAY__ = {};\nwindow.__CODEX_PLUS_PLUGIN_MARKETPLACES__ = {};\nwindow.__CODEX_PLUS_PASTE_FIX__ = {};\n{}\n{}", serde_json::to_string(&helper_url).expect("helper URL should serialize"), serde_json::to_string(&sponsor_images).expect("sponsor images should serialize"), serde_json::to_string(crate::version::VERSION).expect("version should serialize"), @@ -41,6 +46,7 @@ pub fn injection_script_with_settings(helper_port: u16, settings: &BackendSettin serde_json::to_string(&plugin_marketplaces).expect("plugin marketplaces should serialize"), serde_json::to_string(&paste_fix).expect("paste fix config should serialize"), renderer_script(), + stepwise_script(), ) } diff --git a/crates/codex-plus-core/src/lib.rs b/crates/codex-plus-core/src/lib.rs index 626898906..2bf426c21 100644 --- a/crates/codex-plus-core/src/lib.rs +++ b/crates/codex-plus-core/src/lib.rs @@ -31,6 +31,7 @@ pub mod routes; pub mod script_market; pub mod settings; pub mod status; +pub mod stepwise; pub mod update; pub mod upstream_worktree; pub mod user_scripts; diff --git a/crates/codex-plus-core/src/routes.rs b/crates/codex-plus-core/src/routes.rs index 82405b44c..4968a1cb3 100644 --- a/crates/codex-plus-core/src/routes.rs +++ b/crates/codex-plus-core/src/routes.rs @@ -91,6 +91,9 @@ pub trait BridgeRuntimeService: Send + Sync { async fn upstream_worktree_defaults(&self, payload: Value) -> anyhow::Result; async fn upstream_worktree_prepare(&self, payload: Value) -> anyhow::Result; async fn upstream_worktree_create(&self, payload: Value) -> anyhow::Result; + async fn stepwise_settings(&self) -> anyhow::Result; + async fn stepwise_generate(&self, payload: Value) -> anyhow::Result; + async fn stepwise_test(&self, payload: Value) -> anyhow::Result; } #[async_trait] @@ -196,6 +199,9 @@ pub async fn handle_bridge_request( ctx.runtime.upstream_worktree_prepare(payload.clone()).await } "/upstream-worktree/create" => ctx.runtime.upstream_worktree_create(payload.clone()).await, + "/stepwise/settings" => ctx.runtime.stepwise_settings().await, + "/stepwise/generate" => ctx.runtime.stepwise_generate(payload.clone()).await, + "/stepwise/test" => ctx.runtime.stepwise_test(payload.clone()).await, "/delete" => result_value(ctx.data.delete(session_from_payload(&payload)).await), "/undo" => { let undo_token = payload @@ -519,6 +525,30 @@ impl BridgeRuntimeService for CoreRuntimeService { async fn upstream_worktree_create(&self, payload: Value) -> anyhow::Result { Ok(crate::upstream_worktree::create_response(&payload)) } + + async fn stepwise_settings(&self) -> anyhow::Result { + let settings = SettingsStore::default().load().unwrap_or_default(); + Ok(json!({ + "status": "ok", + "settings": crate::stepwise::public_settings(&settings), + })) + } + + async fn stepwise_generate(&self, payload: Value) -> anyhow::Result { + let settings = SettingsStore::default().load().unwrap_or_default(); + let request = payload.get("request").cloned().unwrap_or(payload); + let request = + serde_json::from_value::(request).unwrap_or_default(); + crate::stepwise::generate(request, &settings).await + } + + async fn stepwise_test(&self, payload: Value) -> anyhow::Result { + let settings = crate::stepwise::settings_with_payload( + SettingsStore::default().load().unwrap_or_default(), + &payload, + ); + crate::stepwise::test_connection(&settings).await + } } struct UnavailableDataService; @@ -623,6 +653,7 @@ fn settings_payload_value( ) -> anyhow::Result { let mut value = serde_json::to_value(settings)?; if let Some(object) = value.as_object_mut() { + object.remove("codexAppStepwiseApiKey"); object.insert( "codexAppVersion".to_string(), Value::String(codex_app_version), diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index 0d2ea6fa9..d3d30260f 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -237,6 +237,46 @@ pub struct BackendSettings { pub codex_app_native_menu_localization: bool, #[serde(rename = "codexAppServiceTierControls", default)] pub codex_app_service_tier_controls: bool, + #[serde(rename = "codexAppStepwiseEnabled", default)] + pub codex_app_stepwise_enabled: bool, + #[serde(rename = "codexAppStepwiseDirectSend", default)] + pub codex_app_stepwise_direct_send: bool, + #[serde(rename = "codexAppStepwiseBaseUrl", default)] + pub codex_app_stepwise_base_url: String, + #[serde(rename = "codexAppStepwiseApiKey", default)] + pub codex_app_stepwise_api_key: String, + #[serde( + rename = "codexAppStepwiseApiKeyEnv", + default = "default_stepwise_api_key_env", + deserialize_with = "empty_as_default_stepwise_api_key_env" + )] + pub codex_app_stepwise_api_key_env: String, + #[serde(rename = "codexAppStepwiseModel", default)] + pub codex_app_stepwise_model: String, + #[serde( + rename = "codexAppStepwiseMaxItems", + default = "default_stepwise_max_items", + deserialize_with = "deserialize_stepwise_max_items" + )] + pub codex_app_stepwise_max_items: u8, + #[serde( + rename = "codexAppStepwiseMaxInputChars", + default = "default_stepwise_max_input_chars", + deserialize_with = "deserialize_stepwise_max_input_chars" + )] + pub codex_app_stepwise_max_input_chars: u32, + #[serde( + rename = "codexAppStepwiseMaxOutputTokens", + default = "default_stepwise_max_output_tokens", + deserialize_with = "deserialize_stepwise_max_output_tokens" + )] + pub codex_app_stepwise_max_output_tokens: u32, + #[serde( + rename = "codexAppStepwiseTimeoutMs", + default = "default_stepwise_timeout_ms", + deserialize_with = "deserialize_stepwise_timeout_ms" + )] + pub codex_app_stepwise_timeout_ms: u64, #[serde(rename = "codexAppImageOverlayEnabled", default)] pub codex_app_image_overlay_enabled: bool, #[serde(rename = "codexAppImageOverlayPath", default)] @@ -326,6 +366,16 @@ impl Default for BackendSettings { codex_app_native_menu_placement: true, codex_app_native_menu_localization: true, codex_app_service_tier_controls: false, + codex_app_stepwise_enabled: false, + codex_app_stepwise_direct_send: false, + codex_app_stepwise_base_url: String::new(), + codex_app_stepwise_api_key: String::new(), + codex_app_stepwise_api_key_env: default_stepwise_api_key_env(), + codex_app_stepwise_model: String::new(), + codex_app_stepwise_max_items: default_stepwise_max_items(), + codex_app_stepwise_max_input_chars: default_stepwise_max_input_chars(), + codex_app_stepwise_max_output_tokens: default_stepwise_max_output_tokens(), + codex_app_stepwise_timeout_ms: default_stepwise_timeout_ms(), codex_app_image_overlay_enabled: false, codex_app_image_overlay_path: String::new(), codex_app_image_overlay_opacity: default_image_overlay_opacity(), @@ -472,6 +522,26 @@ pub fn default_api_key_env() -> String { "CUSTOM_OPENAI_API_KEY".to_string() } +pub fn default_stepwise_api_key_env() -> String { + "CODEX_STEPWISE_API_KEY".to_string() +} + +pub fn default_stepwise_max_items() -> u8 { + 6 +} + +pub fn default_stepwise_max_input_chars() -> u32 { + 6000 +} + +pub fn default_stepwise_max_output_tokens() -> u32 { + 500 +} + +pub fn default_stepwise_timeout_ms() -> u64 { + 8000 +} + fn default_image_overlay_opacity() -> u8 { 35 } @@ -480,6 +550,22 @@ fn clamp_image_overlay_opacity(value: u8) -> u8 { value.clamp(1, 100) } +pub fn clamp_stepwise_max_items(value: u8) -> u8 { + value.min(default_stepwise_max_items()) +} + +pub fn clamp_stepwise_max_input_chars(value: u32) -> u32 { + value.clamp(1000, 24000) +} + +pub fn clamp_stepwise_max_output_tokens(value: u32) -> u32 { + value.clamp(100, 4000) +} + +pub fn clamp_stepwise_timeout_ms(value: u64) -> u64 { + value.clamp(1000, 60000) +} + pub fn default_true() -> bool { true } @@ -514,6 +600,16 @@ where .unwrap_or_else(default_api_key_env)) } +pub fn empty_as_default_stepwise_api_key_env<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value + .filter(|value| !value.is_empty()) + .unwrap_or_else(default_stepwise_api_key_env)) +} + fn deserialize_image_overlay_opacity<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -523,6 +619,42 @@ where .unwrap_or_else(default_image_overlay_opacity)) } +fn deserialize_stepwise_max_items<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)? + .map(clamp_stepwise_max_items) + .unwrap_or_else(default_stepwise_max_items)) +} + +fn deserialize_stepwise_max_input_chars<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)? + .map(clamp_stepwise_max_input_chars) + .unwrap_or_else(default_stepwise_max_input_chars)) +} + +fn deserialize_stepwise_max_output_tokens<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)? + .map(clamp_stepwise_max_output_tokens) + .unwrap_or_else(default_stepwise_max_output_tokens)) +} + +fn deserialize_stepwise_timeout_ms<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)? + .map(clamp_stepwise_timeout_ms) + .unwrap_or_else(default_stepwise_timeout_ms)) +} + fn deserialize_profile_api_key<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, @@ -678,6 +810,85 @@ fn merge_known_setting_fields(target: &mut Map, source: &Map BackendS } settings.codex_app_image_overlay_opacity = clamp_image_overlay_opacity(settings.codex_app_image_overlay_opacity); + settings.codex_app_stepwise_base_url = settings + .codex_app_stepwise_base_url + .trim() + .trim_end_matches('/') + .to_string(); + settings.codex_app_stepwise_api_key = settings.codex_app_stepwise_api_key.trim().to_string(); + settings.codex_app_stepwise_api_key_env = + if settings.codex_app_stepwise_api_key_env.trim().is_empty() { + default_stepwise_api_key_env() + } else { + settings.codex_app_stepwise_api_key_env.trim().to_string() + }; + settings.codex_app_stepwise_model = settings.codex_app_stepwise_model.trim().to_string(); + settings.codex_app_stepwise_max_items = + clamp_stepwise_max_items(settings.codex_app_stepwise_max_items); + settings.codex_app_stepwise_max_input_chars = + clamp_stepwise_max_input_chars(settings.codex_app_stepwise_max_input_chars); + settings.codex_app_stepwise_max_output_tokens = + clamp_stepwise_max_output_tokens(settings.codex_app_stepwise_max_output_tokens); + settings.codex_app_stepwise_timeout_ms = + clamp_stepwise_timeout_ms(settings.codex_app_stepwise_timeout_ms); settings } @@ -1059,6 +1291,19 @@ mod tests { assert_eq!(settings.relay_test_model, default_relay_test_model()); assert!(!settings.cli_wrapper_enabled); assert_eq!(settings.cli_wrapper_api_key_env, "CUSTOM_OPENAI_API_KEY"); + assert!(!settings.codex_app_stepwise_enabled); + assert!(!settings.codex_app_stepwise_direct_send); + assert!(settings.codex_app_stepwise_base_url.is_empty()); + assert!(settings.codex_app_stepwise_api_key.is_empty()); + assert_eq!( + settings.codex_app_stepwise_api_key_env, + "CODEX_STEPWISE_API_KEY" + ); + assert!(settings.codex_app_stepwise_model.is_empty()); + assert_eq!(settings.codex_app_stepwise_max_items, 6); + assert_eq!(settings.codex_app_stepwise_max_input_chars, 6000); + assert_eq!(settings.codex_app_stepwise_max_output_tokens, 500); + assert_eq!(settings.codex_app_stepwise_timeout_ms, 8000); } #[test] @@ -1646,6 +1891,45 @@ experimental_bearer_token = "sk-existing""# assert_eq!(store.load().unwrap(), updated); } + #[test] + fn settings_store_update_persists_stepwise_settings() { + let dir = temp_dir(); + let store = SettingsStore::new(dir.join("settings.json")); + + let updated = store + .update(json!({ + "codexAppStepwiseEnabled": true, + "codexAppStepwiseDirectSend": true, + "codexAppStepwiseBaseUrl": "https://api.example.test/v1/", + "codexAppStepwiseApiKey": " sk-stepwise ", + "codexAppStepwiseApiKeyEnv": "", + "codexAppStepwiseModel": " stepwise-mini ", + "codexAppStepwiseMaxItems": 12, + "codexAppStepwiseMaxInputChars": 25000, + "codexAppStepwiseMaxOutputTokens": 50, + "codexAppStepwiseTimeoutMs": 70000 + })) + .unwrap(); + + assert!(updated.codex_app_stepwise_enabled); + assert!(updated.codex_app_stepwise_direct_send); + assert_eq!( + updated.codex_app_stepwise_base_url, + "https://api.example.test/v1" + ); + assert_eq!(updated.codex_app_stepwise_api_key, "sk-stepwise"); + assert_eq!( + updated.codex_app_stepwise_api_key_env, + default_stepwise_api_key_env() + ); + assert_eq!(updated.codex_app_stepwise_model, "stepwise-mini"); + assert_eq!(updated.codex_app_stepwise_max_items, 6); + assert_eq!(updated.codex_app_stepwise_max_input_chars, 24000); + assert_eq!(updated.codex_app_stepwise_max_output_tokens, 100); + assert_eq!(updated.codex_app_stepwise_timeout_ms, 60000); + assert_eq!(store.load().unwrap(), updated); + } + #[test] fn settings_store_update_persists_launch_mode() { let dir = temp_dir(); diff --git a/crates/codex-plus-core/src/stepwise.rs b/crates/codex-plus-core/src/stepwise.rs new file mode 100644 index 000000000..2d881b108 --- /dev/null +++ b/crates/codex-plus-core/src/stepwise.rs @@ -0,0 +1,452 @@ +use std::time::Duration; + +use anyhow::Context; +use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::settings::BackendSettings; + +const MAX_PROMPT_LENGTH: usize = 420; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StepwiseRequest { + #[serde(default)] + pub last_user_message: String, + #[serde(default)] + pub last_assistant_message: String, + #[serde(default)] + pub thread_title: String, + #[serde(default)] + pub page_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StepwiseItem { + #[serde(default, skip_serializing_if = "String::is_empty")] + pub label: String, + pub prompt: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StepwisePublicSettings { + pub enabled: bool, + pub direct_send: bool, + pub base_url_configured: bool, + pub api_key_configured: bool, + pub api_key_env: String, + pub api_key_env_configured: bool, + pub model: String, + pub max_items: u8, + pub max_input_chars: u32, + pub max_output_tokens: u32, + pub timeout_ms: u64, +} + +pub fn public_settings(settings: &BackendSettings) -> StepwisePublicSettings { + StepwisePublicSettings { + enabled: settings.codex_app_stepwise_enabled, + direct_send: settings.codex_app_stepwise_direct_send, + base_url_configured: !settings.codex_app_stepwise_base_url.trim().is_empty(), + api_key_configured: !stepwise_api_key(settings).is_empty(), + api_key_env: settings.codex_app_stepwise_api_key_env.clone(), + api_key_env_configured: std::env::var(settings.codex_app_stepwise_api_key_env.trim()) + .map(|value| !value.trim().is_empty()) + .unwrap_or(false), + model: settings.codex_app_stepwise_model.clone(), + max_items: settings.codex_app_stepwise_max_items, + max_input_chars: settings.codex_app_stepwise_max_input_chars, + max_output_tokens: settings.codex_app_stepwise_max_output_tokens, + timeout_ms: settings.codex_app_stepwise_timeout_ms, + } +} + +pub fn settings_with_payload(mut settings: BackendSettings, payload: &Value) -> BackendSettings { + let Some(raw_settings) = payload.get("settings").and_then(Value::as_object) else { + return settings; + }; + if let Some(value) = raw_settings + .get("codexAppStepwiseEnabled") + .and_then(Value::as_bool) + { + settings.codex_app_stepwise_enabled = value; + } + if let Some(value) = raw_settings + .get("codexAppStepwiseDirectSend") + .and_then(Value::as_bool) + { + settings.codex_app_stepwise_direct_send = value; + } + if let Some(value) = raw_settings + .get("codexAppStepwiseBaseUrl") + .and_then(Value::as_str) + { + settings.codex_app_stepwise_base_url = value.trim().trim_end_matches('/').to_string(); + } + if let Some(value) = raw_settings + .get("codexAppStepwiseApiKey") + .and_then(Value::as_str) + { + settings.codex_app_stepwise_api_key = value.trim().to_string(); + } + if let Some(value) = raw_settings + .get("codexAppStepwiseApiKeyEnv") + .and_then(Value::as_str) + { + settings.codex_app_stepwise_api_key_env = if value.trim().is_empty() { + crate::settings::default_stepwise_api_key_env() + } else { + value.trim().to_string() + }; + } + if let Some(value) = raw_settings + .get("codexAppStepwiseModel") + .and_then(Value::as_str) + { + settings.codex_app_stepwise_model = value.trim().to_string(); + } + if let Some(value) = raw_settings + .get("codexAppStepwiseMaxItems") + .and_then(Value::as_u64) + .and_then(|value| u8::try_from(value).ok()) + { + settings.codex_app_stepwise_max_items = crate::settings::clamp_stepwise_max_items(value); + } + if let Some(value) = raw_settings + .get("codexAppStepwiseMaxInputChars") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + { + settings.codex_app_stepwise_max_input_chars = + crate::settings::clamp_stepwise_max_input_chars(value); + } + if let Some(value) = raw_settings + .get("codexAppStepwiseMaxOutputTokens") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + { + settings.codex_app_stepwise_max_output_tokens = + crate::settings::clamp_stepwise_max_output_tokens(value); + } + if let Some(value) = raw_settings + .get("codexAppStepwiseTimeoutMs") + .and_then(Value::as_u64) + { + settings.codex_app_stepwise_timeout_ms = crate::settings::clamp_stepwise_timeout_ms(value); + } + settings +} + +pub async fn generate( + request: StepwiseRequest, + settings: &BackendSettings, +) -> anyhow::Result { + if !settings.codex_app_stepwise_enabled { + return Ok(json!({ "status": "ok", "disabled": true, "items": [] })); + } + + let base_url = settings + .codex_app_stepwise_base_url + .trim() + .trim_end_matches('/'); + let api_key = stepwise_api_key(settings); + let model = settings.codex_app_stepwise_model.trim(); + let max_items = settings.codex_app_stepwise_max_items; + + if max_items == 0 { + return Ok(json!({ "status": "ok", "items": [] })); + } + if base_url.is_empty() || model.is_empty() { + return Ok(json!({ + "status": "failed", + "items": [], + "error": "Stepwise Base URL or Model is not configured" + })); + } + if api_key.is_empty() { + return Ok(json!({ + "status": "failed", + "items": [], + "error": "Stepwise API Key is not configured" + })); + } + + let client = crate::http_client::proxied_client("")?; + let timeout = Duration::from_millis(settings.codex_app_stepwise_timeout_ms); + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {api_key}")) + .context("failed to build Stepwise authorization header")?, + ); + + let response = client + .post(format!("{base_url}/chat/completions")) + .headers(headers) + .timeout(timeout) + .json(&json!({ + "model": model, + "messages": build_messages(&request, settings), + "temperature": 0.2, + "max_tokens": settings.codex_app_stepwise_max_output_tokens, + "response_format": { "type": "json_object" }, + })) + .send() + .await + .context("failed to request Stepwise API")?; + + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + if !status.is_success() { + return Ok(json!({ + "status": "failed", + "items": [], + "error": format!("Stepwise upstream {}: {}", status.as_u16(), text.chars().take(240).collect::()) + })); + } + + let data: Value = + serde_json::from_str(&text).context("failed to parse Stepwise API response")?; + let content = data + .get("choices") + .and_then(Value::as_array) + .and_then(|choices| choices.first()) + .and_then(|choice| choice.get("message")) + .and_then(|message| message.get("content")) + .and_then(Value::as_str) + .unwrap_or("{}"); + let parsed: Value = + serde_json::from_str(content).context("failed to parse Stepwise JSON content")?; + Ok(json!({ + "status": "ok", + "items": clamp_items(parsed.get("items").cloned().unwrap_or(Value::Null), max_items) + })) +} + +pub async fn test_connection(settings: &BackendSettings) -> anyhow::Result { + generate( + StepwiseRequest { + last_user_message: "测试 Stepwise 配置。".to_string(), + last_assistant_message: "Stepwise 应返回 0 到 6 条可直接发送的后续建议。".to_string(), + thread_title: "Codex++ Stepwise test".to_string(), + page_url: String::new(), + }, + settings, + ) + .await +} + +pub fn build_messages(request: &StepwiseRequest, settings: &BackendSettings) -> Vec { + let limit = settings.codex_app_stepwise_max_input_chars as usize; + let last_user_message = short_text(&request.last_user_message, limit.saturating_mul(35) / 100); + let last_assistant_message = short_text( + &request.last_assistant_message, + limit.saturating_mul(60) / 100, + ); + let language_input = if last_user_message.trim().is_empty() { + last_assistant_message.clone() + } else { + last_user_message.clone() + }; + let system_content = [ + "You generate concise Codex Stepwise actions.", + "Return strict JSON only, no markdown.", + "Schema: {\"items\":[{\"prompt\":\"...\",\"label\":\"optional short label\"}]}", + &format!( + "Generate 0 to {} items.", + settings.codex_app_stepwise_max_items + ), + "Every prompt must be directly sendable by the user.", + "Use the latest user intent and assistant result. Avoid generic filler.", + "Language policy: write Stepwise prompts in the dominant natural language of languageInput.", + "Ignore technical terms, file names, commands, APIs, and product names when detecting language; keep them in their original language when natural.", + "If there is no useful next action, return {\"items\":[]}.", + ] + .join("\n"); + vec![ + json!({ + "role": "system", + "content": system_content + }), + json!({ + "role": "user", + "content": json!({ + "lastUserMessage": last_user_message, + "lastAssistantMessage": last_assistant_message, + "languageInput": language_input, + "threadTitle": short_text(&request.thread_title, 240), + "pageUrl": short_text(&request.page_url, 240), + "maxItems": settings.codex_app_stepwise_max_items, + }).to_string() + }), + ] +} + +pub fn clamp_items(value: Value, max_items: u8) -> Vec { + let mut seen = std::collections::BTreeSet::new(); + let mut items = Vec::new(); + let max_items = usize::from(max_items); + let Some(raw_items) = value.as_array() else { + return items; + }; + + for raw in raw_items { + let prompt = if let Some(prompt) = raw.get("prompt").and_then(Value::as_str) { + prompt + } else if let Some(prompt) = raw.as_str() { + prompt + } else { + "" + }; + let prompt = normalize_spaces(prompt); + if prompt.is_empty() || seen.contains(&prompt) { + continue; + } + seen.insert(prompt.clone()); + let label = raw + .get("label") + .and_then(Value::as_str) + .map(normalize_spaces) + .unwrap_or_default(); + items.push(StepwiseItem { + label: short_text(&label, 36), + prompt: short_text(&prompt, MAX_PROMPT_LENGTH), + }); + if items.len() >= max_items { + break; + } + } + + items +} + +fn stepwise_api_key(settings: &BackendSettings) -> String { + let direct = settings.codex_app_stepwise_api_key.trim(); + if !direct.is_empty() { + return direct.to_string(); + } + std::env::var(settings.codex_app_stepwise_api_key_env.trim()) + .unwrap_or_default() + .trim() + .to_string() +} + +fn short_text(value: &str, limit: usize) -> String { + let text = normalize_text(value); + if text.chars().count() <= limit { + return text; + } + text.chars() + .rev() + .take(limit) + .collect::>() + .into_iter() + .rev() + .collect() +} + +fn normalize_text(value: &str) -> String { + value + .replace('\u{a0}', " ") + .lines() + .map(str::trim_end) + .collect::>() + .join("\n") + .split("\n\n\n") + .collect::>() + .join("\n\n") + .trim() + .to_string() +} + +fn normalize_spaces(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clamp_items_dedupes_and_limits() { + let items = clamp_items( + json!([ + {"label": "继续", "prompt": "继续排查"}, + {"label": "重复", "prompt": "继续排查"}, + {"prompt": "补测试"}, + "更新文档" + ]), + 2, + ); + + assert_eq!(items.len(), 2); + assert_eq!(items[0].label, "继续"); + assert_eq!(items[0].prompt, "继续排查"); + assert_eq!(items[1].prompt, "补测试"); + } + + #[test] + fn prompt_contains_language_policy() { + let settings = BackendSettings { + codex_app_stepwise_max_items: 4, + ..BackendSettings::default() + }; + let messages = build_messages( + &StepwiseRequest { + last_user_message: "请补一个 directSend selftest,覆盖 ProseMirror。".to_string(), + last_assistant_message: "已完成实现。".to_string(), + thread_title: String::new(), + page_url: String::new(), + }, + &settings, + ); + let system = messages[0].get("content").and_then(Value::as_str).unwrap(); + let user = messages[1].get("content").and_then(Value::as_str).unwrap(); + + assert!(system.contains("dominant natural language")); + assert!(system.contains("Generate 0 to 4 items.")); + assert!(user.contains("directSend")); + assert!(user.contains("languageInput")); + } + + #[test] + fn settings_with_payload_clamps_values() { + let settings = settings_with_payload( + BackendSettings::default(), + &json!({ + "settings": { + "codexAppStepwiseEnabled": true, + "codexAppStepwiseDirectSend": true, + "codexAppStepwiseBaseUrl": "https://api.example.test/v1/", + "codexAppStepwiseApiKey": " sk-test ", + "codexAppStepwiseApiKeyEnv": "", + "codexAppStepwiseModel": " stepwise-mini ", + "codexAppStepwiseMaxItems": 9, + "codexAppStepwiseMaxInputChars": 999999, + "codexAppStepwiseMaxOutputTokens": 10, + "codexAppStepwiseTimeoutMs": 999999 + } + }), + ); + + assert!(settings.codex_app_stepwise_enabled); + assert!(settings.codex_app_stepwise_direct_send); + assert_eq!( + settings.codex_app_stepwise_base_url, + "https://api.example.test/v1" + ); + assert_eq!(settings.codex_app_stepwise_api_key, "sk-test"); + assert_eq!( + settings.codex_app_stepwise_api_key_env, + crate::settings::default_stepwise_api_key_env() + ); + assert_eq!(settings.codex_app_stepwise_model, "stepwise-mini"); + assert_eq!(settings.codex_app_stepwise_max_items, 6); + assert_eq!(settings.codex_app_stepwise_max_input_chars, 24000); + assert_eq!(settings.codex_app_stepwise_max_output_tokens, 100); + assert_eq!(settings.codex_app_stepwise_timeout_ms, 60000); + } +} diff --git a/crates/codex-plus-core/tests/bridge_routes.rs b/crates/codex-plus-core/tests/bridge_routes.rs index ac09bba37..16bccd9ca 100644 --- a/crates/codex-plus-core/tests/bridge_routes.rs +++ b/crates/codex-plus-core/tests/bridge_routes.rs @@ -68,6 +68,12 @@ async fn bridge_routes_cover_all_current_paths() { "/upstream-worktree/create", json!({"repoPath": "/repo", "branchName": "feature/demo"}), ), + ("/stepwise/settings", json!({})), + ( + "/stepwise/generate", + json!({"request": {"lastUserMessage": "请继续", "lastAssistantMessage": "已完成"}}), + ), + ("/stepwise/test", json!({})), ("/delete", json!({"session_id": "s1", "title": "First"})), ("/undo", json!({"undo_token": "undo-1"})), ( @@ -118,6 +124,24 @@ async fn settings_get_includes_runtime_codex_app_version() { assert_eq!(result["codexAppThreadIdBadge"], json!(false)); } +#[tokio::test] +async fn settings_get_does_not_expose_stepwise_api_key_to_renderer() { + let settings = BackendSettings { + codex_app_stepwise_api_key: "sk-secret".to_string(), + ..BackendSettings::default() + }; + let ctx = BridgeContext::new( + Arc::new(FakeSettings::with_settings(settings)), + Arc::new(FakeRuntime::default()), + Arc::new(FakeData::default()), + ); + + let result = handle_bridge_request(ctx, "/settings/get", json!({})).await; + + assert!(result.get("codexAppStepwiseApiKey").is_none()); + assert_eq!(result["codexAppStepwiseApiKeyEnv"], json!("CODEX_STEPWISE_API_KEY")); +} + #[tokio::test] async fn settings_set_does_not_persist_runtime_codex_app_version() { let settings = Arc::new(FakeSettings::with_codex_app_version("26.601.21317")); @@ -217,6 +241,43 @@ async fn upstream_worktree_routes_are_dispatched_to_runtime() { ); } +#[tokio::test] +async fn stepwise_routes_are_dispatched_to_runtime() { + let ctx = test_context(); + + assert_eq!( + handle_bridge_request(ctx.clone(), "/stepwise/settings", json!({})).await, + json!({ + "status": "ok", + "settings": { + "enabled": true, + "directSend": false, + "model": "test-stepwise", + "maxItems": 6 + } + }) + ); + assert_eq!( + handle_bridge_request( + ctx.clone(), + "/stepwise/generate", + json!({"request": {"lastUserMessage": "请继续", "lastAssistantMessage": "已完成"}}), + ) + .await, + json!({ + "status": "ok", + "items": [{"label": "继续", "prompt": "继续排查 Stepwise"}] + }) + ); + assert_eq!( + handle_bridge_request(ctx, "/stepwise/test", json!({})).await, + json!({ + "status": "ok", + "items": [{"prompt": "测试 Stepwise"}] + }) + ); +} + #[tokio::test] async fn unknown_bridge_path_preserves_empty_session_id_shape() { let result = handle_bridge_request( @@ -930,6 +991,13 @@ struct FakeSettings { } impl FakeSettings { + fn with_settings(settings: BackendSettings) -> Self { + Self { + settings: Mutex::new(settings), + codex_app_version: Mutex::new(String::new()), + } + } + fn with_codex_app_version(version: &str) -> Self { Self { settings: Mutex::new(BackendSettings::default()), @@ -1178,6 +1246,33 @@ impl BridgeRuntimeService for FakeRuntime { "worktreePath": "/repo-feature-demo", })) } + + async fn stepwise_settings(&self) -> anyhow::Result { + Ok(json!({ + "status": "ok", + "settings": { + "enabled": true, + "directSend": false, + "model": "test-stepwise", + "maxItems": 6 + } + })) + } + + async fn stepwise_generate(&self, payload: Value) -> anyhow::Result { + assert_eq!(payload["request"]["lastUserMessage"], json!("请继续")); + Ok(json!({ + "status": "ok", + "items": [{"label": "继续", "prompt": "继续排查 Stepwise"}] + })) + } + + async fn stepwise_test(&self, _payload: Value) -> anyhow::Result { + Ok(json!({ + "status": "ok", + "items": [{"prompt": "测试 Stepwise"}] + })) + } } impl FakeRuntime { diff --git a/crates/codex-plus-core/tests/cdp_bridge.rs b/crates/codex-plus-core/tests/cdp_bridge.rs index fb172b95f..1151abfe9 100644 --- a/crates/codex-plus-core/tests/cdp_bridge.rs +++ b/crates/codex-plus-core/tests/cdp_bridge.rs @@ -141,6 +141,43 @@ fn injection_script_menu_exposes_marketplace_and_force_install_plugin_switches() assert!(!script.contains("data-codex-plus-setting=\"pluginEntryUnlock\"")); } +#[test] +fn injection_script_menu_exposes_stepwise_switch_and_syncs_panel() { + let script = assets::injection_script(57321); + + assert!(script.contains("stepwise: false")); + assert!(script.contains("stepwise: \"codexAppStepwiseEnabled\"")); + assert!(script.contains("Stepwise")); + assert!(script.contains("data-codex-plus-setting=\"stepwise\"")); + assert!(script.contains("function syncStepwisePanel")); + assert!(script.contains("window.__codexStepwisePanel?.syncSettings")); + assert!(script.contains("if (key === \"stepwise\") syncStepwisePanel(value)")); + assert!(script.contains("if (patch?.enabled === true)")); + assert!(script.contains("activateRuntime();")); +} + +#[test] +fn injection_script_defers_backend_mapped_toggles_until_settings_load() { + let script = assets::injection_script(57321); + + assert!(script.contains("const codexPlusBackendMappedSettings = new Set")); + assert!(script.contains("codexPlusBackendMappedSettings.has(key) && !codexPlusBackendSettingsLoaded")); + assert!(script.contains("button.dataset.pending = String(waitsForBackend)")); + assert!(script.contains("button.disabled = waitsForBackend || button.dataset.relayUnneeded === \"true\"")); + assert!(script.contains("toggle.disabled || toggle.dataset.pending === \"true\"")); +} + +#[test] +fn injection_script_ignores_stale_backend_settings_responses() { + let script = assets::injection_script(57321); + + assert!(script.contains("let codexPlusBackendSettingsSeq = 0")); + assert!(script.contains("const seq = codexPlusBackendSettingsSeq")); + assert!(script.contains("if (seq !== codexPlusBackendSettingsSeq)")); + assert!(script.contains("const seq = ++codexPlusBackendSettingsSeq")); + assert!(script.contains("if (seq === codexPlusBackendSettingsSeq)")); +} + #[test] fn injection_script_skips_plugin_patch_work_in_relay_mode() { let script = assets::injection_script(57321); From aa147c1784cf79b190927cc70393310961edb262 Mon Sep 17 00:00:00 2001 From: Ghibli1024 Date: Sun, 28 Jun 2026 21:31:14 +0800 Subject: [PATCH 2/4] refine Stepwise settings UI and composer targeting --- apps/codex-plus-manager/src/App.tsx | 130 +++++++------ apps/codex-plus-manager/src/styles.css | 44 +++++ assets/inject/stepwise-inject.js | 204 +++++++++++++++++++-- crates/codex-plus-core/tests/cdp_bridge.rs | 12 ++ 4 files changed, 317 insertions(+), 73 deletions(-) diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 5a7a8977d..64f4257f0 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -1527,6 +1527,21 @@ export function App() { if (result) showNotice("供应商测试", result.message, result.status); }; + const testStepwiseSettings = async (settings: BackendSettings) => { + await testRelayProfile({ + ...defaultSettings.relayProfiles[0], + id: "stepwise-test", + name: "Stepwise", + model: settings.codexAppStepwiseModel, + baseUrl: settings.codexAppStepwiseBaseUrl, + upstreamBaseUrl: settings.codexAppStepwiseBaseUrl, + apiKey: settings.codexAppStepwiseApiKey, + protocol: "chatCompletions", + relayMode: "pureApi", + testModel: settings.codexAppStepwiseModel, + }); + }; + const fetchRelayProfileModels = async (profile: RelayProfile) => { const result = await run(() => call("fetch_relay_profile_models", { profile })); if (result) showNotice("模型列表", result.message, result.status); @@ -1854,6 +1869,7 @@ export function App() { deleteContextEntry, extractRelayCommonConfig, testRelayProfile, + testStepwiseSettings, fetchRelayProfileModels, switchRelayProfile, relaySwitching, @@ -2122,6 +2138,7 @@ type Actions = { deleteContextEntry: (settings: BackendSettings, kind: ContextKind, id: string) => Promise; extractRelayCommonConfig: (configContents: string) => Promise; testRelayProfile: (profile: RelayProfile) => Promise; + testStepwiseSettings: (settings: BackendSettings) => Promise; fetchRelayProfileModels: (profile: RelayProfile) => Promise; switchRelayProfile: (settings: BackendSettings, previousActiveRelayId?: string) => Promise; relaySwitching: boolean; @@ -3494,16 +3511,17 @@ function SettingsScreen({ />
-
Stepwise API
+
Stepwise
+
连接
- + onFormChange({ ...form, codexAppStepwiseBaseUrl: event.currentTarget.value })} placeholder="https://api.example.com/v1" /> - + onFormChange({ ...form, codexAppStepwiseModel: event.currentTarget.value })} @@ -3511,55 +3529,56 @@ function SettingsScreen({ />
-
- - onFormChange({ ...form, codexAppStepwiseApiKey: event.currentTarget.value })} - /> - - - onFormChange({ ...form, codexAppStepwiseApiKeyEnv: event.currentTarget.value })} - /> - -
-
- - - onFormChange({ ...form, codexAppStepwiseMaxItems: clampNumber(Number(event.currentTarget.value), 0, 6) }) - } - /> - - - - onFormChange({ ...form, codexAppStepwiseTimeoutMs: clampNumber(Number(event.currentTarget.value), 1000, 60000) }) - } - /> - -
-
- - - onFormChange({ ...form, codexAppStepwiseMaxInputChars: clampNumber(Number(event.currentTarget.value), 1000, 24000) }) - } - /> - + + onFormChange({ ...form, codexAppStepwiseApiKey: event.currentTarget.value })} + /> + +
+ 高级参数 +
+ + onFormChange({ ...form, codexAppStepwiseApiKeyEnv: event.currentTarget.value })} + /> + + + + onFormChange({ ...form, codexAppStepwiseMaxItems: clampNumber(Number(event.currentTarget.value), 0, 6) }) + } + /> + +
+
+ + + onFormChange({ ...form, codexAppStepwiseTimeoutMs: clampNumber(Number(event.currentTarget.value), 1000, 60000) }) + } + /> + + + + onFormChange({ ...form, codexAppStepwiseMaxInputChars: clampNumber(Number(event.currentTarget.value), 1000, 24000) }) + } + /> + +
-
- + +
+ - +