From fec53bb46f9f922a76101be236a07a947e04a813 Mon Sep 17 00:00:00 2001 From: Arepeater Date: Wed, 5 Aug 2026 17:57:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(ai):=20=E4=BF=AE=E5=A4=8D=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=95=86=E5=88=87=E6=8D=A2=E4=B8=8E=20Codex=20=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分离 OpenAI-compatible 与 Codex CLI 的模型和推理配置 - 复用本机 Codex provider、认证与自定义端点,并适配 Docker 回环地址 - 隔离 OpenAI 专属 reasoning_effort,修复自定义预设切换与表单保留 --- backend/app/api/settings.py | 59 ++++++++--- backend/app/services/ai_provider.py | 74 ++++++++++--- backend/tests/test_ai_provider.py | 117 +++++++++++++++++++-- frontend/src/lib/api.ts | 7 +- frontend/src/pages/settings/AI.tsx | 154 +++++++++++++++++++++------- 5 files changed, 331 insertions(+), 80 deletions(-) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 55f8bdf..71f8dea 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -58,7 +58,10 @@ def get_settings() -> dict: ai_configured, current_ai_model, current_codex_command, + current_codex_model, current_codex_reasoning_effort, + current_openai_model, + current_openai_reasoning_effort, ) key = secrets_store.get_tickflow_key() @@ -81,6 +84,9 @@ def get_settings() -> dict: "has_ai_key": bool(secrets_store.get_ai_key()), "ai_configured": ai_configured(ai_provider), "ai_model": current_ai_model(), + "ai_openai_model": current_openai_model(), + "ai_reasoning_effort": current_openai_reasoning_effort(), + "ai_codex_model": current_codex_model(), "ai_codex_command": current_codex_command(), "ai_codex_reasoning_effort": current_codex_reasoning_effort(), "ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent), @@ -240,6 +246,7 @@ class AiSettingsIn(BaseModel): base_url: str = "" api_key: str | None = None model: str = "" + reasoning_effort: str = Field(default="high", max_length=64) codex_command: str = "" codex_reasoning_effort: str = "" user_agent: str = "" @@ -250,12 +257,17 @@ def save_ai_settings(req: AiSettingsIn) -> dict: """保存 AI 配置(全部持久化到 secrets.json)""" from app.config import settings from app.services.ai_provider import ( + OPENAI_PROVIDER, ai_configured, current_ai_model, current_ai_provider, current_codex_command, + current_codex_model, current_codex_reasoning_effort, + current_openai_model, + current_openai_reasoning_effort, normalize_codex_command, + normalize_codex_model, normalize_codex_reasoning_effort, ) @@ -263,23 +275,8 @@ def save_ai_settings(req: AiSettingsIn) -> dict: if req.provider: updates["ai_provider"] = req.provider settings.ai_provider = req.provider - if req.base_url: - updates["ai_base_url"] = req.base_url - settings.ai_base_url = req.base_url - if req.api_key is not None: - if req.api_key: - updates["ai_api_key"] = req.api_key - settings.ai_api_key = req.api_key - else: - secrets_store.clear("ai_api_key") - settings.ai_api_key = "" - if req.provider == "codex_cli" and not req.model: - secrets_store.clear("ai_model") - settings.ai_model = "" - elif req.model: - updates["ai_model"] = req.model - settings.ai_model = req.model if req.provider == "codex_cli": + updates["ai_codex_model"] = normalize_codex_model(req.model) try: codex_command = normalize_codex_command(req.codex_command) except ValueError as exc: @@ -289,6 +286,22 @@ def save_ai_settings(req: AiSettingsIn) -> dict: updates["ai_codex_reasoning_effort"] = codex_reasoning_effort settings.ai_codex_command = codex_command settings.ai_codex_reasoning_effort = codex_reasoning_effort + else: + if req.base_url: + updates["ai_base_url"] = req.base_url + settings.ai_base_url = req.base_url + if req.api_key is not None: + if req.api_key: + updates["ai_api_key"] = req.api_key + settings.ai_api_key = req.api_key + else: + secrets_store.clear("ai_api_key") + settings.ai_api_key = "" + if req.model: + updates["ai_model"] = req.model + settings.ai_model = req.model + if req.provider == OPENAI_PROVIDER: + updates["ai_reasoning_effort"] = req.reasoning_effort.strip() # user_agent 允许清空(回到默认浏览器 UA),故无条件持久化 updates["ai_user_agent"] = req.user_agent settings.ai_user_agent = req.user_agent @@ -301,6 +314,9 @@ def save_ai_settings(req: AiSettingsIn) -> dict: "ok": True, "ai_provider": provider, "ai_model": current_ai_model(), + "ai_openai_model": current_openai_model(), + "ai_reasoning_effort": current_openai_reasoning_effort(), + "ai_codex_model": current_codex_model(), "ai_codex_command": current_codex_command(), "ai_codex_reasoning_effort": current_codex_reasoning_effort(), "ai_configured": ai_configured(provider), @@ -315,7 +331,16 @@ def clear_ai_settings() -> dict: """ from app.config import settings - secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command", "ai_codex_reasoning_effort") + secrets_store.clear( + "ai_provider", + "ai_base_url", + "ai_api_key", + "ai_model", + "ai_reasoning_effort", + "ai_codex_model", + "ai_codex_command", + "ai_codex_reasoning_effort", + ) # 同步重置运行时内存(provider 回默认值,其余置空) settings.ai_provider = "openai_compat" settings.ai_base_url = "" diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index 2886520..925d919 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -20,9 +20,11 @@ from app import secrets_store from app.config import settings OPENAI_COMPAT_PROVIDER = "openai_compat" +OPENAI_PROVIDER = "openai" CODEX_CLI_PROVIDER = "codex_cli" CODEX_DEFAULT_COMMAND = "codex" CODEX_SUPPORTED_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} +OPENAI_DEFAULT_REASONING_EFFORT = "high" _CODEX_ENV_ALLOWLIST = ( "PATH", @@ -104,10 +106,31 @@ def current_ai_provider() -> str: return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER +def current_openai_model() -> str: + return secrets_store.get_ai_config("ai_model", settings.ai_model) + + +def current_codex_model() -> str: + stored = secrets_store.load() + model = stored.get("ai_codex_model") + # 旧版本的两种 provider 共用 ai_model。仅在旧配置仍启用 Codex 时回退读取, + # 避免把正常的 OpenAI-compatible 模型误当作 Codex 模型。 + if model is None and current_ai_provider() == CODEX_CLI_PROVIDER: + model = stored.get("ai_model") + return normalize_codex_model(str(model or "")) + + def current_ai_model() -> str: if current_ai_provider() == CODEX_CLI_PROVIDER: - return normalize_codex_model(str(secrets_store.load().get("ai_model") or "")) - return secrets_store.get_ai_config("ai_model", settings.ai_model) + return current_codex_model() + return current_openai_model() + + +def current_openai_reasoning_effort() -> str: + stored = secrets_store.load() + if "ai_reasoning_effort" not in stored: + return OPENAI_DEFAULT_REASONING_EFFORT + return str(stored.get("ai_reasoning_effort") or "").strip() def current_codex_command() -> str: @@ -351,10 +374,14 @@ def _is_temperature_rejected(exc: Exception) -> bool: def _openai_kwargs(*, temperature: float | None, max_tokens: int) -> dict: - """Build OpenAI create() kwargs; temperature omitted when None.""" + """Build OpenAI create() kwargs; optional parameters are omitted when empty.""" kwargs: dict = {"max_tokens": max_tokens} if temperature is not None: kwargs["temperature"] = temperature + if current_ai_provider() == OPENAI_PROVIDER: + reasoning_effort = current_openai_reasoning_effort() + if reasoning_effort: + kwargs["reasoning_effort"] = reasoning_effort return kwargs @@ -700,10 +727,14 @@ def _codex_home() -> Path: def _write_compatible_codex_config(path: Path) -> None: config = _read_codex_config() lines: list[str] = [] - local_provider = _docker_codex_local_provider(config) + active_provider = _active_codex_provider(config) - if local_provider: - lines.append(_toml_string("model_provider", "codex_local_access")) + if active_provider: + lines.append(_toml_string("model_provider", active_provider[0])) + + openai_base_url = config.get("openai_base_url") + if isinstance(openai_base_url, str) and openai_base_url: + lines.append(_toml_string("openai_base_url", openai_base_url)) model = current_ai_model() or normalize_codex_model(str(config.get("model") or "")) if model: @@ -718,41 +749,43 @@ def _write_compatible_codex_config(path: Path) -> None: lines.append(_toml_string("approval_policy", "never")) lines.append(_toml_string("sandbox_mode", "read-only")) - if local_provider: + if active_provider: + provider_name, provider = active_provider lines.append("") - lines.append("[model_providers.codex_local_access]") + lines.append(f"[model_providers.{_toml_key(provider_name)}]") for key in ("name", "base_url", "wire_api", "experimental_bearer_token"): - value = local_provider.get(key) + value = provider.get(key) if isinstance(value, str) and value: lines.append(_toml_string(key, value)) for key in ("requires_openai_auth", "supports_websockets"): - value = local_provider.get(key) + value = provider.get(key) if isinstance(value, bool): lines.append(f"{key} = {'true' if value else 'false'}") path.write_text("\n".join(lines) + "\n", encoding="utf-8") -def _docker_codex_local_provider(config: dict) -> dict | None: - """Return the local-access provider adapted to Docker's host gateway.""" - docker_host = os.environ.get("CODEX_DOCKER_HOST", "").strip() - if not docker_host or config.get("model_provider") != "codex_local_access": +def _active_codex_provider(config: dict) -> tuple[str, dict] | None: + """Return the active custom provider, adapting loopback URLs for Docker.""" + provider_name = config.get("model_provider") + if not isinstance(provider_name, str) or not provider_name: return None providers = config.get("model_providers") if not isinstance(providers, dict): return None - source = providers.get("codex_local_access") + source = providers.get(provider_name) if not isinstance(source, dict): return None provider = dict(source) base_url = str(provider.get("base_url") or "").strip() parsed = urlsplit(base_url) - if parsed.hostname in {"localhost", "127.0.0.1", "::1"}: + docker_host = os.environ.get("CODEX_DOCKER_HOST", "").strip() + if docker_host and parsed.hostname in {"localhost", "127.0.0.1", "::1"}: port = f":{parsed.port}" if parsed.port else "" provider["base_url"] = urlunsplit(parsed._replace(netloc=f"{docker_host}{port}")) - return provider + return provider_name, provider def _read_codex_config() -> dict: @@ -786,6 +819,13 @@ def _toml_string(key: str, value: str) -> str: return f'{key} = "{escaped}"' +def _toml_key(value: str) -> str: + if re.fullmatch(r"[A-Za-z0-9_-]+", value): + return value + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + def _clean_process_text(raw: bytes) -> str: text = raw.decode("utf-8", errors="replace") return _ANSI_RE.sub("", text).strip() diff --git a/backend/tests/test_ai_provider.py b/backend/tests/test_ai_provider.py index 1b5a89c..e7cf262 100644 --- a/backend/tests/test_ai_provider.py +++ b/backend/tests/test_ai_provider.py @@ -5,6 +5,9 @@ import tomllib import httpx import openai +from app import secrets_store +from app.api import settings as settings_api +from app.config import settings from app.services import ai_provider from app.services.ai_provider import ( _format_openai_error, @@ -145,6 +148,96 @@ def test_is_temperature_rejected_false_for_non_400(): assert _is_temperature_rejected(exc) is False +def test_openai_kwargs_include_configured_reasoning_effort(monkeypatch): + stored = {"ai_provider": "openai_compat"} + monkeypatch.setattr(secrets_store, "load", lambda: stored) + + assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000) + + stored["ai_provider"] = "openai" + assert ai_provider._openai_kwargs(temperature=None, max_tokens=1000)["reasoning_effort"] == "high" + + stored["ai_reasoning_effort"] = "custom-high" + kwargs = ai_provider._openai_kwargs(temperature=0.3, max_tokens=1000) + + assert kwargs == { + "max_tokens": 1000, + "temperature": 0.3, + "reasoning_effort": "custom-high", + } + + stored["ai_reasoning_effort"] = "" + assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000) + + stored["ai_reasoning_effort"] = "custom-high" + stored["ai_provider"] = "openai_compat" + assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000) + + +def test_ai_settings_keep_provider_models_separate(monkeypatch): + stored = { + "ai_provider": "openai_compat", + "ai_model": "custom-api-model", + } + + def save(updates: dict) -> dict: + stored.update(updates) + return stored + + def clear(*keys: str) -> dict: + for key in keys: + stored.pop(key, None) + return stored + + monkeypatch.setattr(secrets_store, "load", lambda: stored) + monkeypatch.setattr(secrets_store, "save", save) + monkeypatch.setattr(secrets_store, "clear", clear) + monkeypatch.setattr(ai_provider, "ai_configured", lambda provider=None: True) + monkeypatch.setattr(settings, "ai_provider", "openai_compat") + monkeypatch.setattr(settings, "ai_base_url", "") + monkeypatch.setattr(settings, "ai_model", "") + monkeypatch.setattr(settings, "ai_codex_command", "codex") + monkeypatch.setattr(settings, "ai_codex_reasoning_effort", "") + monkeypatch.setattr(settings, "ai_user_agent", "") + + settings_api.save_ai_settings( + settings_api.AiSettingsIn( + provider="codex_cli", + model="gpt-5.6-sol", + codex_command="codex", + codex_reasoning_effort="high", + ) + ) + + assert stored["ai_model"] == "custom-api-model" + assert stored["ai_codex_model"] == "gpt-5.6-sol" + + settings_api.save_ai_settings( + settings_api.AiSettingsIn( + provider="openai", + base_url="https://api.openai.com/v1", + model="openai-model", + reasoning_effort="vendor-high", + ) + ) + + assert stored["ai_model"] == "openai-model" + assert stored["ai_reasoning_effort"] == "vendor-high" + assert stored["ai_codex_model"] == "gpt-5.6-sol" + + settings_api.save_ai_settings( + settings_api.AiSettingsIn( + provider="openai_compat", + base_url="https://example.com/v1", + model="new-custom-model", + ) + ) + + assert stored["ai_model"] == "new-custom-model" + assert stored["ai_reasoning_effort"] == "vendor-high" + assert stored["ai_codex_model"] == "gpt-5.6-sol" + + def test_codex_process_env_excludes_application_secrets(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "test-path") monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example") @@ -201,7 +294,7 @@ def test_codex_config_adapts_local_access_provider_for_docker(monkeypatch, tmp_p assert provider["supports_websockets"] is False -def test_codex_config_does_not_copy_provider_without_docker_opt_in(monkeypatch, tmp_path): +def test_codex_config_preserves_remote_provider_without_docker_rewrite(monkeypatch, tmp_path): monkeypatch.delenv("CODEX_DOCKER_HOST", raising=False) monkeypatch.setattr(ai_provider, "current_ai_model", lambda: "") monkeypatch.setattr(ai_provider, "current_codex_reasoning_effort", lambda: "") @@ -209,11 +302,13 @@ def test_codex_config_does_not_copy_provider_without_docker_opt_in(monkeypatch, ai_provider, "_read_codex_config", lambda: { - "model_provider": "codex_local_access", + "model_provider": "remote-api", + "openai_base_url": "https://builtin.example/v1", "model_providers": { - "codex_local_access": { - "base_url": "http://localhost:62678/v1", - "experimental_bearer_token": "must-not-leak", + "remote-api": { + "base_url": "https://custom.example/v1", + "wire_api": "responses", + "requires_openai_auth": True, } }, }, @@ -222,7 +317,11 @@ def test_codex_config_does_not_copy_provider_without_docker_opt_in(monkeypatch, ai_provider._write_compatible_codex_config(path) - text = path.read_text(encoding="utf-8") - assert "model_provider" not in text - assert "model_providers" not in text - assert "must-not-leak" not in text + with path.open("rb") as f: + config = tomllib.load(f) + assert config["model_provider"] == "remote-api" + assert config["openai_base_url"] == "https://builtin.example/v1" + provider = config["model_providers"]["remote-api"] + assert provider["base_url"] == "https://custom.example/v1" + assert provider["wire_api"] == "responses" + assert provider["requires_openai_auth"] is True diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c4f2e27..26d2685 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -860,6 +860,9 @@ export interface SettingsState { has_ai_key: boolean ai_configured?: boolean ai_model: string + ai_openai_model?: string + ai_reasoning_effort?: string + ai_codex_model?: string ai_codex_command?: string ai_codex_reasoning_effort?: string ai_user_agent: string @@ -1078,8 +1081,8 @@ export const api = { ), /** 保存 AI 配置 */ - saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; codex_command?: string; codex_reasoning_effort?: string; user_agent?: string }) => - request<{ ok: boolean; ai_provider?: string; ai_model?: string; ai_codex_command?: string; ai_codex_reasoning_effort?: string; ai_configured?: boolean }>('/api/settings/ai', { + saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; reasoning_effort?: string; codex_command?: string; codex_reasoning_effort?: string; user_agent?: string }) => + request<{ ok: boolean; ai_provider?: string; ai_model?: string; ai_openai_model?: string; ai_reasoning_effort?: string; ai_codex_model?: string; ai_codex_command?: string; ai_codex_reasoning_effort?: string; ai_configured?: boolean }>('/api/settings/ai', { method: 'POST', body: JSON.stringify(ai), }), diff --git a/frontend/src/pages/settings/AI.tsx b/frontend/src/pages/settings/AI.tsx index ed7b9fb..76f302d 100644 --- a/frontend/src/pages/settings/AI.tsx +++ b/frontend/src/pages/settings/AI.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield, @@ -14,10 +14,13 @@ const INPUT_CLS = 'w-full h-9 px-2.5 rounded-lg bg-base border-0 ring-1 ring-border/30 text-xs font-mono text-foreground placeholder:text-muted/30 focus:outline-none focus:ring-2 focus:ring-accent/30 transition-shadow' const CODEX_PROVIDER = 'codex_cli' -const OPENAI_PROVIDER = 'openai_compat' +const OPENAI_PROVIDER = 'openai' +const OPENAI_COMPAT_PROVIDER = 'openai_compat' const CODEX_COMMAND = 'codex' const DEFAULT_CODEX_MODEL = 'gpt-5.6-sol' const DEFAULT_CODEX_REASONING_EFFORT = 'xhigh' +const DEFAULT_OPENAI_MODEL = 'gpt-5.5' +const DEFAULT_REASONING_EFFORT = 'high' const SAVED_CODEX_OPTION_VALUE = '__saved_codex_config__' const CODEX_REASONING_LABELS: Record = { high: '高', @@ -42,8 +45,11 @@ const codexModelLabel = (model?: string, effort?: string) => { return effortLabel ? `${modelLabel} · ${effortLabel}` : modelLabel } -const PRESETS: { label: string; provider?: string; url: string; model: string; codexCommand?: string; website: string; websiteLabel: string; description: string; custom?: boolean }[] = [ +type AiPreset = { label: string; provider?: string; url: string; model: string; codexCommand?: string; website: string; websiteLabel: string; description: string; custom?: boolean } + +const PRESETS: AiPreset[] = [ { label: '自定义', url: '', model: '', website: '', websiteLabel: '', description: '不自动填充任何配置,完全手动填写 API 地址、模型和密钥。', custom: true }, + { label: 'OpenAI', provider: OPENAI_PROVIDER, url: 'https://api.openai.com/v1', model: DEFAULT_OPENAI_MODEL, website: 'https://platform.openai.com/', websiteLabel: 'platform.openai.com', description: 'OpenAI 官方接口,可单独配置模型支持的推理强度。' }, { label: 'DeepSeek', url: 'https://api.deepseek.com', model: 'deepseek-v4-pro', website: 'https://www.deepseek.com/', websiteLabel: 'deepseek.com', description: 'DeepSeek 官方 OpenAI 兼容接口。' }, { label: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-3.6plus', website: 'https://tongyi.aliyun.com/', websiteLabel: 'tongyi.aliyun.com', description: '阿里云 DashScope 兼容模式接口。' }, { label: '智谱 GLM', url: 'https://open.bigmodel.cn/api/paas/v4', model: 'glm-5.2', website: 'https://open.bigmodel.cn/', websiteLabel: 'open.bigmodel.cn', description: '智谱 AI 官方 OpenAI 兼容接口。' }, @@ -52,15 +58,23 @@ const PRESETS: { label: string; provider?: string; url: string; model: string; c { label: '炸鸡中转站', url: 'https://api.zhaji.dev/v1', model: 'gpt-5.5', website: 'https://api.zhaji.dev', websiteLabel: 'api.zhaji.dev', description: 'OpenAI 兼容中转服务,适合直接使用国际模型。' }, ] +const findPreset = (provider: string, baseUrl: string, codexCommand: string) => PRESETS.find(p => { + if (p.custom || (p.provider ?? OPENAI_COMPAT_PROVIDER) !== provider) return false + if (provider === OPENAI_PROVIDER) return true + return provider === CODEX_PROVIDER ? p.codexCommand === codexCommand : p.url === baseUrl +}) ?? PRESETS[0] + export function SettingsAIPanel() { const qc = useQueryClient() const settings = useSettings() const s = settings.data - const [provider, setProvider] = useState(OPENAI_PROVIDER) + const [provider, setProvider] = useState(OPENAI_COMPAT_PROVIDER) const [baseUrl, setBaseUrl] = useState('') const [apiKey, setApiKey] = useState('') const [model, setModel] = useState('') + const [reasoningEffort, setReasoningEffort] = useState(DEFAULT_REASONING_EFFORT) + const [codexModel, setCodexModel] = useState('') const [codexReasoningEffort, setCodexReasoningEffort] = useState('') const [codexCommand, setCodexCommand] = useState(CODEX_COMMAND) const [customUa, setCustomUa] = useState(false) @@ -70,15 +84,21 @@ export function SettingsAIPanel() { const [confirmClear, setConfirmClear] = useState(false) const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null) + const [selectedPresetLabel, setSelectedPresetLabel] = useState(PRESETS[0].label) + const directDrafts = useRef({ + custom: { baseUrl: '', model: '' }, + openai: { baseUrl: 'https://api.openai.com/v1', model: DEFAULT_OPENAI_MODEL }, + }) + const draftsInitialized = useRef(false) const isCodexProvider = provider === CODEX_PROVIDER + const isOpenAIProvider = provider === OPENAI_PROVIDER const savedCodexProvider = s?.ai_provider === CODEX_PROVIDER const configured = s?.ai_configured ?? (savedCodexProvider ? !!(s?.ai_codex_command ?? CODEX_COMMAND) : s?.has_ai_key) - // 选中的预设: 精确匹配 provider+url/codexCommand; 匹配不上时默认"自定义" - const matchedPreset = PRESETS.find(p => (p.provider ?? OPENAI_PROVIDER) === provider && (isCodexProvider ? p.codexCommand === codexCommand : p.url === baseUrl)) - const selectedPreset = matchedPreset ?? PRESETS.find(p => p.custom) - const savedCodexModel = savedCodexProvider ? (s?.ai_model ?? '') : '' - const savedCodexEffort = savedCodexProvider ? (s?.ai_codex_reasoning_effort ?? '') : '' + const selectedPreset = PRESETS.find(p => p.label === selectedPresetLabel) ?? PRESETS[0] + const configTitle = isCodexProvider ? 'Codex CLI 配置' : isOpenAIProvider ? 'OpenAI 配置' : selectedPreset.custom ? '自定义配置' : `${selectedPreset.label} 配置` + const savedCodexModel = s?.ai_codex_model ?? (savedCodexProvider ? (s?.ai_model ?? '') : '') + const savedCodexEffort = s?.ai_codex_reasoning_effort ?? '' const savedCodexOptionKnown = CODEX_MODEL_OPTIONS.some(option => option.model === savedCodexModel && option.effort === savedCodexEffort, ) @@ -96,7 +116,7 @@ export function SettingsAIPanel() { ? [savedCodexOption, ...CODEX_MODEL_OPTIONS] : CODEX_MODEL_OPTIONS const selectedCodexModelOption = codexModelOptions.find(option => - option.model === model && option.effort === codexReasoningEffort, + option.model === codexModel && option.effort === codexReasoningEffort, ) ?? CODEX_MODEL_OPTIONS[0] const codexModelSelectValue = selectedCodexModelOption.value const canSave = isCodexProvider ? true : !!baseUrl.trim() && !!model.trim() @@ -105,11 +125,26 @@ export function SettingsAIPanel() { if (!s) return // 未配置过 AI (无 api_key): 字段留空, 默认选中"自定义"预设, 不预填充后端默认值 const unconfigured = !s.has_ai_key && !s.ai_configured - const savedProvider = s.ai_provider ?? OPENAI_PROVIDER + const savedProvider = s.ai_provider ?? OPENAI_COMPAT_PROVIDER + const savedBaseUrl = unconfigured ? '' : (s.ai_base_url ?? '') + const savedOpenAIModel = unconfigured ? '' : (s.ai_openai_model ?? (savedProvider !== CODEX_PROVIDER ? s.ai_model : '') ?? '') + const savedPreset = unconfigured ? PRESETS[0] : findPreset(savedProvider, savedBaseUrl, s.ai_codex_command ?? CODEX_COMMAND) + if (!draftsInitialized.current) { + const officialOpenAI = PRESETS.find(p => p.provider === OPENAI_PROVIDER) + if (savedProvider === OPENAI_PROVIDER || (savedProvider === CODEX_PROVIDER && savedBaseUrl === officialOpenAI?.url)) { + directDrafts.current.openai = { baseUrl: savedBaseUrl, model: savedOpenAIModel } + } else if (findPreset(OPENAI_COMPAT_PROVIDER, savedBaseUrl, CODEX_COMMAND).custom) { + directDrafts.current.custom = { baseUrl: savedBaseUrl, model: savedOpenAIModel } + } + draftsInitialized.current = true + } setProvider(savedProvider) - setBaseUrl(unconfigured ? '' : (s.ai_base_url ?? '')) - setModel(unconfigured ? '' : (s.ai_model ?? '')) - setCodexReasoningEffort(unconfigured ? '' : (s.ai_codex_reasoning_effort ?? '')) + setSelectedPresetLabel(savedPreset.label) + setBaseUrl(savedBaseUrl) + setModel(savedOpenAIModel) + setReasoningEffort(s.ai_reasoning_effort ?? DEFAULT_REASONING_EFFORT) + setCodexModel(s.ai_codex_model ?? (savedProvider === CODEX_PROVIDER ? s.ai_model : '') ?? '') + setCodexReasoningEffort(s.ai_codex_reasoning_effort ?? '') setCodexCommand(s.ai_codex_command ?? CODEX_COMMAND) const ua = s.ai_user_agent ?? '' setCustomUa(!!ua) @@ -120,7 +155,8 @@ export function SettingsAIPanel() { provider, base_url: baseUrl, api_key: apiKey || undefined, - model, + model: isCodexProvider ? codexModel : model, + ...(isOpenAIProvider ? { reasoning_effort: reasoningEffort } : {}), codex_command: isCodexProvider ? CODEX_COMMAND : codexCommand, codex_reasoning_effort: isCodexProvider ? codexReasoningEffort : '', user_agent: customUa ? userAgent : '', @@ -135,7 +171,10 @@ export function SettingsAIPanel() { ...prev, ai_provider: result.ai_provider ?? provider, ai_base_url: baseUrl, - ai_model: result.ai_model ?? model, + ai_model: result.ai_model ?? (isCodexProvider ? codexModel : model), + ai_openai_model: result.ai_openai_model ?? model, + ai_reasoning_effort: result.ai_reasoning_effort ?? reasoningEffort, + ai_codex_model: result.ai_codex_model ?? codexModel, ai_codex_command: result.ai_codex_command ?? (isCodexProvider ? CODEX_COMMAND : codexCommand), ai_codex_reasoning_effort: result.ai_codex_reasoning_effort ?? (isCodexProvider ? codexReasoningEffort : ''), ai_configured: result.ai_configured ?? (isCodexProvider ? true : (apiKey ? true : prev.ai_configured)), @@ -153,18 +192,28 @@ export function SettingsAIPanel() { mutationFn: () => api.clearAiSettings(), onSuccess: () => { setConfirmClear(false) - setProvider(OPENAI_PROVIDER) + setProvider(OPENAI_COMPAT_PROVIDER) + setSelectedPresetLabel(PRESETS[0].label) setBaseUrl('') setApiKey('') setModel('') + setReasoningEffort(DEFAULT_REASONING_EFFORT) + setCodexModel('') setCodexReasoningEffort('') setCodexCommand(CODEX_COMMAND) + directDrafts.current = { + custom: { baseUrl: '', model: '' }, + openai: { baseUrl: 'https://api.openai.com/v1', model: DEFAULT_OPENAI_MODEL }, + } setTestResult(null) qc.setQueryData(QK.settings, prev => prev ? { ...prev, - ai_provider: OPENAI_PROVIDER, + ai_provider: OPENAI_COMPAT_PROVIDER, ai_base_url: '', ai_model: '', + ai_openai_model: '', + ai_reasoning_effort: DEFAULT_REASONING_EFFORT, + ai_codex_model: '', ai_codex_command: CODEX_COMMAND, ai_codex_reasoning_effort: '', has_ai_key: false, @@ -186,20 +235,42 @@ export function SettingsAIPanel() { setUserAgent(`Mozilla/5.0 (${pf}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`) } - const handlePreset = (p: typeof PRESETS[number]) => { + const handlePreset = (p: AiPreset) => { + setSelectedPresetLabel(p.label) if (p.custom) { - // 自定义: 清空所有自动填充字段, 由用户完全手动填写 - setProvider(OPENAI_PROVIDER) - setBaseUrl('') - setModel('') - setCodexReasoningEffort('') + setProvider(OPENAI_COMPAT_PROVIDER) + setBaseUrl(directDrafts.current.custom.baseUrl) + setModel(directDrafts.current.custom.model) return } - setProvider(p.provider ?? OPENAI_PROVIDER) - setBaseUrl(p.url) - setModel(p.model) - setCodexReasoningEffort(p.provider === CODEX_PROVIDER ? DEFAULT_CODEX_REASONING_EFFORT : '') - if (p.codexCommand) setCodexCommand(CODEX_COMMAND) + if (p.provider === CODEX_PROVIDER) { + setProvider(CODEX_PROVIDER) + setCodexModel(p.model) + setCodexReasoningEffort(DEFAULT_CODEX_REASONING_EFFORT) + setCodexCommand(CODEX_COMMAND) + return + } + const nextProvider = p.provider ?? OPENAI_COMPAT_PROVIDER + setProvider(nextProvider) + if (nextProvider === OPENAI_PROVIDER) { + setBaseUrl(directDrafts.current.openai.baseUrl) + setModel(directDrafts.current.openai.model) + } else { + setBaseUrl(p.url) + setModel(p.model) + } + } + + const handleBaseUrlChange = (value: string) => { + setBaseUrl(value) + if (selectedPreset.custom) directDrafts.current.custom.baseUrl = value + if (isOpenAIProvider) directDrafts.current.openai.baseUrl = value + } + + const handleModelChange = (value: string) => { + setModel(value) + if (selectedPreset.custom) directDrafts.current.custom.model = value + if (isOpenAIProvider) directDrafts.current.openai.model = value } const handleTest = async () => { @@ -280,7 +351,7 @@ export function SettingsAIPanel() { {isCodexProvider ? 'codex exec' : 'Chat Completions'} @@ -290,7 +361,7 @@ export function SettingsAIPanel() { >
{isCodexProvider ? ( -
+
{CODEX_COMMAND} @@ -305,7 +376,7 @@ export function SettingsAIPanel() { onChange={e => { const value = e.target.value const option = codexModelOptions.find(item => item.value === value) ?? CODEX_MODEL_OPTIONS[0] - setModel(option.model) + setCodexModel(option.model) setCodexReasoningEffort(option.effort) }} className={INPUT_CLS} @@ -318,15 +389,28 @@ export function SettingsAIPanel() {
) : ( <> -
+
- setBaseUrl(e.target.value)} placeholder="https://api.zhaji.dev/v1" className={INPUT_CLS} /> + handleBaseUrlChange(e.target.value)} placeholder="https://api.zhaji.dev/v1" className={INPUT_CLS} /> - setModel(e.target.value)} placeholder="gpt-5.6-sol" className={INPUT_CLS} /> + handleModelChange(e.target.value)} placeholder="gpt-5.6-sol" className={INPUT_CLS} />
+ {isOpenAIProvider && ( +
+
+ OpenAI 专属 +
+
+ + setReasoningEffort(e.target.value)} placeholder={DEFAULT_REASONING_EFFORT} className={INPUT_CLS} /> + +
+
+ )} +