diff --git a/README.md b/README.md
index 0cb8d6b9..2670a370 100644
--- a/README.md
+++ b/README.md
@@ -65,7 +65,7 @@ CharacterArc(弧光)不是"只会对话的 AI 壳子",而是一套围绕
🌐 多厂商接入
- 支持所有 OpenAI 兼容接口(DeepSeek、通义千问、智谱、Kimi、SiliconFlow、Ollama 等)和 Anthropic 协议(官方及中转站),只需选协议填地址即可。
+ 支持 Codex CLI、OpenAI 兼容接口(DeepSeek、通义千问、智谱、Kimi、SiliconFlow、Ollama 等)和 Anthropic 协议(官方及中转站)。
|
@@ -184,7 +184,7 @@ CharacterArc(弧光)不是"只会对话的 AI 壳子",而是一套围绕
| 富文本编辑 | TipTap |
| 持久化 | SQLite(主进程) |
| 关系图谱 | Cytoscape |
-| AI SDK | Vercel AI SDK (@ai-sdk/openai, @ai-sdk/anthropic) |
+| AI 接入 | Vercel AI SDK(OpenAI / Anthropic)+ 本机 Codex CLI |
| 文档解析 | mammoth (.docx)、marked (Markdown) |
## 📋 环境要求
@@ -234,6 +234,7 @@ macOS 产物为 ad-hoc 签名、未公证的 `.dmg` / `.zip`,适合内部安
📝 文本生成
- 支持维护多套接口配置,并在标题栏快速切换
+ - Codex CLI:复用本机登录状态,无需 API Key;支持自动查找路径、拉取模型和设置推理强度
- 协议类型:OpenAI 兼容协议 / Anthropic 协议
- Base URL(只需填域名或路径前缀,系统自动补全 /v1)
- API Key
diff --git a/electron/main/ai/agent/run-agent.ts b/electron/main/ai/agent/run-agent.ts
index 04f2a805..309b2314 100644
--- a/electron/main/ai/agent/run-agent.ts
+++ b/electron/main/ai/agent/run-agent.ts
@@ -5,6 +5,8 @@ import type { Tool, ToolContext } from './tools/types'
import { stripReasoningMarkup } from '../reasoning'
import { isAiStreamIdleTimeoutError } from '../sse'
import { resolveSamplingOptions } from '../request-options'
+import { isCodexCliProvider } from '@shared/ai-provider-catalog'
+import { runCodexCli } from '../codex-cli'
export type RunAgentParams = {
settings: AppSettings
@@ -94,6 +96,26 @@ function mergeUsage(a: AiRunUsage | undefined, b: AiRunUsage | undefined): AiRun
export async function runAgent(params: RunAgentParams): Promise {
const maxSteps = params.maxSteps ?? 8
+ if (isCodexCliProvider(params.settings.provider)) {
+ params.handlers.onAgentStatus('正在通过 Codex CLI 思考...', 1, 1)
+ const result = await runCodexCli(
+ params.settings,
+ {
+ system: [
+ params.systemPrompt,
+ '当前通过 Codex CLI 文本模式运行,CharacterArc 的进程内工具不可用。请直接根据已提供的上下文回答,不要尝试调用工具。'
+ ].join('\n\n'),
+ user: params.userPrompt
+ },
+ { signal: params.ctx.signal, handlers: params.handlers }
+ )
+ return {
+ finalText: stripReasoningMarkup(result.text),
+ toolCalls: [],
+ iterations: 1,
+ usage: result.usage
+ }
+ }
const toolCalls: ToolCallTrace[] = []
const toolStartTimes = new Map()
let stepCount = 0
diff --git a/electron/main/ai/codex-cli.test.mjs b/electron/main/ai/codex-cli.test.mjs
new file mode 100644
index 00000000..98309192
--- /dev/null
+++ b/electron/main/ai/codex-cli.test.mjs
@@ -0,0 +1,81 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { buildCodexExecArgs, parseCodexJsonLine, resolveCodexCommand } from './codex-cli.ts'
+import { normalizeSettings, validateSettings } from './settings.ts'
+import { getAiProviderCatalogEntry, shouldTryStreamingAgent } from '../../shared/ai-provider-catalog.ts'
+
+const codexSettings = {
+ provider: 'codex-cli',
+ model: '',
+ apiKey: '',
+ baseUrl: '',
+ codexCliPath: '',
+ codexReasoningEffort: 'default',
+ embeddingModel: '',
+ imageModel: '',
+ imageApiKey: '',
+ imageBaseUrl: ''
+}
+
+test('Codex CLI 预设不要求 API 地址或 Key,并使用 default 模型', () => {
+ const preset = getAiProviderCatalogEntry('codex-cli')
+ const normalized = normalizeSettings(codexSettings)
+
+ assert.equal(preset?.transport, 'codex-cli')
+ assert.equal(preset?.supportsEmbedding, false)
+ assert.equal(normalized.model, 'default')
+ assert.equal(normalized.baseUrl, '')
+ assert.doesNotThrow(() => validateSettings(normalized))
+ assert.equal(shouldTryStreamingAgent('global-assistant', 'codex-cli', 'default'), false)
+})
+
+test('Codex exec 使用只读 JSONL 与 stdin,并传递模型和推理强度', () => {
+ const args = buildCodexExecArgs({
+ ...codexSettings,
+ model: 'gpt-5.6-sol',
+ codexReasoningEffort: 'high'
+ })
+
+ assert.deepEqual(args.slice(0, 6), [
+ 'exec',
+ '--json',
+ '--skip-git-repo-check',
+ '--sandbox',
+ 'read-only',
+ '-c'
+ ])
+ assert.ok(args.includes('features.shell_tool=false'))
+ assert.ok(args.includes('model_reasoning_effort="high"'))
+ assert.deepEqual(args.slice(-3), ['--model', 'gpt-5.6-sol', '-'])
+})
+
+test('Codex CLI 路径拒绝混入 shell 环境变量', () => {
+ assert.throws(
+ () => resolveCodexCommand({
+ ...codexSettings,
+ codexCliPath: 'HTTPS_PROXY=http://127.0.0.1:7890 /opt/homebrew/bin/codex'
+ }),
+ /只能填写可执行文件路径/
+ )
+})
+
+test('Codex JSONL 事件转换为正文、推理和 token 用量', () => {
+ const reasoning = []
+ assert.deepEqual(
+ parseCodexJsonLine(
+ '{"type":"item.completed","item":{"type":"reasoning","text":"分析中"}}',
+ { onTextDelta() {}, onReasoningDelta: (delta) => reasoning.push(delta) }
+ ),
+ {}
+ )
+ assert.deepEqual(reasoning, ['分析中'])
+ assert.deepEqual(
+ parseCodexJsonLine('{"type":"item.completed","item":{"type":"agent_message","text":"完成"}}'),
+ { text: '完成' }
+ )
+ assert.deepEqual(
+ parseCodexJsonLine('{"type":"turn.completed","usage":{"input_tokens":12,"output_tokens":8}}'),
+ { usage: { promptTokens: 12, completionTokens: 8, totalTokens: 20 } }
+ )
+})
diff --git a/electron/main/ai/codex-cli.ts b/electron/main/ai/codex-cli.ts
new file mode 100644
index 00000000..f1860268
--- /dev/null
+++ b/electron/main/ai/codex-cli.ts
@@ -0,0 +1,420 @@
+import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
+import { constants as fsConstants, accessSync, existsSync, statSync } from 'node:fs'
+import { delimiter, dirname, extname, join } from 'node:path'
+import { homedir, tmpdir } from 'node:os'
+import { createInterface } from 'node:readline'
+import type { AiRunUsage, AppSettings, AiStreamHandlers, PromptPair } from './shared-types'
+import { normalizeProxyUrl } from './proxy-fetch'
+
+const CODEX_MODEL_DISCOVERY_TIMEOUT_MS = 15_000
+const CODEX_COMMAND_TIMEOUT_MS = 15_000
+const MAX_CAPTURED_OUTPUT = 5 * 1024 * 1024
+const DEFAULT_CODEX_MODELS = ['default', 'gpt-5.5', 'gpt-5.4-mini']
+
+export type CodexCliModel = {
+ id: string
+ ownedBy: string | null
+}
+
+export type CodexCliResult = {
+ text: string
+ usage?: AiRunUsage
+}
+
+type CodexCommand = {
+ program: string
+ prefixArgs: string[]
+}
+
+type CapturedCommand = {
+ stdout: string
+ stderr: string
+ exitCode: number | null
+}
+
+function isExecutable(path: string): boolean {
+ try {
+ if (!statSync(path).isFile()) return false
+ if (process.platform !== 'win32') accessSync(path, fsConstants.X_OK)
+ return true
+ } catch {
+ return false
+ }
+}
+
+function executableNames(): string[] {
+ return process.platform === 'win32'
+ ? ['codex.cmd', 'codex.exe', 'codex.bat', 'codex.com', 'codex']
+ : ['codex']
+}
+
+function candidateExecutableDirs(): string[] {
+ const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean)
+ if (process.platform === 'win32') {
+ if (process.env.APPDATA) dirs.push(join(process.env.APPDATA, 'npm'))
+ } else {
+ if (process.env.HOME) {
+ dirs.push(join(process.env.HOME, '.local', 'bin'))
+ dirs.push(join(process.env.HOME, '.npm-global', 'bin'))
+ dirs.push(join(process.env.HOME, '.volta', 'bin'))
+ }
+ dirs.push('/opt/homebrew/bin', '/usr/local/bin', '/usr/bin')
+ dirs.push('/Applications/ChatGPT.app/Contents/Resources')
+ }
+ return [...new Set(dirs)]
+}
+
+function findCodexInDirectory(directory: string): string | null {
+ for (const name of executableNames()) {
+ const candidate = join(directory, name)
+ if (isExecutable(candidate)) return candidate
+ }
+ return null
+}
+
+function looksLikeEnvironmentAssignment(value: string): boolean {
+ const firstToken = value.split(/\s+/, 1)[0] ?? ''
+ return /^[A-Za-z_][A-Za-z\d_]*=/.test(firstToken)
+}
+
+function expandHomePath(value: string): string {
+ if (value === '~') return homedir()
+ if (value.startsWith('~/') || value.startsWith('~\\')) {
+ return join(homedir(), value.slice(2))
+ }
+ return value
+}
+
+function commandForExecutable(executable: string): CodexCommand {
+ if (process.platform !== 'win32' || !['.cmd', '.bat'].includes(extname(executable).toLowerCase())) {
+ return { program: executable, prefixArgs: [] }
+ }
+
+ // npm 在 Windows 上安装的是 cmd shim。直接调用相邻的 JS 入口,可以避免
+ // shell quoting,也能正确处理包含空格的用户名和安装路径。
+ const codexJs = join(dirname(executable), 'node_modules', '@openai', 'codex', 'bin', 'codex.js')
+ if (!existsSync(codexJs)) {
+ throw new Error('当前 Codex CLI 路径是 .cmd/.bat,但未找到对应的 @openai/codex JS 入口。请填写 codex.exe 或 npm 全局安装目录中的 codex.cmd。')
+ }
+ const bundledNode = join(dirname(executable), 'node.exe')
+ return {
+ program: isExecutable(bundledNode) ? bundledNode : 'node',
+ prefixArgs: [codexJs]
+ }
+}
+
+/** 解析用户配置的 Codex 路径;留空时兼容 Electron GUI 常见的精简 PATH。 */
+export function resolveCodexCommand(settings: AppSettings): CodexCommand {
+ const configured = settings.codexCliPath?.trim() ?? ''
+ if (looksLikeEnvironmentAssignment(configured)) {
+ throw new Error('Codex CLI 路径只能填写可执行文件路径,不能包含环境变量或启动命令。')
+ }
+
+ if (configured) {
+ const expanded = expandHomePath(configured)
+ let executable = expanded
+ try {
+ if (statSync(expanded).isDirectory()) {
+ executable = findCodexInDirectory(expanded) ?? ''
+ }
+ } catch {
+ // 下面统一给出可操作的路径错误。
+ }
+ if (!executable || !isExecutable(executable)) {
+ throw new Error('未找到指定的 Codex CLI。请填写 codex 可执行文件,或包含它的目录。')
+ }
+ return commandForExecutable(executable)
+ }
+
+ for (const directory of candidateExecutableDirs()) {
+ const executable = findCodexInDirectory(directory)
+ if (executable) return commandForExecutable(executable)
+ }
+
+ // 保留裸命令作为最后兜底,让 spawn 返回准确的 ENOENT 错误。
+ return { program: 'codex', prefixArgs: [] }
+}
+
+function buildCodexEnvironment(settings: AppSettings): NodeJS.ProcessEnv {
+ const env = { ...process.env }
+ const proxyUrl = settings.proxyUrl?.trim() ? normalizeProxyUrl(settings.proxyUrl) : ''
+ if (proxyUrl) {
+ env.HTTP_PROXY = proxyUrl
+ env.HTTPS_PROXY = proxyUrl
+ env.http_proxy = proxyUrl
+ env.https_proxy = proxyUrl
+ }
+ return env
+}
+
+function buildCodexPrompt(settings: AppSettings, prompt: PromptPair): string {
+ return [
+ prompt.system.trim(),
+ '',
+ '【运行时模型信息】',
+ `当前请求使用 Codex CLI,模型标识:${settings.model || 'default'}。`,
+ '',
+ '【用户请求】',
+ prompt.user.trim()
+ ].join('\n')
+}
+
+/** 构建只读、无持久会话的 Codex exec 参数。Prompt 始终通过 stdin 传入。 */
+export function buildCodexExecArgs(settings: AppSettings): string[] {
+ const args = [
+ 'exec',
+ '--json',
+ '--skip-git-repo-check',
+ '--sandbox',
+ 'read-only',
+ '-c',
+ 'features.shell_tool=false',
+ '-c',
+ 'web_search="disabled"'
+ ]
+ const effort = settings.codexReasoningEffort?.trim().toLowerCase()
+ if (effort && effort !== 'default') {
+ args.push('-c', `model_reasoning_effort=${JSON.stringify(effort)}`)
+ }
+ const model = settings.model?.trim()
+ if (model && model.toLowerCase() !== 'default') {
+ args.push('--model', model)
+ }
+ args.push('-')
+ return args
+}
+
+function makeAbortError(): Error {
+ const error = new Error('任务已取消。')
+ error.name = 'AbortError'
+ return error
+}
+
+function classifySpawnError(error: unknown): Error {
+ const message = error instanceof Error ? error.message : String(error)
+ if (/ENOENT|not found|no such file/i.test(message)) {
+ return new Error('未找到 Codex CLI。请先安装 @openai/codex,或在设置中填写 Codex CLI 路径。')
+ }
+ return new Error(`Codex CLI 启动失败:${message}`)
+}
+
+function classifyRunError(stderr: string, fallback = 'Codex CLI 执行失败。'): Error {
+ const detail = stderr.trim()
+ if (/not authenticated|log\s*in|login|authentication/i.test(detail)) {
+ return new Error('Codex CLI 尚未登录。请先在终端执行 codex login。')
+ }
+ return new Error(detail ? `Codex CLI 执行失败:${detail.slice(0, 1200)}` : fallback)
+}
+
+function appendCaptured(current: string, chunk: Buffer | string): string {
+ if (current.length >= MAX_CAPTURED_OUTPUT) return current
+ return (current + chunk.toString()).slice(0, MAX_CAPTURED_OUTPUT)
+}
+
+export function parseCodexJsonLine(
+ line: string,
+ handlers?: AiStreamHandlers
+): { text?: string; usage?: AiRunUsage; error?: string } {
+ let value: Record
+ try {
+ value = JSON.parse(line) as Record
+ } catch {
+ return {}
+ }
+
+ const type = String(value.type ?? '')
+ if (type === 'turn.failed' || type === 'error') {
+ const nested = value.error && typeof value.error === 'object'
+ ? String((value.error as Record).message ?? '')
+ : String(value.error ?? value.message ?? '')
+ return { error: nested || 'Codex CLI 返回失败事件。' }
+ }
+
+ if (type === 'turn.completed') {
+ const usage = value.usage && typeof value.usage === 'object'
+ ? value.usage as Record
+ : null
+ if (!usage) return {}
+ const promptTokens = Number(usage.input_tokens ?? usage.prompt_tokens)
+ const completionTokens = Number(usage.output_tokens ?? usage.completion_tokens)
+ const normalized: AiRunUsage = {
+ promptTokens: Number.isFinite(promptTokens) ? promptTokens : undefined,
+ completionTokens: Number.isFinite(completionTokens) ? completionTokens : undefined
+ }
+ if (normalized.promptTokens !== undefined || normalized.completionTokens !== undefined) {
+ normalized.totalTokens = (normalized.promptTokens ?? 0) + (normalized.completionTokens ?? 0)
+ return { usage: normalized }
+ }
+ return {}
+ }
+
+ if (type !== 'item.completed' || !value.item || typeof value.item !== 'object') return {}
+ const item = value.item as Record
+ const itemType = String(item.type ?? '')
+ const text = typeof item.text === 'string'
+ ? item.text
+ : typeof item.message === 'string'
+ ? item.message
+ : typeof item.content === 'string'
+ ? item.content
+ : ''
+ if (!text) return {}
+ if (itemType === 'reasoning') {
+ handlers?.onReasoningDelta?.(text)
+ return {}
+ }
+ return itemType === 'agent_message' ? { text } : {}
+}
+
+/** 通过 Codex CLI 完成一次生成;JSONL 事件会映射到现有流式回调。 */
+export async function runCodexCli(
+ settings: AppSettings,
+ prompt: PromptPair,
+ options: { signal?: AbortSignal; handlers?: AiStreamHandlers } = {}
+): Promise {
+ options.signal?.throwIfAborted()
+ const command = resolveCodexCommand(settings)
+ const args = [...command.prefixArgs, ...buildCodexExecArgs(settings)]
+ const child = spawn(command.program, args, {
+ cwd: tmpdir(),
+ env: buildCodexEnvironment(settings),
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true
+ }) as ChildProcessWithoutNullStreams
+
+ return await new Promise((resolve, reject) => {
+ let fullText = ''
+ let stderr = ''
+ let usage: AiRunUsage | undefined
+ let eventError = ''
+ let settled = false
+ const lines = createInterface({ input: child.stdout })
+
+ const cleanup = () => {
+ options.signal?.removeEventListener('abort', abort)
+ lines.close()
+ }
+ const finishReject = (error: Error) => {
+ if (settled) return
+ settled = true
+ cleanup()
+ reject(error)
+ }
+ const abort = () => {
+ child.kill()
+ finishReject(makeAbortError())
+ }
+
+ options.signal?.addEventListener('abort', abort, { once: true })
+ child.on('error', (error) => finishReject(classifySpawnError(error)))
+ child.stderr.on('data', (chunk) => { stderr = appendCaptured(stderr, chunk) })
+ lines.on('line', (line) => {
+ const parsed = parseCodexJsonLine(line, options.handlers)
+ if (parsed.text) {
+ fullText += parsed.text
+ options.handlers?.onTextDelta(parsed.text)
+ }
+ if (parsed.usage) usage = parsed.usage
+ if (parsed.error) eventError = parsed.error
+ })
+ child.on('close', (exitCode) => {
+ if (settled) return
+ settled = true
+ cleanup()
+ if (options.signal?.aborted) {
+ reject(makeAbortError())
+ } else if (eventError) {
+ reject(classifyRunError(eventError))
+ } else if (exitCode !== 0) {
+ reject(classifyRunError(stderr))
+ } else if (!fullText.trim()) {
+ reject(new Error('Codex CLI 已结束,但没有返回可见正文。'))
+ } else {
+ resolve({ text: fullText, usage })
+ }
+ })
+
+ child.stdin.on('error', (error) => finishReject(classifyRunError(String(error))))
+ child.stdin.end(buildCodexPrompt(settings, prompt))
+ })
+}
+
+async function captureCodexCommand(
+ settings: AppSettings,
+ args: string[],
+ timeoutMs = CODEX_COMMAND_TIMEOUT_MS
+): Promise {
+ const command = resolveCodexCommand(settings)
+ const child = spawn(command.program, [...command.prefixArgs, ...args], {
+ cwd: tmpdir(),
+ env: buildCodexEnvironment(settings),
+ stdio: ['ignore', 'pipe', 'pipe'],
+ windowsHide: true
+ })
+
+ return await new Promise((resolve, reject) => {
+ let stdout = ''
+ let stderr = ''
+ let settled = false
+ const timer = setTimeout(() => {
+ child.kill()
+ if (!settled) {
+ settled = true
+ reject(new Error('Codex CLI 响应超时。'))
+ }
+ }, timeoutMs)
+ child.on('error', (error) => {
+ clearTimeout(timer)
+ if (!settled) {
+ settled = true
+ reject(classifySpawnError(error))
+ }
+ })
+ child.stdout?.on('data', (chunk) => { stdout = appendCaptured(stdout, chunk) })
+ child.stderr?.on('data', (chunk) => { stderr = appendCaptured(stderr, chunk) })
+ child.on('close', (exitCode) => {
+ clearTimeout(timer)
+ if (!settled) {
+ settled = true
+ resolve({ stdout, stderr, exitCode })
+ }
+ })
+ })
+}
+
+export async function testCodexCliConnection(settings: AppSettings): Promise {
+ const result = await captureCodexCommand(settings, ['login', 'status'])
+ if (result.exitCode !== 0) throw classifyRunError(result.stderr || result.stdout)
+}
+
+function parseCodexModels(stdout: string): CodexCliModel[] | null {
+ const jsonStart = stdout.indexOf('{')
+ if (jsonStart < 0) return null
+ try {
+ const payload = JSON.parse(stdout.slice(jsonStart)) as { models?: Array> }
+ if (!Array.isArray(payload.models)) return null
+ const ids = payload.models
+ .map((item) => String(item.slug ?? item.id ?? '').trim())
+ .filter(Boolean)
+ if (ids.length === 0) return null
+ return ['default', ...new Set(ids)].map((id) => ({ id, ownedBy: 'codex-cli' }))
+ } catch {
+ return null
+ }
+}
+
+export async function fetchCodexCliModels(settings: AppSettings): Promise {
+ const discovered = await captureCodexCommand(settings, ['debug', 'models'], CODEX_MODEL_DISCOVERY_TIMEOUT_MS)
+ const discoveredModels = discovered.exitCode === 0 ? parseCodexModels(discovered.stdout) : null
+ if (discoveredModels) return discoveredModels
+
+ // 较旧版本或未登录环境可能无法刷新目录,继续读取 CLI 内置目录;再失败时
+ // 保留 DBX 同类实现采用的静态入口,用户仍可手动填写任意模型名。
+ const bundled = await captureCodexCommand(
+ settings,
+ ['debug', 'models', '--bundled'],
+ CODEX_MODEL_DISCOVERY_TIMEOUT_MS
+ )
+ return (bundled.exitCode === 0 ? parseCodexModels(bundled.stdout) : null)
+ ?? DEFAULT_CODEX_MODELS.map((id) => ({ id, ownedBy: 'codex-cli' }))
+}
diff --git a/electron/main/ai/generate.ts b/electron/main/ai/generate.ts
index 0137c3c1..27c66a33 100644
--- a/electron/main/ai/generate.ts
+++ b/electron/main/ai/generate.ts
@@ -5,8 +5,9 @@ import { buildSystemPrompt, createModel, providerSupportsNativeStructuredOutput
import type { AiRunUsage, AppSettings, AiStreamHandlers, PromptPair } from './shared-types'
import { stripReasoningMarkup } from './reasoning'
import { isOpenAIReasoningChatModel, resolveProviderOptions, resolveSamplingOptions } from './request-options'
-import { isAnthropicProtocol, isOpenAIChatProtocol } from '@shared/ai-provider-catalog'
+import { isAnthropicProtocol, isCodexCliProvider, isOpenAIChatProtocol } from '@shared/ai-provider-catalog'
import { AiStreamProtocolError } from './sse'
+import { runCodexCli } from './codex-cli'
function useStreamFallback(settings: AppSettings): boolean {
return isAnthropicProtocol(settings.provider, settings.model)
@@ -82,6 +83,9 @@ export async function aiGenerateTextWithUsage(
signal?: AbortSignal,
options?: AiGenerateOptions
): Promise {
+ if (isCodexCliProvider(settings.provider)) {
+ return runCodexCli(settings, prompt, { signal })
+ }
const system = buildSystemPrompt(settings, prompt.system)
const canUseNativeStructuredOutput = providerSupportsNativeStructuredOutput(settings)
const providerOptions = resolveProviderOptions(settings, options)
@@ -195,6 +199,9 @@ export async function aiStreamTextWithUsage(
signal: AbortSignal,
maxTokens?: number
): Promise {
+ if (isCodexCliProvider(settings.provider)) {
+ return runCodexCli(settings, prompt, { signal, handlers })
+ }
const providerOptions = resolveProviderOptions(settings, { preferLowReasoning: true })
const samplingOptions = resolveSamplingOptions(settings)
let streamError: unknown = null
diff --git a/electron/main/ai/ipc.ts b/electron/main/ai/ipc.ts
index 79cbda09..cf54f2bf 100644
--- a/electron/main/ai/ipc.ts
+++ b/electron/main/ai/ipc.ts
@@ -18,7 +18,7 @@ import type { SpiralBootstrapInput } from './spiral'
import { formatAiErrorMessage } from './error-message'
import { testProxyConnection } from './proxy-fetch'
import { normalizeSettings } from './settings'
-import { shouldTryStreamingAgent } from '@shared/ai-provider-catalog'
+import { isCodexCliProvider, shouldTryStreamingAgent } from '@shared/ai-provider-catalog'
/**
* AI IPC 模块的外部依赖注入接口。
@@ -289,58 +289,61 @@ export function registerAiIpcHandlers(injectedDeps: AiIpcDeps): void {
let streamedContent = ''
void (async () => {
try {
- try {
- const result = await runStreamingAgentTask(
- payload,
- {
- onTextDelta: (delta) => {
- streamedContent += delta
- if (!event.sender.isDestroyed()) {
- event.sender.send('characterarc:ai-stream-event', { streamId, type: 'chunk', delta, charCount: streamedContent.length })
- }
- },
- onReasoningDelta: (delta) => {
- if (!event.sender.isDestroyed()) {
- event.sender.send('characterarc:ai-stream-event', { streamId, type: 'reasoning', delta })
- }
- },
- onToolUseStart: (toolUseId, toolName, args) => {
- if (!event.sender.isDestroyed()) {
- event.sender.send('characterarc:ai-stream-event', { streamId, type: 'tool_use_start', toolUseId, toolName, args })
- }
- },
- onToolResult: (toolUseId, toolName, content, isError, durationMs) => {
- if (!event.sender.isDestroyed()) {
- event.sender.send('characterarc:ai-stream-event', { streamId, type: 'tool_result', toolUseId, toolName, content, isError, durationMs })
- }
- },
- onAgentStatus: (message, iteration, maxIterations) => {
- if (!event.sender.isDestroyed()) {
- event.sender.send('characterarc:ai-stream-event', { streamId, type: 'agent_status', message, iteration, maxIterations })
- }
- },
- onEditApplied: (chapterId, editType, preview, versionId) => {
- if (!event.sender.isDestroyed()) {
- event.sender.send('characterarc:ai-stream-event', { streamId, type: 'edit_applied', chapterId, editType, preview, versionId })
+ const normalizedSettings = normalizeSettings(payload.settings)
+ if (!isCodexCliProvider(normalizedSettings.provider)) {
+ try {
+ const result = await runStreamingAgentTask(
+ payload,
+ {
+ onTextDelta: (delta) => {
+ streamedContent += delta
+ if (!event.sender.isDestroyed()) {
+ event.sender.send('characterarc:ai-stream-event', { streamId, type: 'chunk', delta, charCount: streamedContent.length })
+ }
+ },
+ onReasoningDelta: (delta) => {
+ if (!event.sender.isDestroyed()) {
+ event.sender.send('characterarc:ai-stream-event', { streamId, type: 'reasoning', delta })
+ }
+ },
+ onToolUseStart: (toolUseId, toolName, args) => {
+ if (!event.sender.isDestroyed()) {
+ event.sender.send('characterarc:ai-stream-event', { streamId, type: 'tool_use_start', toolUseId, toolName, args })
+ }
+ },
+ onToolResult: (toolUseId, toolName, content, isError, durationMs) => {
+ if (!event.sender.isDestroyed()) {
+ event.sender.send('characterarc:ai-stream-event', { streamId, type: 'tool_result', toolUseId, toolName, content, isError, durationMs })
+ }
+ },
+ onAgentStatus: (message, iteration, maxIterations) => {
+ if (!event.sender.isDestroyed()) {
+ event.sender.send('characterarc:ai-stream-event', { streamId, type: 'agent_status', message, iteration, maxIterations })
+ }
+ },
+ onEditApplied: (chapterId, editType, preview, versionId) => {
+ if (!event.sender.isDestroyed()) {
+ event.sender.send('characterarc:ai-stream-event', { streamId, type: 'edit_applied', chapterId, editType, preview, versionId })
+ }
+ },
+ onEditProposed: (chapterId, proposalId, editType, preview, oldContent, newContent) => {
+ if (!event.sender.isDestroyed()) {
+ event.sender.send('characterarc:ai-stream-event', { streamId, type: 'edit_proposed', chapterId, proposalId, editType, preview, oldContent, newContent })
+ }
}
},
- onEditProposed: (chapterId, proposalId, editType, preview, oldContent, newContent) => {
- if (!event.sender.isDestroyed()) {
- event.sender.send('characterarc:ai-stream-event', { streamId, type: 'edit_proposed', chapterId, proposalId, editType, preview, oldContent, newContent })
- }
- }
- },
- controller.signal,
- knowledgeContext
- )
- deps!.emitAiRunEvent({ projectId: result.meta.projectId ?? '', meta: { id: randomUUID(), ...result.meta } })
- if (!event.sender.isDestroyed()) {
- event.sender.send('characterarc:ai-stream-event', { streamId, type: 'done', content: streamedContent, result: result.result })
+ controller.signal,
+ knowledgeContext
+ )
+ deps!.emitAiRunEvent({ projectId: result.meta.projectId ?? '', meta: { id: randomUUID(), ...result.meta } })
+ if (!event.sender.isDestroyed()) {
+ event.sender.send('characterarc:ai-stream-event', { streamId, type: 'done', content: streamedContent, result: result.result })
+ }
+ return
+ } catch (agentError) {
+ if (!isToolUseNotSupportedError(agentError)) throw agentError
+ streamedContent = ''
}
- return
- } catch (agentError) {
- if (!isToolUseNotSupportedError(agentError)) throw agentError
- streamedContent = ''
}
const result = await streamAiTask(
diff --git a/electron/main/ai/provider.ts b/electron/main/ai/provider.ts
index 049d94ed..091328a6 100644
--- a/electron/main/ai/provider.ts
+++ b/electron/main/ai/provider.ts
@@ -1,6 +1,6 @@
import type { LanguageModel } from 'ai'
import type { AppSettings } from './shared-types'
-import { isAnthropicProtocol, resolveAiProviderProtocol } from '@shared/ai-provider-catalog'
+import { isAnthropicProtocol, isCodexCliProvider, resolveAiProviderProtocol } from '@shared/ai-provider-catalog'
import { createProxyFetch } from './proxy-fetch'
import { createProviderTransportFetch } from './sse'
import { createProtocolModel } from './protocol-adapter'
@@ -15,6 +15,7 @@ function isOfficialOpenAIProvider(settings: AppSettings): boolean {
}
export function providerSupportsNativeStructuredOutput(settings: AppSettings): boolean {
+ if (isCodexCliProvider(settings.provider)) return false
// Anthropic's SDK object streaming can produce an empty text stream or fail
// object parsing for otherwise recoverable JSON tasks. Keep Claude JSON tasks
// on the text path and let task normalizers/repair prompts handle the JSON.
@@ -26,6 +27,9 @@ export function createModel(
settings: AppSettings,
options?: { requestFetch?: typeof fetch }
): LanguageModel {
+ if (isCodexCliProvider(settings.provider)) {
+ throw new Error('Codex CLI 由本机子进程传输层调用,不能创建 HTTP 模型。')
+ }
const requestFetch = options?.requestFetch ?? createProxyFetch(settings.proxyUrl)
const customFetch = createProviderTransportFetch(requestFetch)
return createProtocolModel({
diff --git a/electron/main/ai/runtime/orchestrator.ts b/electron/main/ai/runtime/orchestrator.ts
index d8439849..9ca9c4b1 100644
--- a/electron/main/ai/runtime/orchestrator.ts
+++ b/electron/main/ai/runtime/orchestrator.ts
@@ -33,6 +33,7 @@ import { formatAiErrorMessage } from '../error-message'
import {
isOpenCodeProvider,
isOpenAIChatProtocol,
+ isCodexCliProvider,
resolveAiProviderProtocol
} from '@shared/ai-provider-catalog'
import { createHash, randomUUID } from 'node:crypto'
@@ -43,6 +44,7 @@ import {
finishChapterProcessing,
type ChapterProcessingStageStatus
} from './chapter-processing-store'
+import { testCodexCliConnection } from '../codex-cli'
const postGenerationTasks = new BackgroundTaskCoordinator()
@@ -88,7 +90,10 @@ export async function runAiTask(
// 白名单内的任务直接尝试走 agent loop,不预判 provider 能力。
// 如果模型不支持 tool_use,运行时会抛错,在 catch 中降级或提示用户。
const settingsForRouting = normalizeSettings(task.settings)
+ // Codex CLI 自己运行在独立进程中,无法直接执行 CharacterArc 的进程内 tools;
+ // 此模式仍使用相同的完整任务 Prompt,并走单次/流式文本链路。
const usesAgentRoute = AGENT_TASK_WHITELIST.has(task.task)
+ && !isCodexCliProvider(settingsForRouting.provider)
if (usesAgentRoute) {
await enrichTaskContextForGeneration(task, settingsForRouting)
try {
@@ -420,6 +425,14 @@ export async function testAiConnection(rawSettings: AppSettings): Promise<{
}> {
const settings = normalizeSettings(rawSettings)
validateSettings(settings)
+ if (isCodexCliProvider(settings.provider)) {
+ await testCodexCliConnection(settings)
+ return {
+ provider: settings.provider,
+ model: settings.model,
+ protocol: 'codex-cli'
+ }
+ }
const probePrompt = {
system: 'You are a connectivity probe. Reply briefly to confirm the request succeeded.',
user: 'Reply with CONNECTED.'
diff --git a/electron/main/ai/settings.ts b/electron/main/ai/settings.ts
index fe151965..184e49ed 100644
--- a/electron/main/ai/settings.ts
+++ b/electron/main/ai/settings.ts
@@ -3,7 +3,8 @@ import {
getAiProviderCatalogEntry,
normalizeAiProtocolPreference,
normalizeAiBaseUrl,
- normalizeAiProviderName
+ normalizeAiProviderName,
+ isCodexCliProvider
} from '@shared/ai-provider-catalog'
/**
@@ -25,6 +26,13 @@ function normalizeOptionalNumber(value: unknown, min: number, max: number): numb
return Math.min(max, Math.max(min, value))
}
+function normalizeCodexReasoningEffort(value: unknown): AppSettings['codexReasoningEffort'] {
+ return typeof value === 'string'
+ && ['default', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(value)
+ ? value as AppSettings['codexReasoningEffort']
+ : 'default'
+}
+
/**
* 规范化用户设置:trim、转小写、缺失字段回退到供应商默认值。
*
@@ -42,6 +50,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
apiKey: settings.apiKey?.trim() || '',
baseUrl,
apiProtocol: normalizeAiProtocolPreference(settings.apiProtocol),
+ codexCliPath: settings.codexCliPath?.trim() || '',
+ codexReasoningEffort: normalizeCodexReasoningEffort(settings.codexReasoningEffort),
proxyUrl: settings.proxyUrl?.trim() || '',
temperature: normalizeOptionalNumber(settings.temperature, 0, 2),
topP: normalizeOptionalNumber(settings.topP, 0, 1),
@@ -74,7 +84,7 @@ export function isLocalBaseUrl(baseUrl: string): boolean {
* @returns 是否需要填写 API Key
*/
export function requiresApiKey(settings: AppSettings): boolean {
- if (settings.provider === 'ollama') {
+ if (settings.provider === 'ollama' || isCodexCliProvider(settings.provider)) {
return false
}
return !isLocalBaseUrl(settings.baseUrl)
@@ -90,7 +100,7 @@ export function validateSettings(settings: AppSettings): void {
if (!settings.model.trim()) {
throw new Error('请先填写模型名称。')
}
- if (!settings.baseUrl.trim()) {
+ if (!isCodexCliProvider(settings.provider) && !settings.baseUrl.trim()) {
throw new Error('请先填写 Base URL。')
}
if (requiresApiKey(settings) && !settings.apiKey.trim()) {
diff --git a/electron/main/ai/shared-types.ts b/electron/main/ai/shared-types.ts
index a8bb0ea0..85d4392a 100644
--- a/electron/main/ai/shared-types.ts
+++ b/electron/main/ai/shared-types.ts
@@ -30,6 +30,10 @@ export type AppSettings = {
baseUrl: string
/** API 线协议;auto 时按供应商和模型目录解析 */
apiProtocol?: 'auto' | 'openai-responses' | 'openai-chat' | 'anthropic'
+ /** Codex CLI 可执行文件或其所在目录;留空时从 PATH 和常见目录查找 */
+ codexCliPath?: string
+ /** Codex CLI 的 model_reasoning_effort;default 时沿用 Codex 配置 */
+ codexReasoningEffort?: 'default' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'
/** 可选:AI 请求使用的 HTTP/HTTPS 代理地址 */
proxyUrl?: string
/** 可选:模型采样温度,留空时使用服务端默认值 */
diff --git a/electron/main/ai/transport/models.ts b/electron/main/ai/transport/models.ts
index 57d3c24d..b22f726f 100644
--- a/electron/main/ai/transport/models.ts
+++ b/electron/main/ai/transport/models.ts
@@ -3,9 +3,11 @@ import { normalizeSettings } from '../settings'
import { createProxyFetch } from '../proxy-fetch'
import {
isAnthropicProtocol,
+ isCodexCliProvider,
isOpenCodeProvider,
isSupportedProviderModel
} from '@shared/ai-provider-catalog'
+import { fetchCodexCliModels } from '../codex-cli'
/** 从模型列表接口获取到的模型信息 */
export interface FetchedModel {
@@ -137,6 +139,9 @@ async function fetchModelsAnthropic(baseUrl: string, apiKey: string, requestFetc
*/
export async function fetchModels(settings: AppSettings): Promise {
const normalized = normalizeSettings(settings)
+ if (isCodexCliProvider(normalized.provider)) {
+ return fetchCodexCliModels(normalized)
+ }
if (!normalized.baseUrl.trim()) throw new Error('请先填写 Base URL。')
if (!normalized.apiKey.trim() && normalized.provider !== 'ollama') {
throw new Error('需要 API Key 才能获取模型列表。')
diff --git a/electron/main/workspace-types.test.mjs b/electron/main/workspace-types.test.mjs
index 603f9680..e94d6f07 100644
--- a/electron/main/workspace-types.test.mjs
+++ b/electron/main/workspace-types.test.mjs
@@ -110,6 +110,57 @@ test('旧 AI 设置和配置档案缺少协议时迁移为 auto', () => {
assert.equal(merged.appSettings.aiProfiles[0].apiProtocol, 'auto')
})
+test('Codex CLI 路径和推理强度随配置档案持久化', () => {
+ const normalized = normalizeAppSettings({
+ provider: 'codex-cli',
+ model: 'default',
+ codexCliPath: ' /opt/homebrew/bin/codex ',
+ codexReasoningEffort: 'high',
+ activeAiProfileId: 'profile-codex',
+ aiProfiles: [{
+ id: 'profile-codex',
+ name: 'Codex CLI',
+ provider: 'codex-cli',
+ baseUrl: '',
+ apiKey: '',
+ model: 'default',
+ codexCliPath: ' /opt/homebrew/bin/codex ',
+ codexReasoningEffort: 'xhigh'
+ }]
+ })
+
+ assert.equal(normalized.codexCliPath, '/opt/homebrew/bin/codex')
+ assert.equal(normalized.codexReasoningEffort, 'xhigh')
+ assert.equal(normalized.aiProfiles[0].codexCliPath, '/opt/homebrew/bin/codex')
+ assert.equal(normalized.aiProfiles[0].codexReasoningEffort, 'xhigh')
+})
+
+test('活动配置缺失时自动激活首个档案并同步运行模型', () => {
+ const normalized = normalizeAppSettings({
+ provider: 'openai-compatible',
+ model: '',
+ apiKey: '',
+ baseUrl: '',
+ activeAiProfileId: '',
+ aiProfiles: [{
+ id: 'profile-codex',
+ name: '我的Codex',
+ provider: 'codex-cli',
+ baseUrl: '',
+ apiKey: '',
+ model: 'gpt-5.6-luna',
+ codexCliPath: '/opt/homebrew/bin/codex',
+ codexReasoningEffort: 'high'
+ }]
+ })
+
+ assert.equal(normalized.activeAiProfileId, 'profile-codex')
+ assert.equal(normalized.provider, 'codex-cli')
+ assert.equal(normalized.model, 'gpt-5.6-luna')
+ assert.equal(normalized.codexCliPath, '/opt/homebrew/bin/codex')
+ assert.equal(normalized.codexReasoningEffort, 'high')
+})
+
test('工作台菜单顺序按应用设置保存并清理重复值', () => {
const normalized = normalizeWorkspacePayload({
theme: 'ocean',
diff --git a/electron/main/workspace-types.ts b/electron/main/workspace-types.ts
index aef2f048..df5df766 100644
--- a/electron/main/workspace-types.ts
+++ b/electron/main/workspace-types.ts
@@ -318,6 +318,8 @@ export type WorkspacePayload = {
apiKey: string
baseUrl: string
apiProtocol?: 'auto' | 'openai-responses' | 'openai-chat' | 'anthropic'
+ codexCliPath?: string
+ codexReasoningEffort?: 'default' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'
proxyUrl: string
temperature?: number
topP?: number
@@ -331,6 +333,8 @@ export type WorkspacePayload = {
apiKey: string
model: string
apiProtocol?: 'auto' | 'openai-responses' | 'openai-chat' | 'anthropic'
+ codexCliPath?: string
+ codexReasoningEffort?: 'default' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'
temperature?: number
topP?: number
presencePenalty?: number
@@ -477,6 +481,15 @@ function normalizeApiProtocol(
: 'auto'
}
+function normalizeCodexReasoningEffort(
+ value: unknown
+): 'default' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra' {
+ return typeof value === 'string'
+ && ['default', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(value)
+ ? value as 'default' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'
+ : 'default'
+}
+
export function normalizeAppSettings(
settings?: Partial | null
): WorkspacePayload['appSettings'] {
@@ -501,48 +514,63 @@ export function normalizeAppSettings(
? Math.min(2, Math.max(-2, settings.frequencyPenalty))
: undefined
+ const aiProfiles = Array.isArray(settings?.aiProfiles)
+ ? settings.aiProfiles
+ .filter((item): item is NonNullable[number] => !!item && typeof item === 'object')
+ .map((item) => ({
+ id: String(item.id ?? '').trim(),
+ name: String(item.name ?? '').trim(),
+ provider: String(item.provider ?? '').trim(),
+ baseUrl: String(item.baseUrl ?? '').trim(),
+ apiKey: String(item.apiKey ?? '').trim(),
+ model: String(item.model ?? '').trim(),
+ apiProtocol: normalizeApiProtocol(item.apiProtocol),
+ codexCliPath: typeof item.codexCliPath === 'string' ? item.codexCliPath.trim() : '',
+ codexReasoningEffort: normalizeCodexReasoningEffort(item.codexReasoningEffort),
+ temperature:
+ typeof item.temperature === 'number' && Number.isFinite(item.temperature)
+ ? Math.min(2, Math.max(0, item.temperature))
+ : undefined,
+ topP:
+ typeof item.topP === 'number' && Number.isFinite(item.topP)
+ ? Math.min(1, Math.max(0, item.topP))
+ : undefined,
+ presencePenalty:
+ typeof item.presencePenalty === 'number' && Number.isFinite(item.presencePenalty)
+ ? Math.min(2, Math.max(-2, item.presencePenalty))
+ : undefined,
+ frequencyPenalty:
+ typeof item.frequencyPenalty === 'number' && Number.isFinite(item.frequencyPenalty)
+ ? Math.min(2, Math.max(-2, item.frequencyPenalty))
+ : undefined
+ }))
+ .filter((item) => item.id)
+ : []
+ const requestedActiveProfileId = typeof settings?.activeAiProfileId === 'string'
+ ? settings.activeAiProfileId.trim()
+ : ''
+ const activeAiProfileId = aiProfiles.some((item) => item.id === requestedActiveProfileId)
+ ? requestedActiveProfileId
+ : aiProfiles[0]?.id ?? ''
+ const activeProfile = aiProfiles.find((item) => item.id === activeAiProfileId)
+
return {
- provider: settings?.provider || 'openai-compatible',
- model: settings?.model || '',
- apiKey: settings?.apiKey || '',
- baseUrl: settings?.baseUrl || '',
- apiProtocol: normalizeApiProtocol(settings?.apiProtocol),
+ provider: activeProfile?.provider || settings?.provider || 'openai-compatible',
+ model: activeProfile?.model ?? settings?.model ?? '',
+ apiKey: activeProfile?.apiKey ?? settings?.apiKey ?? '',
+ baseUrl: activeProfile?.baseUrl ?? settings?.baseUrl ?? '',
+ apiProtocol: activeProfile?.apiProtocol ?? normalizeApiProtocol(settings?.apiProtocol),
+ codexCliPath: activeProfile?.codexCliPath
+ ?? (typeof settings?.codexCliPath === 'string' ? settings.codexCliPath.trim() : ''),
+ codexReasoningEffort: activeProfile?.codexReasoningEffort
+ ?? normalizeCodexReasoningEffort(settings?.codexReasoningEffort),
proxyUrl: settings?.proxyUrl || '',
- temperature,
- topP,
- presencePenalty,
- frequencyPenalty,
- aiProfiles: Array.isArray(settings?.aiProfiles)
- ? settings.aiProfiles
- .filter((item): item is NonNullable[number] => !!item && typeof item === 'object')
- .map((item) => ({
- id: String(item.id ?? '').trim(),
- name: String(item.name ?? '').trim(),
- provider: String(item.provider ?? '').trim(),
- baseUrl: String(item.baseUrl ?? '').trim(),
- apiKey: String(item.apiKey ?? '').trim(),
- model: String(item.model ?? '').trim(),
- apiProtocol: normalizeApiProtocol(item.apiProtocol),
- temperature:
- typeof item.temperature === 'number' && Number.isFinite(item.temperature)
- ? Math.min(2, Math.max(0, item.temperature))
- : undefined,
- topP:
- typeof item.topP === 'number' && Number.isFinite(item.topP)
- ? Math.min(1, Math.max(0, item.topP))
- : undefined,
- presencePenalty:
- typeof item.presencePenalty === 'number' && Number.isFinite(item.presencePenalty)
- ? Math.min(2, Math.max(-2, item.presencePenalty))
- : undefined,
- frequencyPenalty:
- typeof item.frequencyPenalty === 'number' && Number.isFinite(item.frequencyPenalty)
- ? Math.min(2, Math.max(-2, item.frequencyPenalty))
- : undefined
- }))
- .filter((item) => item.id)
- : [],
- activeAiProfileId: typeof settings?.activeAiProfileId === 'string' ? settings.activeAiProfileId : '',
+ temperature: activeProfile?.temperature ?? temperature,
+ topP: activeProfile?.topP ?? topP,
+ presencePenalty: activeProfile?.presencePenalty ?? presencePenalty,
+ frequencyPenalty: activeProfile?.frequencyPenalty ?? frequencyPenalty,
+ aiProfiles,
+ activeAiProfileId,
imageProvider: settings?.imageProvider || '',
imageModel: settings?.imageModel || '',
imageApiKey: settings?.imageApiKey || '',
diff --git a/electron/shared/ai-provider-catalog.ts b/electron/shared/ai-provider-catalog.ts
index 6026d234..dc210bcc 100644
--- a/electron/shared/ai-provider-catalog.ts
+++ b/electron/shared/ai-provider-catalog.ts
@@ -4,6 +4,7 @@ export type AiProtocolPreference = 'auto' | AiProviderProtocol
export interface AiProviderCatalogEntry {
label: string
value: string
+ transport?: 'http' | 'codex-cli'
protocol: AiProviderProtocol
baseUrl: string
model: string
@@ -13,6 +14,7 @@ export interface AiProviderCatalogEntry {
}
export const AI_PROVIDER_CATALOG: readonly AiProviderCatalogEntry[] = [
+ { label: 'Codex CLI(本机)', value: 'codex-cli', transport: 'codex-cli', protocol: 'openai-responses', baseUrl: '', model: 'default', customBaseUrl: false, supportsEmbedding: false, hint: '复用本机 Codex CLI 的登录状态,无需 API Key。请先在终端执行 codex login。' },
{ label: 'OpenAI', value: 'openai', protocol: 'openai-responses', baseUrl: 'https://api.openai.com/v1', model: '', customBaseUrl: false, supportsEmbedding: true, hint: '官方接口,填写 API Key 后即可使用,也可拉取账号可用模型。' },
{ label: 'Anthropic', value: 'anthropic', protocol: 'anthropic', baseUrl: 'https://api.anthropic.com/v1', model: '', customBaseUrl: false, supportsEmbedding: false, hint: 'Claude 官方接口,使用 Anthropic Messages 协议。' },
{ label: 'DeepSeek', value: 'deepseek', protocol: 'openai-chat', baseUrl: 'https://api.deepseek.com/v1', model: '', customBaseUrl: false, supportsEmbedding: false, hint: 'DeepSeek 官方接口,支持 deepseek-chat 和 deepseek-reasoner。' },
@@ -38,6 +40,10 @@ export function getAiProviderCatalogEntry(provider: string): AiProviderCatalogEn
return AI_PROVIDER_CATALOG.find((item) => item.value === normalized)
}
+export function isCodexCliProvider(provider: string): boolean {
+ return provider.trim().toLowerCase() === 'codex-cli'
+}
+
const KNOWN_ENDPOINT_SUFFIXES = [
'/chat/completions',
'/messages',
@@ -149,6 +155,7 @@ export function shouldTryStreamingAgent(
model: string,
protocol: AiProtocolPreference = 'auto'
): boolean {
+ if (isCodexCliProvider(provider)) return false
if (task === 'global-assistant') return true
if (task !== 'chapter-first-draft') return false
diff --git a/electron/shared/ipc-types.ts b/electron/shared/ipc-types.ts
index abc4bc6b..342f0bf3 100644
--- a/electron/shared/ipc-types.ts
+++ b/electron/shared/ipc-types.ts
@@ -16,12 +16,14 @@ export interface AppSettingsPayload {
apiKey: string
baseUrl: string
apiProtocol?: 'auto' | 'openai-responses' | 'openai-chat' | 'anthropic'
+ codexCliPath?: string
+ codexReasoningEffort?: 'default' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'
proxyUrl: string
temperature?: number
topP?: number
presencePenalty?: number
frequencyPenalty?: number
- aiProfiles: Array<{ id: string; name: string; provider: string; baseUrl: string; apiKey: string; model: string; apiProtocol?: 'auto' | 'openai-responses' | 'openai-chat' | 'anthropic'; temperature?: number; topP?: number; presencePenalty?: number; frequencyPenalty?: number }>
+ aiProfiles: Array<{ id: string; name: string; provider: string; baseUrl: string; apiKey: string; model: string; apiProtocol?: 'auto' | 'openai-responses' | 'openai-chat' | 'anthropic'; codexCliPath?: string; codexReasoningEffort?: 'default' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'; temperature?: number; topP?: number; presencePenalty?: number; frequencyPenalty?: number }>
activeAiProfileId: string
imageProvider: string
imageModel: string
diff --git a/package.json b/package.json
index 46737405..5779da50 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"license": "MIT",
"scripts": {
"dev": "electron-vite dev",
- "test": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/shared/continuation-import.test.mjs electron/main/workspace-types.test.mjs renderer/src/features/workspace/outlineReorder.test.mjs renderer/src/features/workspace/volumeCollapseState.test.mjs renderer/src/features/workspace/workbenchMenu.test.mjs renderer/src/features/chapters/chapterFormatting.test.mjs renderer/src/features/ai/taskRegistry.test.mjs renderer/src/features/ai/outlineReferences.test.mjs renderer/src/features/tutorials/tutorials.test.mjs renderer/src/features/knowledge/knowledgeCenter.test.mjs electron/main/story-state-store.test.mjs electron/main/knowledge-document-schema.test.mjs electron/main/ai/settings.test.mjs electron/main/ai/generate.test.mjs electron/main/ai/provider.test.mjs electron/main/ai/protocol-adapter.test.mjs electron/main/ai/proxy-fetch.test.mjs electron/main/ai/knowledge-retrieval.test.mjs electron/main/ai/state-backfill-store.test.mjs electron/main/ai/state-backfill-task-controller.test.mjs electron/main/ai/runtime/background-task-coordinator.test.mjs electron/main/ai/runtime/chapter-processing-store.test.mjs electron/main/ai/tasks/chapter-session-note.test.mjs electron/main/ai/tasks/outline-context.test.mjs electron/main/ai/tasks/story-state-generation.test.mjs electron/main/ai/tasks/worldview-type.test.mjs electron/main/ai/tasks/ranking-analysis.test.mjs electron/main/ai/tasks/qimao-ranking.test.mjs electron/main/ai/tasks/zongheng-ranking.test.mjs electron/main/ai/runtime-v2/conversation-manager.test.mjs electron/main/ai/runtime-v2/staged-changes-store.test.mjs electron/main/ai/runtime-v2/commit-result.test.mjs electron/main/ai/runtime-v2/context-builder.test.mjs electron/main/ai/runtime-v2/agent-loop.test.mjs electron/main/ai/agent/tools/text-window.test.mjs electron/main/ai/agent/tools/knowledge-tools.test.mjs electron/main/ai/agent/tools/chapter-data-access.test.mjs electron/main/ai/agent/tools/chapter-html-edit.test.mjs electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs",
+ "test": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/shared/continuation-import.test.mjs electron/main/workspace-types.test.mjs renderer/src/features/workspace/outlineReorder.test.mjs renderer/src/features/workspace/volumeCollapseState.test.mjs renderer/src/features/workspace/workbenchMenu.test.mjs renderer/src/features/chapters/chapterFormatting.test.mjs renderer/src/features/ai/taskRegistry.test.mjs renderer/src/features/ai/outlineReferences.test.mjs renderer/src/features/tutorials/tutorials.test.mjs renderer/src/features/knowledge/knowledgeCenter.test.mjs electron/main/story-state-store.test.mjs electron/main/knowledge-document-schema.test.mjs electron/main/ai/settings.test.mjs electron/main/ai/codex-cli.test.mjs electron/main/ai/generate.test.mjs electron/main/ai/provider.test.mjs electron/main/ai/protocol-adapter.test.mjs electron/main/ai/proxy-fetch.test.mjs electron/main/ai/knowledge-retrieval.test.mjs electron/main/ai/state-backfill-store.test.mjs electron/main/ai/state-backfill-task-controller.test.mjs electron/main/ai/runtime/background-task-coordinator.test.mjs electron/main/ai/runtime/chapter-processing-store.test.mjs electron/main/ai/tasks/chapter-session-note.test.mjs electron/main/ai/tasks/outline-context.test.mjs electron/main/ai/tasks/story-state-generation.test.mjs electron/main/ai/tasks/worldview-type.test.mjs electron/main/ai/tasks/ranking-analysis.test.mjs electron/main/ai/tasks/qimao-ranking.test.mjs electron/main/ai/tasks/zongheng-ranking.test.mjs electron/main/ai/runtime-v2/conversation-manager.test.mjs electron/main/ai/runtime-v2/staged-changes-store.test.mjs electron/main/ai/runtime-v2/commit-result.test.mjs electron/main/ai/runtime-v2/context-builder.test.mjs electron/main/ai/runtime-v2/agent-loop.test.mjs electron/main/ai/agent/tools/text-window.test.mjs electron/main/ai/agent/tools/knowledge-tools.test.mjs electron/main/ai/agent/tools/chapter-data-access.test.mjs electron/main/ai/agent/tools/chapter-html-edit.test.mjs electron/main/ai/runtime-v2/tools/stage-chapter-edit-core.test.mjs",
"test:ranking": "set ELECTRON_RUN_AS_NODE=1&& electron --test electron/main/ai/tasks/ranking-analysis.test.mjs",
"typecheck": "vue-tsc --noEmit",
"build": "pnpm run typecheck && electron-vite build",
diff --git a/renderer/src/components/home/HomepageSettingsModal.vue b/renderer/src/components/home/HomepageSettingsModal.vue
index 9b645822..01ba3001 100644
--- a/renderer/src/components/home/HomepageSettingsModal.vue
+++ b/renderer/src/components/home/HomepageSettingsModal.vue
@@ -18,7 +18,7 @@ import { useAppStore } from '@/stores/app'
import { darkModePresets, themePresets } from '@/theme/presets'
import { toIpcPayload } from '@/utils/ipcPayload'
import type { AiProfile, AppSettings, DarkModeStyle, ThemeName } from '@/types/app'
-import { isOpenAIChatProtocol, resolveAiProviderProtocol } from '@shared/ai-provider-catalog'
+import { isCodexCliProvider, isOpenAIChatProtocol, resolveAiProviderProtocol } from '@shared/ai-provider-catalog'
const props = defineProps<{
show: boolean
@@ -53,6 +53,16 @@ const apiProtocolOptions = [
{ label: 'Anthropic Messages', value: 'anthropic' },
{ label: 'OpenAI Chat Completions', value: 'openai-chat' }
]
+const codexReasoningEffortOptions = [
+ { label: '默认(沿用 Codex 配置)', value: 'default' },
+ { label: 'Minimal', value: 'minimal' },
+ { label: 'Low', value: 'low' },
+ { label: 'Medium', value: 'medium' },
+ { label: 'High', value: 'high' },
+ { label: 'XHigh', value: 'xhigh' },
+ { label: 'Max', value: 'max' },
+ { label: 'Ultra', value: 'ultra' }
+]
const draftSettings = reactive({
provider: '',
@@ -60,6 +70,8 @@ const draftSettings = reactive({
apiKey: '',
baseUrl: '',
apiProtocol: 'auto',
+ codexCliPath: '',
+ codexReasoningEffort: 'default',
proxyUrl: '',
temperature: undefined,
topP: undefined,
@@ -132,6 +144,7 @@ function handleScroll(): void {
}
const activeProviderPreset = computed(() => getProviderPreset(editingProfile.value?.provider ?? draftSettings.provider))
+const isCodexCli = computed(() => isCodexCliProvider(editingProfile.value?.provider ?? draftSettings.provider))
const activeApiProtocol = computed(() => resolveAiProviderProtocol(
editingProfile.value?.provider ?? draftSettings.provider,
editingProfile.value?.model ?? draftSettings.model,
@@ -152,6 +165,12 @@ const modelSelectOptions = computed(() =>
const imageModelSelectOptions = computed(() =>
fetchedImageModels.value.map((m) => ({ label: m.id, value: m.id }))
)
+const canFetchModels = computed(() => {
+ const profile = editingProfile.value
+ if (!profile || isFetchingModels.value) return false
+ if (isCodexCliProvider(profile.provider)) return true
+ return Boolean(profile.baseUrl.trim() && (profile.provider === 'ollama' || profile.apiKey.trim()))
+})
const hasPendingChanges = computed(() =>
draftTheme.value !== appStore.theme
|| JSON.stringify(draftSettings.aiProfiles) !== JSON.stringify(appStore.appSettings.aiProfiles)
@@ -175,6 +194,8 @@ function syncDraftFromStore(): void {
draftSettings.apiKey = appStore.appSettings.apiKey
draftSettings.baseUrl = appStore.appSettings.baseUrl
draftSettings.apiProtocol = appStore.appSettings.apiProtocol ?? 'auto'
+ draftSettings.codexCliPath = appStore.appSettings.codexCliPath
+ draftSettings.codexReasoningEffort = appStore.appSettings.codexReasoningEffort
draftSettings.proxyUrl = appStore.appSettings.proxyUrl
proxyTestIp.value = ''
draftSettings.temperature = appStore.appSettings.temperature
@@ -326,6 +347,8 @@ function handleAddProfile(): void {
apiKey: '',
model: defaults.model,
apiProtocol: 'auto',
+ codexCliPath: '',
+ codexReasoningEffort: 'default',
temperature: undefined,
topP: undefined,
presencePenalty: undefined,
@@ -333,6 +356,9 @@ function handleAddProfile(): void {
}
draftSettings.aiProfiles.push(newProfile)
editingProfileId.value = id
+ if (!draftSettings.activeAiProfileId) {
+ draftSettings.activeAiProfileId = id
+ }
fetchedModels.value = []
}
@@ -348,6 +374,8 @@ function handleCopyProfile(): void {
apiKey: source.apiKey,
model: source.model,
apiProtocol: source.apiProtocol ?? 'auto',
+ codexCliPath: source.codexCliPath ?? '',
+ codexReasoningEffort: source.codexReasoningEffort ?? 'default',
temperature: source.temperature,
topP: source.topP,
presencePenalty: source.presencePenalty,
@@ -384,6 +412,8 @@ function updateEditingProfile(updates: Partial): void {
if (updates.apiKey !== undefined) draftSettings.apiKey = updates.apiKey
if (updates.baseUrl !== undefined) draftSettings.baseUrl = updates.baseUrl
if (updates.apiProtocol !== undefined) draftSettings.apiProtocol = updates.apiProtocol
+ if (updates.codexCliPath !== undefined) draftSettings.codexCliPath = updates.codexCliPath
+ if (updates.codexReasoningEffort !== undefined) draftSettings.codexReasoningEffort = updates.codexReasoningEffort
if ('temperature' in updates) draftSettings.temperature = updates.temperature
if ('topP' in updates) draftSettings.topP = updates.topP
if ('presencePenalty' in updates) draftSettings.presencePenalty = updates.presencePenalty
@@ -440,6 +470,8 @@ function buildProfilePayload(): AppSettings {
apiKey: profile.apiKey,
baseUrl: profile.baseUrl,
apiProtocol: profile.apiProtocol ?? 'auto',
+ codexCliPath: profile.codexCliPath ?? '',
+ codexReasoningEffort: profile.codexReasoningEffort ?? 'default',
temperature: profile.temperature,
topP: profile.topP,
presencePenalty: profile.presencePenalty,
@@ -497,6 +529,8 @@ async function saveSettings(): Promise {
apiKey: activeProfile?.apiKey ?? draftSettings.apiKey,
baseUrl: activeProfile?.baseUrl ?? draftSettings.baseUrl,
apiProtocol: activeProfile?.apiProtocol ?? draftSettings.apiProtocol ?? 'auto',
+ codexCliPath: activeProfile?.codexCliPath ?? draftSettings.codexCliPath,
+ codexReasoningEffort: activeProfile?.codexReasoningEffort ?? draftSettings.codexReasoningEffort,
temperature: activeProfile?.temperature ?? draftSettings.temperature,
topP: activeProfile?.topP ?? draftSettings.topP,
presencePenalty: activeProfile?.presencePenalty ?? draftSettings.presencePenalty,
@@ -602,7 +636,7 @@ async function saveSettings(): Promise {
@update:value="(value) => handleProviderChange(value ?? 'openai-compatible')"
/>
-
+
{
/>
+
+
+ updateEditingProfile({ codexCliPath: value })"
+ />
+ 请先在终端执行 codex login;应用代理会自动传给 Codex CLI。
+
+
{
updateEditingProfile({ model: value })"
/>
@@ -655,7 +699,14 @@ async function saveSettings(): Promise {
{{ activeProviderPreset.hint }}
-
+
+ updateEditingProfile({ codexReasoningEffort: value ?? 'default' })"
+ />
+
+
API 高级设置
diff --git a/renderer/src/features/workspace/storeHelpers.ts b/renderer/src/features/workspace/storeHelpers.ts
index 855bb0f0..f68df205 100644
--- a/renderer/src/features/workspace/storeHelpers.ts
+++ b/renderer/src/features/workspace/storeHelpers.ts
@@ -236,6 +236,8 @@ export const defaultAppSettings: AppSettings = {
apiKey: '',
baseUrl: 'https://api.deepseek.com/v1',
apiProtocol: 'auto',
+ codexCliPath: '',
+ codexReasoningEffort: 'default',
proxyUrl: '',
aiProfiles: [],
activeAiProfileId: '',
@@ -348,6 +350,12 @@ function normalizeAiProfile(profile: AiProfile): AiProfile {
|| profile.apiProtocol === 'anthropic'
? profile.apiProtocol
: 'auto',
+ codexCliPath: String(profile.codexCliPath ?? '').trim(),
+ codexReasoningEffort:
+ typeof profile.codexReasoningEffort === 'string'
+ && ['default', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(profile.codexReasoningEffort)
+ ? profile.codexReasoningEffort
+ : 'default',
temperature: normalizeOptionalNumber(profile.temperature, 0, 2),
topP: normalizeOptionalNumber(profile.topP, 0, 1),
presencePenalty: normalizeOptionalNumber(profile.presencePenalty, -2, 2),
@@ -371,6 +379,12 @@ export function normalizeAppSettings(settings?: Partial | null): Ap
const topP = normalizeOptionalNumber(source.topP, 0, 1)
const presencePenalty = normalizeOptionalNumber(source.presencePenalty, -2, 2)
const frequencyPenalty = normalizeOptionalNumber(source.frequencyPenalty, -2, 2)
+ const codexCliPath = sanitizeSettingString(source.codexCliPath, '')
+ const codexReasoningEffort =
+ typeof source.codexReasoningEffort === 'string'
+ && ['default', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'].includes(source.codexReasoningEffort)
+ ? source.codexReasoningEffort
+ : 'default'
let aiProfiles = Array.isArray(source.aiProfiles)
? source.aiProfiles.map(normalizeAiProfile).filter((profile) => profile.id)
@@ -379,25 +393,29 @@ export function normalizeAppSettings(settings?: Partial | null): Ap
if (aiProfiles.length === 0 && (apiKey || model !== defaultAppSettings.model)) {
const migratedId = `profile-${Date.now()}`
- aiProfiles = [{ id: migratedId, name: provider || 'Default', provider, baseUrl, apiKey, model, apiProtocol, temperature, topP, presencePenalty, frequencyPenalty }]
+ aiProfiles = [{ id: migratedId, name: provider || 'Default', provider, baseUrl, apiKey, model, apiProtocol, codexCliPath, codexReasoningEffort, temperature, topP, presencePenalty, frequencyPenalty }]
activeAiProfileId = migratedId
}
- if (activeAiProfileId && !aiProfiles.find(p => p.id === activeAiProfileId)) {
+ if (!aiProfiles.find(p => p.id === activeAiProfileId)) {
activeAiProfileId = aiProfiles[0]?.id ?? ''
}
+ const activeProfile = aiProfiles.find(p => p.id === activeAiProfileId)
+
return {
- provider,
- model,
- apiKey,
- baseUrl,
- apiProtocol,
+ provider: activeProfile?.provider ?? provider,
+ model: activeProfile?.model ?? model,
+ apiKey: activeProfile?.apiKey ?? apiKey,
+ baseUrl: activeProfile?.baseUrl ?? baseUrl,
+ apiProtocol: activeProfile?.apiProtocol ?? apiProtocol,
+ codexCliPath: activeProfile?.codexCliPath ?? codexCliPath,
+ codexReasoningEffort: activeProfile?.codexReasoningEffort ?? codexReasoningEffort,
proxyUrl: sanitizeSettingString(source.proxyUrl, defaultAppSettings.proxyUrl),
- temperature,
- topP,
- presencePenalty,
- frequencyPenalty,
+ temperature: activeProfile?.temperature ?? temperature,
+ topP: activeProfile?.topP ?? topP,
+ presencePenalty: activeProfile?.presencePenalty ?? presencePenalty,
+ frequencyPenalty: activeProfile?.frequencyPenalty ?? frequencyPenalty,
aiProfiles,
activeAiProfileId,
imageProvider: sanitizeSettingString(source.imageProvider, defaultAppSettings.imageProvider),
diff --git a/renderer/src/pages/ContinuationImportPage.vue b/renderer/src/pages/ContinuationImportPage.vue
index 539c792d..7302f3f2 100644
--- a/renderer/src/pages/ContinuationImportPage.vue
+++ b/renderer/src/pages/ContinuationImportPage.vue
@@ -35,6 +35,7 @@ import {
import { useAppStore } from '@/stores/app'
import { createDefaultWorkflowDocuments } from '@/features/novelWorkflow/documents'
+import { isCodexCliProvider } from '@shared/ai-provider-catalog'
import type {
CharacterCard,
CharacterRelationship,
@@ -192,6 +193,7 @@ const totalCharacterCount = computed(() => chapters.value.reduce((sum, chapter)
const volumeCount = computed(() => new Set(chapters.value.map((chapter) => chapter.volumeTitle.trim() || '正文')).size)
const hasAiSettings = computed(() => {
const settings = appStore.appSettings
+ if (isCodexCliProvider(settings.provider)) return Boolean(settings.model)
const isLocal = /^https?:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?(?:\/|$)/i.test(settings.baseUrl.trim())
return Boolean(settings.model && settings.baseUrl && (settings.apiKey || settings.provider === 'ollama' || isLocal))
})
diff --git a/renderer/src/stores/app.ts b/renderer/src/stores/app.ts
index bb1bd159..c8452030 100644
--- a/renderer/src/stores/app.ts
+++ b/renderer/src/stores/app.ts
@@ -2804,6 +2804,8 @@ export const useAppStore = defineStore('app', () => {
appSettings.value.apiKey = profile.apiKey
appSettings.value.baseUrl = profile.baseUrl
appSettings.value.apiProtocol = profile.apiProtocol ?? 'auto'
+ appSettings.value.codexCliPath = profile.codexCliPath ?? ''
+ appSettings.value.codexReasoningEffort = profile.codexReasoningEffort ?? 'default'
appSettings.value.temperature = profile.temperature
appSettings.value.topP = profile.topP
appSettings.value.presencePenalty = profile.presencePenalty
@@ -2839,6 +2841,8 @@ export const useAppStore = defineStore('app', () => {
if (updates.apiKey !== undefined) appSettings.value.apiKey = updates.apiKey
if (updates.baseUrl !== undefined) appSettings.value.baseUrl = updates.baseUrl
if (updates.apiProtocol !== undefined) appSettings.value.apiProtocol = updates.apiProtocol
+ if (updates.codexCliPath !== undefined) appSettings.value.codexCliPath = updates.codexCliPath
+ if (updates.codexReasoningEffort !== undefined) appSettings.value.codexReasoningEffort = updates.codexReasoningEffort
if ('temperature' in updates) appSettings.value.temperature = updates.temperature
if ('topP' in updates) appSettings.value.topP = updates.topP
if ('presencePenalty' in updates) appSettings.value.presencePenalty = updates.presencePenalty
diff --git a/renderer/src/types/app.ts b/renderer/src/types/app.ts
index 6410fe5b..f8752e17 100644
--- a/renderer/src/types/app.ts
+++ b/renderer/src/types/app.ts
@@ -875,6 +875,8 @@ export interface AiProfile {
apiKey: string
model: string
apiProtocol?: 'auto' | 'openai-responses' | 'openai-chat' | 'anthropic'
+ codexCliPath?: string
+ codexReasoningEffort?: 'default' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'
temperature?: number
topP?: number
presencePenalty?: number
@@ -894,6 +896,10 @@ export interface AppSettings {
baseUrl: string
/** API 线协议;auto 时按供应商和模型目录解析 */
apiProtocol?: 'auto' | 'openai-responses' | 'openai-chat' | 'anthropic'
+ /** Codex CLI 可执行文件或所在目录 */
+ codexCliPath: string
+ /** Codex CLI 推理强度 */
+ codexReasoningEffort: 'default' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'
/** AI 网络请求使用的 HTTP/HTTPS 代理地址 */
proxyUrl: string
/** 可选:模型采样温度,留空时使用服务端默认值 */