mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 14:24:15 +08:00
fix(ai): 修复提供商切换与 Codex 自定义端点
- 分离 OpenAI-compatible 与 Codex CLI 的模型和推理配置 - 复用本机 Codex provider、认证与自定义端点,并适配 Docker 回环地址 - 隔离 OpenAI 专属 reasoning_effort,修复自定义预设切换与表单保留
This commit is contained in:
+42
-17
@@ -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 = ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<SettingsState>(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() {
|
||||
|
||||
<Card
|
||||
icon={Settings2}
|
||||
title="自定义配置"
|
||||
title={configTitle}
|
||||
right={
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted/60" title={isCodexProvider ? 'Use local Codex CLI via codex exec' : 'Use OpenAI-compatible Chat Completions API'}>
|
||||
<span className="rounded-full border border-border/40 bg-base/50 px-1.5 py-px font-mono">{isCodexProvider ? 'codex exec' : 'Chat Completions'}</span>
|
||||
@@ -290,7 +361,7 @@ export function SettingsAIPanel() {
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{isCodexProvider ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="CLI 命令" hint="固定使用默认 codex 命令, 由后端自动解析本机 Codex Desktop/CLI, 不支持自定义可执行路径。">
|
||||
<div className={`${INPUT_CLS} flex items-center text-muted/80 select-none`} aria-label="Codex CLI command">
|
||||
{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() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="API 地址">
|
||||
<input type="text" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} placeholder="https://api.zhaji.dev/v1" className={INPUT_CLS} />
|
||||
<input type="text" value={baseUrl} onChange={e => handleBaseUrlChange(e.target.value)} placeholder="https://api.zhaji.dev/v1" className={INPUT_CLS} />
|
||||
</Field>
|
||||
<Field label="模型">
|
||||
<input type="text" value={model} onChange={e => setModel(e.target.value)} placeholder="gpt-5.6-sol" className={INPUT_CLS} />
|
||||
<input type="text" value={model} onChange={e => handleModelChange(e.target.value)} placeholder="gpt-5.6-sol" className={INPUT_CLS} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{isOpenAIProvider && (
|
||||
<div className="rounded-lg border border-accent/15 bg-accent/[0.03] p-3">
|
||||
<div className="mb-2.5">
|
||||
<span className="rounded-full bg-accent/10 px-2 py-0.5 text-[10px] font-medium text-accent">OpenAI 专属</span>
|
||||
</div>
|
||||
<div className="max-w-xs">
|
||||
<Field label="推理强度">
|
||||
<input type="text" value={reasoningEffort} onChange={e => setReasoningEffort(e.target.value)} placeholder={DEFAULT_REASONING_EFFORT} className={INPUT_CLS} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label="API Key">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
|
||||
Reference in New Issue
Block a user