mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
feat: 完善 Codex CLI 模型配置与安全隔离
This commit is contained in:
@@ -53,7 +53,12 @@ def get_settings() -> dict:
|
||||
"""返回当前配置概况(Key 脱敏)。"""
|
||||
from app.config import settings
|
||||
from app.services import preferences
|
||||
from app.services.ai_provider import ai_configured, current_ai_model, current_codex_command
|
||||
from app.services.ai_provider import (
|
||||
ai_configured,
|
||||
current_ai_model,
|
||||
current_codex_command,
|
||||
current_codex_reasoning_effort,
|
||||
)
|
||||
|
||||
key = secrets_store.get_tickflow_key()
|
||||
ai_provider = secrets_store.get_ai_config("ai_provider", settings.ai_provider)
|
||||
@@ -76,6 +81,7 @@ def get_settings() -> dict:
|
||||
"ai_configured": ai_configured(ai_provider),
|
||||
"ai_model": current_ai_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),
|
||||
}
|
||||
|
||||
@@ -234,6 +240,7 @@ class AiSettingsIn(BaseModel):
|
||||
api_key: str | None = None
|
||||
model: str = ""
|
||||
codex_command: str = ""
|
||||
codex_reasoning_effort: str = ""
|
||||
user_agent: str = ""
|
||||
|
||||
|
||||
@@ -241,7 +248,15 @@ class AiSettingsIn(BaseModel):
|
||||
def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
"""保存 AI 配置(全部持久化到 secrets.json)"""
|
||||
from app.config import settings
|
||||
from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider, current_codex_command, normalize_codex_command
|
||||
from app.services.ai_provider import (
|
||||
ai_configured,
|
||||
current_ai_model,
|
||||
current_ai_provider,
|
||||
current_codex_command,
|
||||
current_codex_reasoning_effort,
|
||||
normalize_codex_command,
|
||||
normalize_codex_reasoning_effort,
|
||||
)
|
||||
|
||||
updates: dict = {}
|
||||
if req.provider:
|
||||
@@ -268,8 +283,11 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
codex_command = normalize_codex_command(req.codex_command)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
codex_reasoning_effort = normalize_codex_reasoning_effort(req.codex_reasoning_effort)
|
||||
updates["ai_codex_command"] = codex_command
|
||||
updates["ai_codex_reasoning_effort"] = codex_reasoning_effort
|
||||
settings.ai_codex_command = codex_command
|
||||
settings.ai_codex_reasoning_effort = codex_reasoning_effort
|
||||
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化
|
||||
updates["ai_user_agent"] = req.user_agent
|
||||
settings.ai_user_agent = req.user_agent
|
||||
@@ -283,6 +301,7 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
"ai_provider": provider,
|
||||
"ai_model": current_ai_model(),
|
||||
"ai_codex_command": current_codex_command(),
|
||||
"ai_codex_reasoning_effort": current_codex_reasoning_effort(),
|
||||
"ai_configured": ai_configured(provider),
|
||||
}
|
||||
|
||||
@@ -295,13 +314,14 @@ 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")
|
||||
secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command", "ai_codex_reasoning_effort")
|
||||
# 同步重置运行时内存(provider 回默认值,其余置空)
|
||||
settings.ai_provider = "openai_compat"
|
||||
settings.ai_base_url = ""
|
||||
settings.ai_api_key = ""
|
||||
settings.ai_model = ""
|
||||
settings.ai_codex_command = "codex"
|
||||
settings.ai_codex_reasoning_effort = ""
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -564,7 +564,11 @@ async def ai_test(request: Request):
|
||||
)
|
||||
return {"ok": True, "model": current_ai_model() or current_ai_provider(), "response": text[:80]}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
return {
|
||||
"ok": False,
|
||||
"error": str(e) or repr(e),
|
||||
"error_type": type(e).__name__,
|
||||
}
|
||||
|
||||
|
||||
def _build_prompt(req: BuildRequest) -> str:
|
||||
|
||||
@@ -81,6 +81,7 @@ class Settings(BaseSettings):
|
||||
ai_api_key: str = ""
|
||||
ai_model: str = "gpt-5.5"
|
||||
ai_codex_command: str = "codex"
|
||||
ai_codex_reasoning_effort: str = ""
|
||||
# 默认浏览器风格 UA,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
|
||||
# 用户可在 AI 设置页按需修改。
|
||||
ai_user_agent: str = (
|
||||
|
||||
@@ -5,11 +5,15 @@ import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import tomllib
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from collections.abc import AsyncIterator, Callable, Sequence
|
||||
from pathlib import Path
|
||||
from types import TracebackType
|
||||
|
||||
from app import secrets_store
|
||||
from app.config import settings
|
||||
@@ -17,8 +21,47 @@ from app.config import settings
|
||||
OPENAI_COMPAT_PROVIDER = "openai_compat"
|
||||
CODEX_CLI_PROVIDER = "codex_cli"
|
||||
CODEX_DEFAULT_COMMAND = "codex"
|
||||
CODEX_SERVICE_TIER_FALLBACK = "fast"
|
||||
CODEX_SUPPORTED_SERVICE_TIERS = {"fast", "flex"}
|
||||
CODEX_SUPPORTED_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"}
|
||||
|
||||
_CODEX_ENV_ALLOWLIST = (
|
||||
"PATH",
|
||||
"PATHEXT",
|
||||
"SYSTEMROOT",
|
||||
"WINDIR",
|
||||
"COMSPEC",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"APPDATA",
|
||||
"LOCALAPPDATA",
|
||||
"PROGRAMDATA",
|
||||
"PROGRAMFILES",
|
||||
"PROGRAMFILES(X86)",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"TMPDIR",
|
||||
"SHELL",
|
||||
"USER",
|
||||
"LOGNAME",
|
||||
"LANG",
|
||||
"LC_ALL",
|
||||
"LC_CTYPE",
|
||||
"TZ",
|
||||
"HTTP_PROXY",
|
||||
"HTTPS_PROXY",
|
||||
"ALL_PROXY",
|
||||
"NO_PROXY",
|
||||
"http_proxy",
|
||||
"https_proxy",
|
||||
"all_proxy",
|
||||
"no_proxy",
|
||||
"SSL_CERT_FILE",
|
||||
"SSL_CERT_DIR",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"CURL_CA_BUNDLE",
|
||||
"NODE_EXTRA_CA_CERTS",
|
||||
)
|
||||
|
||||
Message = dict[str, str]
|
||||
|
||||
@@ -42,6 +85,15 @@ def current_codex_command() -> str:
|
||||
)
|
||||
|
||||
|
||||
def current_codex_reasoning_effort() -> str:
|
||||
return normalize_codex_reasoning_effort(
|
||||
secrets_store.get_ai_config(
|
||||
"ai_codex_reasoning_effort",
|
||||
settings.ai_codex_reasoning_effort,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_codex_cli_provider(provider: str | None = None) -> bool:
|
||||
return (provider or current_ai_provider()) == CODEX_CLI_PROVIDER
|
||||
|
||||
@@ -49,12 +101,20 @@ def is_codex_cli_provider(provider: str | None = None) -> bool:
|
||||
def normalize_codex_model(model: str) -> str:
|
||||
value = model.strip()
|
||||
aliases = {
|
||||
"gpt5": "gpt-5",
|
||||
"gpt5.5": "gpt-5.5",
|
||||
"gpt5.6": "gpt-5.6-sol",
|
||||
"gpt5.6-sol": "gpt-5.6-sol",
|
||||
"gpt5.6-terra": "gpt-5.6-terra",
|
||||
"gpt5.6-luna": "gpt-5.6-luna",
|
||||
}
|
||||
return aliases.get(value.lower(), value)
|
||||
|
||||
|
||||
def normalize_codex_reasoning_effort(effort: str | None) -> str:
|
||||
value = (effort or "").strip().lower()
|
||||
return value if value in CODEX_SUPPORTED_REASONING_EFFORTS else ""
|
||||
|
||||
|
||||
def normalize_codex_command(command: str | None, *, strict: bool = True) -> str:
|
||||
value = (command or "").strip()
|
||||
if not value or value.lower() == CODEX_DEFAULT_COMMAND:
|
||||
@@ -363,8 +423,8 @@ async def _run_codex_cli(
|
||||
timeout: float,
|
||||
) -> str:
|
||||
prompt = _codex_prompt(messages, max_tokens=max_tokens)
|
||||
with tempfile.TemporaryDirectory(prefix="tickflow-codex-run-") as run_dir:
|
||||
run_path = Path(run_dir)
|
||||
run_path = Path(tempfile.mkdtemp(prefix="tickflow-codex-run-"))
|
||||
try:
|
||||
codex_home_path = run_path / "codex-home"
|
||||
workspace_path = run_path / "workspace"
|
||||
codex_home_path.mkdir()
|
||||
@@ -389,37 +449,107 @@ async def _run_codex_cli(
|
||||
args.extend(["--model", model])
|
||||
args.extend(["--cd", str(workspace_path), "-"])
|
||||
|
||||
env = os.environ.copy()
|
||||
env.setdefault("NO_COLOR", "1")
|
||||
env["CODEX_HOME"] = str(codex_home_path)
|
||||
env = _codex_process_env(codex_home_path)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
returncode, stdout, stderr = await asyncio.to_thread(
|
||||
_run_codex_process,
|
||||
args,
|
||||
prompt,
|
||||
env,
|
||||
timeout,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(prompt.encode("utf-8")),
|
||||
timeout=timeout,
|
||||
)
|
||||
except TimeoutError as exc:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
raise RuntimeError("Codex CLI 调用超时, 请稍后重试或检查本机 Codex 登录状态") from exc
|
||||
|
||||
out = _clean_process_text(stdout)
|
||||
err = _clean_process_text(stderr)
|
||||
final_message = _read_output_file(output_path)
|
||||
if proc.returncode != 0:
|
||||
detail = err or out or f"exit code {proc.returncode}"
|
||||
if returncode != 0:
|
||||
detail = err or out or f"exit code {returncode}"
|
||||
raise RuntimeError(f"Codex CLI 调用失败: {detail[-1200:]}")
|
||||
result = final_message or out
|
||||
if not result:
|
||||
raise RuntimeError("Codex CLI 未返回内容")
|
||||
return result
|
||||
finally:
|
||||
await asyncio.to_thread(_remove_tree_best_effort, run_path)
|
||||
|
||||
|
||||
def _run_codex_process(
|
||||
args: Sequence[str],
|
||||
prompt: str,
|
||||
env: dict[str, str],
|
||||
timeout: float,
|
||||
) -> tuple[int, bytes, bytes]:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
list(args),
|
||||
input=prompt.encode("utf-8"),
|
||||
capture_output=True,
|
||||
env=env,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError("Codex CLI 调用超时, 请稍后重试或检查本机 Codex 登录状态") from exc
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
|
||||
|
||||
def _codex_process_env(codex_home_path: Path) -> dict[str, str]:
|
||||
"""Pass only OS, locale, certificate, and proxy settings to Codex."""
|
||||
env: dict[str, str] = {}
|
||||
seen: set[str] = set()
|
||||
for name in _CODEX_ENV_ALLOWLIST:
|
||||
normalized = name.casefold() if os.name == "nt" else name
|
||||
if normalized in seen:
|
||||
continue
|
||||
value = os.environ.get(name)
|
||||
if value:
|
||||
env[name] = value
|
||||
seen.add(normalized)
|
||||
env["NO_COLOR"] = "1"
|
||||
env["CODEX_HOME"] = str(codex_home_path)
|
||||
return env
|
||||
|
||||
|
||||
def _remove_tree_best_effort(path: Path) -> None:
|
||||
_remove_auth_files(path)
|
||||
for attempt in range(4):
|
||||
try:
|
||||
shutil.rmtree(path, onerror=_make_writable_and_retry)
|
||||
return
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
if attempt == 3:
|
||||
break
|
||||
time.sleep(0.2 * (attempt + 1))
|
||||
_remove_auth_files(path)
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
_remove_auth_files(path)
|
||||
|
||||
|
||||
def _remove_auth_files(path: Path) -> None:
|
||||
try:
|
||||
auth_files = list(path.rglob("auth.json"))
|
||||
except OSError:
|
||||
return
|
||||
for auth_file in auth_files:
|
||||
try:
|
||||
os.chmod(auth_file, stat.S_IWRITE)
|
||||
auth_file.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _make_writable_and_retry(
|
||||
func: Callable[[str], object],
|
||||
path: str,
|
||||
exc_info: tuple[type[BaseException], BaseException, TracebackType],
|
||||
) -> None:
|
||||
try:
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
func(path)
|
||||
except OSError:
|
||||
raise exc_info[1] from None
|
||||
|
||||
|
||||
def _codex_prompt(messages: Sequence[Message], *, max_tokens: int) -> str:
|
||||
@@ -539,10 +669,16 @@ def _write_compatible_codex_config(path: Path) -> None:
|
||||
config = _read_codex_config()
|
||||
lines: list[str] = []
|
||||
|
||||
tier = str(config.get("service_tier") or "").strip()
|
||||
if tier not in CODEX_SUPPORTED_SERVICE_TIERS:
|
||||
tier = CODEX_SERVICE_TIER_FALLBACK
|
||||
lines.append(_toml_string("service_tier", tier))
|
||||
model = current_ai_model() or normalize_codex_model(str(config.get("model") or ""))
|
||||
if model:
|
||||
lines.append(_toml_string("model", model))
|
||||
|
||||
effort = current_codex_reasoning_effort() or normalize_codex_reasoning_effort(
|
||||
str(config.get("model_reasoning_effort") or "")
|
||||
)
|
||||
if effort:
|
||||
lines.append(_toml_string("model_reasoning_effort", effort))
|
||||
|
||||
lines.append(_toml_string("approval_policy", "never"))
|
||||
lines.append(_toml_string("sandbox_mode", "read-only"))
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import httpx
|
||||
import openai
|
||||
|
||||
from app.services import ai_provider
|
||||
from app.services.ai_provider import (
|
||||
_format_openai_error,
|
||||
_is_temperature_rejected,
|
||||
@@ -140,3 +141,23 @@ def test_is_temperature_rejected_false_for_non_400():
|
||||
)
|
||||
exc = openai.AuthenticationError("unauthorized", response=response, body=None)
|
||||
assert _is_temperature_rejected(exc) is False
|
||||
|
||||
|
||||
def test_codex_process_env_excludes_application_secrets(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("PATH", "test-path")
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example")
|
||||
monkeypatch.setenv("TICKFLOW_API_KEY", "tickflow-secret")
|
||||
monkeypatch.setenv("AI_API_KEY", "ai-secret")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "openai-secret")
|
||||
monkeypatch.setenv("AUTH_PASSWORD", "password-secret")
|
||||
|
||||
env = ai_provider._codex_process_env(tmp_path / "codex-home")
|
||||
|
||||
assert env["PATH"] == "test-path"
|
||||
assert env["HTTPS_PROXY"] == "http://proxy.example"
|
||||
assert env["NO_COLOR"] == "1"
|
||||
assert env["CODEX_HOME"] == str(tmp_path / "codex-home")
|
||||
assert "TICKFLOW_API_KEY" not in env
|
||||
assert "AI_API_KEY" not in env
|
||||
assert "OPENAI_API_KEY" not in env
|
||||
assert "AUTH_PASSWORD" not in env
|
||||
|
||||
@@ -703,6 +703,7 @@ export interface SettingsState {
|
||||
ai_configured?: boolean
|
||||
ai_model: string
|
||||
ai_codex_command?: string
|
||||
ai_codex_reasoning_effort?: string
|
||||
ai_user_agent: string
|
||||
}
|
||||
|
||||
@@ -896,8 +897,8 @@ export const api = {
|
||||
),
|
||||
|
||||
/** 保存 AI 配置 */
|
||||
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; codex_command?: string; user_agent?: string }) =>
|
||||
request<{ ok: boolean; ai_provider?: string; ai_model?: string; ai_codex_command?: string; ai_configured?: boolean }>('/api/settings/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', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(ai),
|
||||
}),
|
||||
|
||||
@@ -15,21 +15,39 @@ const INPUT_CLS =
|
||||
|
||||
const CODEX_PROVIDER = 'codex_cli'
|
||||
const OPENAI_PROVIDER = 'openai_compat'
|
||||
const CUSTOM_CODEX_MODEL = '__custom__'
|
||||
const CODEX_COMMAND = 'codex'
|
||||
const DEFAULT_CODEX_MODEL = 'gpt-5.6-sol'
|
||||
const DEFAULT_CODEX_REASONING_EFFORT = 'xhigh'
|
||||
const SAVED_CODEX_OPTION_VALUE = '__saved_codex_config__'
|
||||
const CODEX_REASONING_LABELS: Record<string, string> = {
|
||||
high: '高',
|
||||
xhigh: '极高',
|
||||
}
|
||||
|
||||
const CODEX_MODEL_OPTIONS = [
|
||||
{ label: 'Codex 默认(推荐)', value: '', hint: '使用当前 Codex CLI 支持的默认模型' },
|
||||
{ label: 'gpt-5.5', value: 'gpt-5.5', hint: '高能力模型' },
|
||||
{ label: 'gpt-5', value: 'gpt-5', hint: '通用模型' },
|
||||
type CodexModelOption = { label: string; value: string; model: string; effort: string; hint: string }
|
||||
|
||||
const CODEX_MODEL_OPTIONS: CodexModelOption[] = [
|
||||
{ label: 'GPT-5.6 Sol · 极高(推荐)', value: 'gpt-5.6-sol:xhigh', model: 'gpt-5.6-sol', effort: 'xhigh', hint: '旗舰档,适合复杂金融分析与专业任务' },
|
||||
{ label: 'GPT-5.6 Terra · 极高', value: 'gpt-5.6-terra:xhigh', model: 'gpt-5.6-terra', effort: 'xhigh', hint: '平衡智能、速度与使用成本' },
|
||||
{ label: 'GPT-5.6 Luna · 极高', value: 'gpt-5.6-luna:xhigh', model: 'gpt-5.6-luna', effort: 'xhigh', hint: '适合成本敏感与高频分析任务' },
|
||||
{ label: 'gpt-5.5 · 高', value: 'gpt-5.5:high', model: 'gpt-5.5', effort: 'high', hint: '使用 gpt-5.5 + high 推理档' },
|
||||
{ label: 'gpt-5.5 · 极高', value: 'gpt-5.5:xhigh', model: 'gpt-5.5', effort: 'xhigh', hint: '使用 gpt-5.5 + xhigh 推理档' },
|
||||
{ label: '跟随本机 Codex 默认', value: '', model: '', effort: '', hint: '使用本机 Codex CLI 配置的默认模型与推理强度' },
|
||||
]
|
||||
|
||||
const codexModelLabel = (model?: string, effort?: string) => {
|
||||
if (!model && !effort) return '默认模型'
|
||||
const modelLabel = model || '默认模型'
|
||||
const effortLabel = effort ? CODEX_REASONING_LABELS[effort] ?? effort : ''
|
||||
return effortLabel ? `${modelLabel} · ${effortLabel}` : modelLabel
|
||||
}
|
||||
|
||||
const PRESETS: { label: string; provider?: string; url: string; model: string; codexCommand?: string; website: string; websiteLabel: string; description: string; partner?: boolean; promo?: string }[] = [
|
||||
{ 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 兼容接口。' },
|
||||
{ label: 'Kimi', url: 'https://api.moonshot.cn/v1', model: 'kimi-k2.7-code', website: 'https://platform.moonshot.cn/', websiteLabel: 'platform.moonshot.cn', description: '月之暗面 Moonshot 官方 OpenAI 兼容接口,支持超长上下文。' },
|
||||
{ label: 'Codex CLI', provider: CODEX_PROVIDER, url: '', model: '', codexCommand: CODEX_COMMAND, website: 'https://developers.openai.com/codex/noninteractive', websiteLabel: 'codex exec', description: '调用本机 Codex CLI 的 codex exec, 适合已登录 ChatGPT/Codex 的本地环境。' },
|
||||
{ label: 'Codex CLI', provider: CODEX_PROVIDER, url: '', model: DEFAULT_CODEX_MODEL, codexCommand: CODEX_COMMAND, website: 'https://developers.openai.com/codex/noninteractive', websiteLabel: 'codex exec', description: '调用本机 Codex CLI 的 codex exec, 适合已登录 ChatGPT/Codex 的本地环境。' },
|
||||
{ label: '炸鸡中转站', url: 'https://api.zhaji.dev/v1', model: 'gpt-5.5', website: 'https://api.zhaji.dev', websiteLabel: 'api.zhaji.dev', description: 'OpenAI 兼容中转服务,适合直接使用国际模型。', partner: true, promo: '通过链接邀请注册赠送免费额度 · 国际模型最低0.02倍率' },
|
||||
]
|
||||
|
||||
@@ -42,7 +60,7 @@ export function SettingsAIPanel() {
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
const [codexCustomModel, setCodexCustomModel] = useState(false)
|
||||
const [codexReasoningEffort, setCodexReasoningEffort] = useState('')
|
||||
const [codexCommand, setCodexCommand] = useState(CODEX_COMMAND)
|
||||
const [customUa, setCustomUa] = useState(false)
|
||||
const [userAgent, setUserAgent] = useState('')
|
||||
@@ -56,15 +74,42 @@ export function SettingsAIPanel() {
|
||||
const savedCodexProvider = s?.ai_provider === CODEX_PROVIDER
|
||||
const configured = s?.ai_configured ?? (savedCodexProvider ? !!(s?.ai_codex_command ?? CODEX_COMMAND) : s?.has_ai_key)
|
||||
const selectedPreset = PRESETS.find(p => (p.provider ?? OPENAI_PROVIDER) === provider && (isCodexProvider ? p.codexCommand === codexCommand : p.url === baseUrl))
|
||||
const codexModelSelectValue = codexCustomModel ? CUSTOM_CODEX_MODEL : model
|
||||
const savedCodexModel = savedCodexProvider ? (s?.ai_model ?? '') : ''
|
||||
const savedCodexEffort = savedCodexProvider ? (s?.ai_codex_reasoning_effort ?? '') : ''
|
||||
const savedCodexOptionKnown = CODEX_MODEL_OPTIONS.some(option =>
|
||||
option.model === savedCodexModel && option.effort === savedCodexEffort,
|
||||
)
|
||||
const savedCodexOption: CodexModelOption | null =
|
||||
(savedCodexModel || savedCodexEffort) && !savedCodexOptionKnown
|
||||
? {
|
||||
label: `${codexModelLabel(savedCodexModel, savedCodexEffort)}(当前配置)`,
|
||||
value: SAVED_CODEX_OPTION_VALUE,
|
||||
model: savedCodexModel,
|
||||
effort: savedCodexEffort,
|
||||
hint: '保留项目中已保存的模型与推理档;此兼容项不可编辑',
|
||||
}
|
||||
: null
|
||||
const codexModelOptions = savedCodexOption
|
||||
? [savedCodexOption, ...CODEX_MODEL_OPTIONS]
|
||||
: CODEX_MODEL_OPTIONS
|
||||
const selectedCodexModelOption = codexModelOptions.find(option =>
|
||||
option.model === model && option.effort === codexReasoningEffort,
|
||||
) ?? CODEX_MODEL_OPTIONS[0]
|
||||
const codexModelSelectValue = selectedCodexModelOption.value
|
||||
const canSave = isCodexProvider ? true : !!baseUrl.trim() && !!model.trim()
|
||||
|
||||
useEffect(() => {
|
||||
if (!s) return
|
||||
setProvider(s.ai_provider ?? OPENAI_PROVIDER)
|
||||
const savedProvider = s.ai_provider ?? OPENAI_PROVIDER
|
||||
setProvider(savedProvider)
|
||||
setBaseUrl(s.ai_base_url ?? '')
|
||||
setModel(s.ai_model ?? '')
|
||||
setCodexCustomModel(!!s.ai_model && !CODEX_MODEL_OPTIONS.some(o => o.value === s.ai_model))
|
||||
if (savedProvider === CODEX_PROVIDER) {
|
||||
setModel(s.ai_model ?? '')
|
||||
setCodexReasoningEffort(s.ai_codex_reasoning_effort ?? '')
|
||||
} else {
|
||||
setModel(s.ai_model ?? '')
|
||||
setCodexReasoningEffort(s.ai_codex_reasoning_effort ?? '')
|
||||
}
|
||||
setCodexCommand(s.ai_codex_command ?? CODEX_COMMAND)
|
||||
const ua = s.ai_user_agent ?? ''
|
||||
setCustomUa(!!ua)
|
||||
@@ -77,6 +122,7 @@ export function SettingsAIPanel() {
|
||||
api_key: apiKey || undefined,
|
||||
model,
|
||||
codex_command: isCodexProvider ? CODEX_COMMAND : codexCommand,
|
||||
codex_reasoning_effort: isCodexProvider ? codexReasoningEffort : '',
|
||||
user_agent: customUa ? userAgent : '',
|
||||
})
|
||||
|
||||
@@ -91,6 +137,7 @@ export function SettingsAIPanel() {
|
||||
ai_base_url: baseUrl,
|
||||
ai_model: result.ai_model ?? model,
|
||||
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)),
|
||||
...(apiKey ? {
|
||||
has_ai_key: true,
|
||||
@@ -110,7 +157,7 @@ export function SettingsAIPanel() {
|
||||
setBaseUrl('')
|
||||
setApiKey('')
|
||||
setModel('')
|
||||
setCodexCustomModel(false)
|
||||
setCodexReasoningEffort('')
|
||||
setCodexCommand(CODEX_COMMAND)
|
||||
setTestResult(null)
|
||||
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
|
||||
@@ -119,6 +166,7 @@ export function SettingsAIPanel() {
|
||||
ai_base_url: '',
|
||||
ai_model: '',
|
||||
ai_codex_command: CODEX_COMMAND,
|
||||
ai_codex_reasoning_effort: '',
|
||||
has_ai_key: false,
|
||||
ai_configured: false,
|
||||
ai_api_key_masked: '',
|
||||
@@ -142,7 +190,7 @@ export function SettingsAIPanel() {
|
||||
setProvider(p.provider ?? OPENAI_PROVIDER)
|
||||
setBaseUrl(p.url)
|
||||
setModel(p.model)
|
||||
setCodexCustomModel(false)
|
||||
setCodexReasoningEffort(p.provider === CODEX_PROVIDER ? DEFAULT_CODEX_REASONING_EFFORT : '')
|
||||
if (p.codexCommand) setCodexCommand(CODEX_COMMAND)
|
||||
}
|
||||
|
||||
@@ -180,7 +228,7 @@ export function SettingsAIPanel() {
|
||||
<div className="text-xs text-muted mt-0.5 truncate">
|
||||
{configured
|
||||
? (savedCodexProvider
|
||||
? `${s?.ai_codex_command ?? CODEX_COMMAND} · ${s?.ai_model || '默认模型'}`
|
||||
? `${s?.ai_codex_command ?? CODEX_COMMAND} · ${codexModelLabel(s?.ai_model, s?.ai_codex_reasoning_effort)}`
|
||||
: `${s?.ai_model} · ${s?.ai_api_key_masked}`)
|
||||
: (isCodexProvider ? '使用本机 codex exec, 此处无需填写 API Key。' : '配置 API Key 后即可使用 AI 功能。')}
|
||||
</div>
|
||||
@@ -241,39 +289,23 @@ export function SettingsAIPanel() {
|
||||
</div>
|
||||
</Field>
|
||||
<Field
|
||||
label="模型(可选)"
|
||||
hint={codexCustomModel
|
||||
? '留空则使用 Codex 默认模型'
|
||||
: CODEX_MODEL_OPTIONS.find(o => o.value === model)?.hint}
|
||||
label="模型 / 推理档"
|
||||
hint={selectedCodexModelOption.hint}
|
||||
>
|
||||
<select
|
||||
value={codexModelSelectValue}
|
||||
onChange={e => {
|
||||
const value = e.target.value
|
||||
if (value === CUSTOM_CODEX_MODEL) {
|
||||
setCodexCustomModel(true)
|
||||
if (CODEX_MODEL_OPTIONS.some(o => o.value === model)) setModel('')
|
||||
} else {
|
||||
setCodexCustomModel(false)
|
||||
setModel(value)
|
||||
}
|
||||
const option = codexModelOptions.find(item => item.value === value) ?? CODEX_MODEL_OPTIONS[0]
|
||||
setModel(option.model)
|
||||
setCodexReasoningEffort(option.effort)
|
||||
}}
|
||||
className={INPUT_CLS}
|
||||
>
|
||||
{CODEX_MODEL_OPTIONS.map(option => (
|
||||
<option key={option.label} value={option.value}>{option.label}</option>
|
||||
{codexModelOptions.map(option => (
|
||||
<option key={option.value || 'codex-local-default'} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
<option value={CUSTOM_CODEX_MODEL}>自定义模型</option>
|
||||
</select>
|
||||
{codexCustomModel && (
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder="例如 gpt-5.5"
|
||||
className={`${INPUT_CLS} mt-2`}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
@@ -283,7 +315,7 @@ export function SettingsAIPanel() {
|
||||
<input type="text" value={baseUrl} onChange={e => setBaseUrl(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.5" className={INPUT_CLS} />
|
||||
<input type="text" value={model} onChange={e => setModel(e.target.value)} placeholder="gpt-5.6-sol" className={INPUT_CLS} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user