Merge PR #103: Codex CLI 增加 GPT-5.6 支持 + 安全隔离

冲突解决: 保留自定义预设 + GPT-5.6 模型选择 + reasoning_effort 配置
This commit is contained in:
shy3130
2026-07-12 11:07:16 +08:00
8 changed files with 294 additions and 74 deletions
+23 -3
View File
@@ -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}
+5 -1
View File
@@ -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:
+1
View File
@@ -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 = (
+166 -30
View File
@@ -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"))
+21
View File
@@ -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
+3 -2
View File
@@ -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),
}),
+65 -38
View File
@@ -15,22 +15,40 @@ 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; custom?: boolean }[] = [
{ label: '自定义', url: '', model: '', website: '', websiteLabel: '', description: '不自动填充任何配置,完全手动填写 API 地址、模型和密钥。', custom: true },
{ 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倍率' },
]
@@ -43,7 +61,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('')
@@ -59,17 +77,39 @@ export function SettingsAIPanel() {
// 选中的预设: 精确匹配 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 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
// 未配置过 AI (无 api_key): 字段留空, 默认选中"自定义"预设, 不预填充后端默认值
const unconfigured = !s.has_ai_key && !s.ai_configured
setProvider(s.ai_provider ?? OPENAI_PROVIDER)
const savedProvider = s.ai_provider ?? OPENAI_PROVIDER
setProvider(savedProvider)
setBaseUrl(unconfigured ? '' : (s.ai_base_url ?? ''))
setModel(unconfigured ? '' : (s.ai_model ?? ''))
setCodexCustomModel(!unconfigured && !!s.ai_model && !CODEX_MODEL_OPTIONS.some(o => o.value === s.ai_model))
setCodexReasoningEffort(unconfigured ? '' : (s.ai_codex_reasoning_effort ?? ''))
setCodexCommand(s.ai_codex_command ?? CODEX_COMMAND)
const ua = s.ai_user_agent ?? ''
setCustomUa(!!ua)
@@ -82,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 : '',
})
@@ -96,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,
@@ -115,7 +157,7 @@ export function SettingsAIPanel() {
setBaseUrl('')
setApiKey('')
setModel('')
setCodexCustomModel(false)
setCodexReasoningEffort('')
setCodexCommand(CODEX_COMMAND)
setTestResult(null)
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
@@ -124,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: '',
@@ -149,13 +192,13 @@ export function SettingsAIPanel() {
setProvider(OPENAI_PROVIDER)
setBaseUrl('')
setModel('')
setCodexCustomModel(false)
setCodexReasoningEffort('')
return
}
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)
}
@@ -193,7 +236,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>
@@ -256,39 +299,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>
) : (
@@ -298,7 +325,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>
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="512" height="512" fill="none" role="img" aria-label="TickFlow Stock Panel">
<!-- 左方括号 -->
<path d="M10 4 L4 4 L4 28 L10 28" stroke="#8B5CF6" stroke-width="2" stroke-linejoin="miter" stroke-linecap="butt"/>
<!-- 右方括号 -->
<path d="M22 4 L28 4 L28 28 L22 28" stroke="#8B5CF6" stroke-width="2" stroke-linejoin="miter" stroke-linecap="butt"/>
<!-- K 线 wick(上下影线,半透明) -->
<line x1="16" y1="7" x2="16" y2="25" stroke="#8B5CF6" stroke-width="1.5" stroke-linecap="round" stroke-opacity="0.6"/>
<!-- K 线 body — 偏上,上影短/下影长, bullish 站稳感 -->
<rect x="13" y="9" width="6" height="10" fill="#8B5CF6" rx="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 744 B