Add Codex CLI AI provider

This commit is contained in:
Lzwaang
2026-06-29 22:32:58 +08:00
parent 5d4d643bd8
commit ec6380376d
13 changed files with 695 additions and 303 deletions
+29 -5
View File
@@ -38,8 +38,10 @@ 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
key = secrets_store.get_tickflow_key()
ai_provider = secrets_store.get_ai_config("ai_provider", settings.ai_provider)
return {
"mode": tf_client.current_mode(),
"tickflow_api_key_masked": secrets_store.mask(key),
@@ -52,11 +54,13 @@ def get_settings() -> dict:
# 首次使用引导
"onboarding_completed": preferences.get_onboarding_completed(),
# AI 配置
"ai_provider": secrets_store.get_ai_config("ai_provider", settings.ai_provider),
"ai_provider": ai_provider,
"ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url),
"ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()),
"has_ai_key": bool(secrets_store.get_ai_key()),
"ai_model": secrets_store.get_ai_config("ai_model", settings.ai_model),
"ai_configured": ai_configured(ai_provider),
"ai_model": current_ai_model(),
"ai_codex_command": current_codex_command(),
"ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent),
}
@@ -211,6 +215,7 @@ class AiSettingsIn(BaseModel):
base_url: str = ""
api_key: str | None = None
model: str = ""
codex_command: str = ""
user_agent: str = ""
@@ -218,6 +223,7 @@ 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
updates: dict = {}
if req.provider:
@@ -233,9 +239,19 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
else:
secrets_store.clear("ai_api_key")
settings.ai_api_key = ""
if req.model:
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":
try:
codex_command = normalize_codex_command(req.codex_command)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
updates["ai_codex_command"] = codex_command
settings.ai_codex_command = codex_command
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化
updates["ai_user_agent"] = req.user_agent
settings.ai_user_agent = req.user_agent
@@ -243,7 +259,14 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
if updates:
secrets_store.save(updates)
return {"ok": True}
provider = current_ai_provider()
return {
"ok": True,
"ai_provider": provider,
"ai_model": current_ai_model(),
"ai_codex_command": current_codex_command(),
"ai_configured": ai_configured(provider),
}
@router.delete("/ai")
@@ -254,12 +277,13 @@ def clear_ai_settings() -> dict:
"""
from app.config import settings
secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model")
secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command")
# 同步重置运行时内存(provider 回默认值,其余置空)
settings.ai_provider = "openai_compat"
settings.ai_base_url = ""
settings.ai_api_key = ""
settings.ai_model = ""
settings.ai_codex_command = "codex"
return {"ok": True}
+18 -23
View File
@@ -286,12 +286,19 @@ class BuildRequest(BaseModel):
@router.get("/ai/status")
def ai_status(request: Request):
"""检查 AI 配置状态"""
from app.config import settings
"""Check whether the selected AI provider is configured."""
from app import secrets_store
from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider
has_key = bool(secrets_store.get_ai_key())
has_model = bool(settings.ai_model)
return {"configured": has_key and has_model, "has_key": has_key, "has_model": has_model}
model = current_ai_model()
provider = current_ai_provider()
return {
"configured": ai_configured(provider) and bool(model or provider == "codex_cli"),
"has_key": has_key,
"has_model": bool(model),
"provider": provider,
}
@router.get("/{strategy_id}/source")
@@ -315,29 +322,17 @@ def get_strategy_source(strategy_id: str, request: Request):
@router.post("/ai/test")
async def ai_test(request: Request):
"""测试 AI 连通性 — 发送简单请求验证 Key 和模型"""
from app.config import settings
from app import secrets_store
from openai import AsyncOpenAI
ai_key = secrets_store.get_ai_key()
if not ai_key:
return {"ok": False, "error": "未配置 API Key"}
"""Send a small prompt through the selected AI provider."""
from app.services.ai_provider import current_ai_model, current_ai_provider, generate_ai_text
try:
# User-Agent: 默认浏览器标识,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
client = AsyncOpenAI(
api_key=ai_key,
base_url=settings.ai_base_url,
default_headers={"User-Agent": settings.ai_user_agent or "Mozilla/5.0"},
)
resp = await client.chat.completions.create(
model=settings.ai_model,
messages=[{"role": "user", "content": "回复 OK"}],
max_tokens=5,
text = await generate_ai_text(
[{"role": "user", "content": "Reply exactly: OK"}],
temperature=0,
max_tokens=8,
timeout=15,
)
return {"ok": True, "model": resp.model, "usage": {"prompt": resp.usage.prompt_tokens, "completion": resp.usage.completion_tokens} if resp.usage else None}
return {"ok": True, "model": current_ai_model() or current_ai_provider(), "response": text[:80]}
except Exception as e:
return {"ok": False, "error": str(e)}
+1
View File
@@ -80,6 +80,7 @@ class Settings(BaseSettings):
ai_base_url: str = "https://api.alysc.top"
ai_api_key: str = ""
ai_model: str = "gpt-5.5"
ai_codex_command: str = "codex"
# 默认浏览器风格 UA,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
# 用户可在 AI 设置页按需修改。
ai_user_agent: str = (
+419
View File
@@ -0,0 +1,419 @@
"""AI provider adapter for OpenAI-compatible APIs and local Codex CLI."""
from __future__ import annotations
import asyncio
import os
import re
import shutil
import sys
import tempfile
import tomllib
from collections.abc import AsyncIterator, Sequence
from pathlib import Path
from app import secrets_store
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"}
Message = dict[str, str]
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
def current_ai_provider() -> str:
return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER
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)
def current_codex_command() -> str:
return normalize_codex_command(
secrets_store.get_ai_config("ai_codex_command", settings.ai_codex_command),
strict=False,
)
def is_codex_cli_provider(provider: str | None = None) -> bool:
return (provider or current_ai_provider()) == CODEX_CLI_PROVIDER
def normalize_codex_model(model: str) -> str:
value = model.strip()
aliases = {
"gpt5": "gpt-5",
"gpt5.5": "gpt-5.5",
}
return aliases.get(value.lower(), value)
def normalize_codex_command(command: str | None, *, strict: bool = True) -> str:
value = (command or "").strip()
if not value or value.lower() == CODEX_DEFAULT_COMMAND:
return CODEX_DEFAULT_COMMAND
if strict:
raise ValueError("Codex CLI 仅支持使用默认 codex 命令自动解析, 不支持自定义可执行路径")
return CODEX_DEFAULT_COMMAND
def codex_cli_available() -> bool:
try:
_codex_base_command()
return True
except RuntimeError:
return False
def ai_configured(provider: str | None = None) -> bool:
provider = provider or current_ai_provider()
if is_codex_cli_provider(provider):
return codex_cli_available()
return bool(secrets_store.get_ai_key())
async def generate_ai_text(
messages: Sequence[Message],
*,
temperature: float = 0.3,
max_tokens: int = 3000,
timeout: float = 180.0,
) -> str:
"""Return a complete AI response from the currently configured provider."""
if is_codex_cli_provider():
return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return await _run_openai_once(
messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
)
async def stream_ai_text(
messages: Sequence[Message],
*,
temperature: float = 0.5,
max_tokens: int = 4000,
timeout: float = 180.0,
) -> AsyncIterator[str]:
"""Yield text deltas from the configured provider.
Codex CLI only exposes the final assistant message for this use case, so it
yields one complete chunk after the command exits.
"""
if is_codex_cli_provider():
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return
async for chunk in _stream_openai(
messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
):
yield chunk
async def _run_openai_once(
messages: Sequence[Message],
*,
temperature: float,
max_tokens: int,
timeout: float,
) -> str:
ai_key = secrets_store.get_ai_key()
if not ai_key:
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
resp = await client.chat.completions.create(
model=current_ai_model(),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
)
if not resp.choices:
return ""
return (resp.choices[0].message.content or "").strip()
async def _stream_openai(
messages: Sequence[Message],
*,
temperature: float,
max_tokens: int,
timeout: float,
) -> AsyncIterator[str]:
ai_key = secrets_store.get_ai_key()
if not ai_key:
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
stream = await client.chat.completions.create(
model=current_ai_model(),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield delta.content
def _openai_client(api_key: str, timeout: float):
from openai import AsyncOpenAI
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
return AsyncOpenAI(
api_key=api_key,
base_url=secrets_store.get_ai_config("ai_base_url", settings.ai_base_url),
timeout=timeout,
max_retries=2,
default_headers={"User-Agent": user_agent},
)
async def _run_codex_cli(
messages: Sequence[Message],
*,
max_tokens: int,
timeout: float,
) -> str:
prompt = _codex_prompt(messages, max_tokens=max_tokens)
with tempfile.TemporaryDirectory(prefix="tickflow-codex-home-") as codex_home:
codex_home_path = Path(codex_home)
output_path = codex_home_path / "last-message.txt"
_prepare_codex_home(codex_home_path)
args = [
*_codex_base_command(),
"exec",
"--ephemeral",
"--sandbox",
"read-only",
"--skip-git-repo-check",
"--color",
"never",
"--output-last-message",
str(output_path),
]
model = current_ai_model().strip()
if model:
args.extend(["--model", model])
args.extend(["--cd", str(_project_root()), "-"])
env = os.environ.copy()
env.setdefault("NO_COLOR", "1")
env["CODEX_HOME"] = str(codex_home_path)
proc = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
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}"
raise RuntimeError(f"Codex CLI 调用失败: {detail[-1200:]}")
result = final_message or out
if not result:
raise RuntimeError("Codex CLI 未返回内容")
return result
def _codex_prompt(messages: Sequence[Message], *, max_tokens: int) -> str:
parts = [
"You are TickFlow Stock Panel's local AI provider.",
"This is a text-generation task. Do not inspect or modify local files.",
"Return only the final requested content; do not include execution logs.",
]
if max_tokens > 0:
parts.append(f"Keep the final answer within about {max_tokens} output tokens.")
for message in messages:
role = message.get("role", "user")
content = message.get("content", "")
parts.append(f"\n<{role}>\n{content}\n</{role}>")
return "\n".join(parts)
def _codex_base_command() -> list[str]:
command = current_codex_command()
resolved = _resolve_command(command)
if not resolved:
raise RuntimeError(f"未找到 Codex CLI 命令: {command}")
if sys.platform == "win32" and resolved.lower().endswith(".ps1"):
return ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolved]
return [resolved]
def _resolve_command(command: str) -> str | None:
if command.lower() != CODEX_DEFAULT_COMMAND:
return None
if sys.platform == "win32":
desktop_codex = _resolve_windows_desktop_codex()
if desktop_codex:
return desktop_codex
resolved = shutil.which(command)
if sys.platform == "win32" and resolved:
resolved_path = Path(resolved)
if not resolved_path.suffix:
cmd_path = resolved_path.with_suffix(".cmd")
if cmd_path.exists():
return str(cmd_path)
if not resolved and sys.platform == "win32" and not command.lower().endswith(".cmd"):
resolved = shutil.which(f"{command}.cmd")
if not resolved and sys.platform == "win32":
resolved = _resolve_windows_codex_command(command)
return resolved
def _resolve_windows_codex_command(command: str) -> str | None:
"""Find npm-installed Codex when the backend process has a minimal PATH."""
raw = Path(command)
if raw.parent != Path("."):
return None
names = [command]
if not raw.suffix:
names = [f"{command}.cmd", f"{command}.exe", f"{command}.bat", f"{command}.ps1", command]
dirs: list[Path] = []
appdata = os.environ.get("APPDATA")
if appdata:
dirs.append(Path(appdata) / "npm")
dirs.append(Path.home() / "AppData" / "Roaming" / "npm")
for env_name in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"):
value = os.environ.get(env_name)
if value:
dirs.append(Path(value) / "nodejs")
for directory in dirs:
for name in names:
candidate = directory / name
if candidate.exists():
return str(candidate)
return None
def _resolve_windows_desktop_codex() -> str | None:
"""Prefer the Codex Desktop bundled CLI over an older npm shim."""
local_appdata = os.environ.get("LOCALAPPDATA")
if not local_appdata:
return None
root = Path(local_appdata) / "OpenAI" / "Codex" / "bin"
if not root.exists():
return None
candidates = list(root.glob("*/codex.exe"))
direct = root / "codex.exe"
if direct.exists():
candidates.append(direct)
if not candidates:
return None
newest = max(candidates, key=lambda p: p.stat().st_mtime)
return str(newest)
def _project_root() -> Path:
return Path(__file__).resolve().parents[3]
def _prepare_codex_home(target: Path) -> None:
"""Create an isolated CODEX_HOME that reuses auth but not fragile config."""
source = _codex_home()
for filename in ("auth.json", ".env"):
src = source / filename
if src.exists():
shutil.copy2(src, target / filename)
_write_compatible_codex_config(target / "config.toml")
def _codex_home() -> Path:
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
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))
lines.append(_toml_string("approval_policy", "never"))
lines.append(_toml_string("sandbox_mode", "read-only"))
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _read_codex_config() -> dict:
path = _codex_home() / "config.toml"
if not path.exists():
return {}
try:
with path.open("rb") as f:
return tomllib.load(f)
except tomllib.TOMLDecodeError:
return _read_codex_config_lenient(path)
except OSError:
return {}
def _read_codex_config_lenient(path: Path) -> dict:
config: dict[str, str] = {}
pattern = re.compile(r'^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"\s*$')
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
match = pattern.match(line)
if match:
config[match.group(1)] = match.group(2)
except OSError:
pass
return config
def _toml_string(key: str, value: str) -> str:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'{key} = "{escaped}"'
def _clean_process_text(raw: bytes) -> str:
text = raw.decode("utf-8", errors="replace")
return _ANSI_RE.sub("", text).strip()
def _read_output_file(path: Path) -> str:
if path.exists():
return _ANSI_RE.sub("", path.read_text(encoding="utf-8", errors="replace")).strip()
return ""
+5 -27
View File
@@ -166,40 +166,18 @@ async def analyze_financials_stream(
# 3. 调用 LLM 流式
try:
from openai import AsyncOpenAI
from app import secrets_store
from app.config import settings
ai_key = secrets_store.get_ai_key()
if not ai_key:
yield json.dumps({"type": "error", "message": "AI API Key 未配置,请在「设置 → AI」中配置"}, ensure_ascii=False)
return
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
client = AsyncOpenAI(
api_key=ai_key,
base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"),
timeout=180.0,
max_retries=2,
default_headers={"User-Agent": user_agent},
)
from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(fins, symbol, focus)
stream = await client.chat.completions.create(
model=secrets_store.get_ai_config("ai_model", "gpt-5.5"),
messages=[
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.4,
max_tokens=4000,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield json.dumps({"type": "delta", "content": delta.content}, ensure_ascii=False)
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
logger.exception("AI financial analysis failed for %s: %s", symbol, e)
+5 -31
View File
@@ -291,44 +291,18 @@ async def recap_market_stream(
# 3+4. 构建 prompt + 流式调用 LLM(整体 try-except,任何异常 yield error,避免前端卡死)
try:
from openai import AsyncOpenAI
from app import secrets_store
from app.config import settings
ai_key = secrets_store.get_ai_key()
if not ai_key:
yield json.dumps({
"type": "error",
"message": "AI API Key 未配置,请在「设置 → AI」中配置",
}, ensure_ascii=False)
return
from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(overview, news or [], focus)
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
client = AsyncOpenAI(
api_key=ai_key,
base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"),
timeout=180.0,
max_retries=2,
default_headers={"User-Agent": user_agent},
)
stream = await client.chat.completions.create(
model=secrets_store.get_ai_config("ai_model", "gpt-5.5"),
messages=[
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.5,
max_tokens=4500,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield json.dumps({"type": "delta", "content": delta.content}, ensure_ascii=False)
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
logger.exception("AI market recap failed for %s: %s", as_of_str, e)
+5 -33
View File
@@ -287,47 +287,19 @@ async def analyze_stock_stream(
# 5+6. 构建提示词 + 流式调用 LLM(整体 try-except,任何异常都 yield error,避免前端卡死)
try:
from openai import AsyncOpenAI
from app import secrets_store
from app.config import settings
from app.services.ai_provider import stream_ai_text
# 5. 构建提示词
kline_tail = _clean_rows(df, _KLINE_KEEP_COLS)
user_prompt = _build_user_prompt(kline_tail, fins, levels, close, symbol, focus)
# 6. 流式调用 LLM
ai_key = secrets_store.get_ai_key()
if not ai_key:
yield json.dumps({
"type": "error",
"message": "AI API Key 未配置,请在「设置 → AI」中配置",
}, ensure_ascii=False)
return
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
client = AsyncOpenAI(
api_key=ai_key,
base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"),
timeout=180.0,
max_retries=2,
default_headers={"User-Agent": user_agent},
)
stream = await client.chat.completions.create(
model=secrets_store.get_ai_config("ai_model", "gpt-5.5"),
messages=[
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.5,
max_tokens=4500,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield json.dumps({"type": "delta", "content": delta.content}, ensure_ascii=False)
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
logger.exception("AI stock analysis failed for %s: %s", symbol, e)
+5 -31
View File
@@ -75,44 +75,18 @@ class AIStrategyGenerator:
return {"code": code, "meta": meta, "valid": True, "error": None}
async def _call_llm(self, user_prompt: str, guide: str) -> str:
"""调用 OpenAI 兼容 API(流式,避免 CDN 长连接超时)"""
from openai import AsyncOpenAI
from app import secrets_store
"""Call the configured AI provider and return generated strategy code."""
from app.services.ai_provider import generate_ai_text
ai_key = secrets_store.get_ai_key()
if not ai_key:
raise RuntimeError("AI API Key 未配置,请在设置页面配置")
# User-Agent: 默认浏览器标识,绕过 Cloudflare 等 CDN/WAF 的 Bot 拦截(Issue #8)。
# 用户可在 AI 设置页自定义。
from app.config import settings
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
client = AsyncOpenAI(
api_key=ai_key,
base_url=secrets_store.get_ai_config("ai_base_url", "https://api.alysc.top"),
timeout=180.0,
max_retries=2,
default_headers={"User-Agent": user_agent},
)
# 使用流式请求:CDN 收到首个 token 后会持续转发,不会因等待超时
stream = await client.chat.completions.create(
model=secrets_store.get_ai_config("ai_model", "gpt-5.5"),
messages=[
content = await generate_ai_text(
[
{"role": "system", "content": _SYSTEM_PREFIX + guide},
{"role": "user", "content": user_prompt},
],
temperature=0.3,
max_tokens=3000,
stream=True,
)
chunks: list[str] = []
async for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
chunks.append(delta.content)
content = "".join(chunks).strip()
# 提取代码块
# Extract fenced code if the model wrapped the answer in Markdown.
if "```python" in content:
content = content.split("```python", 1)[1].split("```", 1)[0].strip()
elif "```" in content:
+1 -1
View File
@@ -409,7 +409,7 @@ export function Layout() {
hasKey={settingsState?.mode !== 'none'}
/>
<AIConfigBadge
configured={settingsState?.has_ai_key}
configured={settingsState?.ai_configured ?? settingsState?.has_ai_key}
model={settingsState?.ai_model}
/>
</div>
+7 -3
View File
@@ -63,6 +63,12 @@ function subscribe(fn: () => void) {
return () => { listeners.delete(fn) }
}
function normalizeAiError(msg: string) {
return msg.includes('API Key') || msg.includes('api_key')
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
: msg
}
// 快照必须返回稳定引用:只有内容真正变化时才返回新数组/对象。
// useSyncExternalStore 用 Object.is 比较,getSnapshot 必须缓存。
let _activeSnap: ActiveTask[] = []
@@ -258,9 +264,7 @@ async function runStream(id: string, symbol: string, focus: string) {
const msg = String(e?.message ?? '分析失败')
patchTask(id, {
phase: 'error',
error: msg.includes('API Key') || msg.includes('api_key')
? 'AI API Key 未配置或无效,请在「设置 → AI」中配置'
: msg,
error: normalizeAiError(msg),
})
}
}
+6 -4
View File
@@ -638,7 +638,9 @@ export interface SettingsState {
ai_base_url: string
ai_api_key_masked: string
has_ai_key: boolean
ai_configured?: boolean
ai_model: string
ai_codex_command?: string
ai_user_agent: string
}
@@ -749,8 +751,8 @@ export const api = {
),
/** 保存 AI 配置 */
saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; user_agent?: string }) =>
request<{ ok: boolean }>('/api/settings/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', {
method: 'POST',
body: JSON.stringify(ai),
}),
@@ -1635,11 +1637,11 @@ export const api = {
/** 检查 AI 配置状态 */
strategyAiStatus: () =>
request<{ configured: boolean; has_key: boolean; has_model: boolean }>('/api/strategies/ai/status'),
request<{ configured: boolean; has_key: boolean; has_model: boolean; provider?: string }>('/api/strategies/ai/status'),
/** 测试 AI 连通性 */
strategyAiTest: () =>
request<{ ok: boolean; error?: string; model?: string; usage?: { prompt: number; completion: number } }>(
request<{ ok: boolean; error?: string; model?: string; response?: string; usage?: { prompt: number; completion: number } }>(
'/api/strategies/ai/test',
{ method: 'POST' },
),
+7 -3
View File
@@ -58,6 +58,12 @@ let dialogMinimized = false
function emit() { listeners.forEach(fn => fn()) }
function subscribe(fn: () => void) { listeners.add(fn); return () => { listeners.delete(fn) } }
function normalizeAiError(msg: string) {
return msg.includes('API Key') || msg.includes('api_key')
? 'AI 未配置或无效,请在「设置 → AI」中检查当前 AI 提供方'
: msg
}
let _activeSnap: ActiveTask[] = []
let _historySnap: HistoryReport[] = []
interface DialogSnap { taskId: string | null; minimized: boolean }
@@ -229,9 +235,7 @@ async function runStream(id: string, symbol: string, _name: string, focus: strin
const msg = String(e?.message ?? '分析失败')
patchTask(id, {
phase: 'error',
error: msg.includes('API Key') || msg.includes('api_key')
? 'AI API Key 未配置或无效,请在「设置 → AI」中配置'
: msg,
error: normalizeAiError(msg),
})
}
}
+187 -142
View File
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import {
Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield,
Shuffle, Plug, Zap, Settings2, ExternalLink, Trash2,
Terminal,
} from 'lucide-react'
import { useSettings } from '@/lib/useSharedQueries'
import { api, type SettingsState } from '@/lib/api'
@@ -12,12 +13,24 @@ import { QK } from '@/lib/queryKeys'
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 PRESETS: { label: string; url: string; model: string; website: string; websiteLabel: string; description: string; partner?: boolean; promo?: string }[] = [
const CODEX_PROVIDER = 'codex_cli'
const OPENAI_PROVIDER = 'openai_compat'
const CUSTOM_CODEX_MODEL = '__custom__'
const CODEX_COMMAND = 'codex'
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: '通用模型' },
]
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.6', website: 'https://platform.moonshot.cn/', websiteLabel: 'platform.moonshot.cn', description: '月之暗面 Moonshot 官方 OpenAI 兼容接口,支持超长上下文。' },
{ label: '炸鸡中转站', url: 'https://code.alysc.top/v1', model: 'gpt-5.5', website: 'https://code.alysc.top/sign-up?aff=1afk', websiteLabel: 'code.alysc.top', description: 'OpenAI 兼容中转服务,适合直接使用国际模型。', partner: true, promo: '通过链接邀请注册赠送免费额度 · 国际模型最低0.01倍率' },
{ 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 的本地环境。' },
]
export function SettingsAIPanel() {
@@ -25,63 +38,63 @@ export function SettingsAIPanel() {
const settings = useSettings()
const s = settings.data
const [provider, setProvider] = useState('openai_compat')
const [provider, setProvider] = useState(OPENAI_PROVIDER)
const [baseUrl, setBaseUrl] = useState('')
const [apiKey, setApiKey] = useState('')
const [model, setModel] = useState('')
// 自定义 User-Agent 开关:关闭 → 后端用内置默认浏览器 UA(开箱绕过 CDN 拦截);
// 开启 → 用下方文本框的 UA,留空时随机生成。
const [codexCustomModel, setCodexCustomModel] = useState(false)
const [codexCommand, setCodexCommand] = useState(CODEX_COMMAND)
const [customUa, setCustomUa] = useState(false)
const [userAgent, setUserAgent] = useState('')
const [showKey, setShowKey] = useState(false)
const [saved, setSaved] = useState(false)
const [confirmClear, setConfirmClear] = useState(false)
// 测试
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null)
// 随机生成一个近期桌面端 Chrome UA(Win/Mac/Linux 随机)
const genRandomUa = () => {
const major = 128 + Math.floor(Math.random() * 8) // 128~135
const platforms = [
`Windows NT 10.0; Win64; x64`,
`Macintosh; Intel Mac OS X 10_15_7`,
`X11; Linux x86_64`,
]
const pf = platforms[Math.floor(Math.random() * platforms.length)]
setUserAgent(`Mozilla/5.0 (${pf}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36`)
}
const isCodexProvider = provider === CODEX_PROVIDER
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 canSave = isCodexProvider ? true : !!baseUrl.trim() && !!model.trim()
useEffect(() => {
if (!s) return
setProvider(s.ai_provider ?? 'openai_compat')
setProvider(s.ai_provider ?? OPENAI_PROVIDER)
setBaseUrl(s.ai_base_url ?? '')
setModel(s.ai_model ?? '')
// 有已保存的自定义 UA → 开关默认开启;否则关闭(用后端内置默认)
setCodexCustomModel(!!s.ai_model && !CODEX_MODEL_OPTIONS.some(o => o.value === s.ai_model))
setCodexCommand(s.ai_codex_command ?? CODEX_COMMAND)
const ua = s.ai_user_agent ?? ''
setCustomUa(!!ua)
setUserAgent(ua)
}, [s])
const payload = () => ({
provider,
base_url: baseUrl,
api_key: apiKey || undefined,
model,
codex_command: isCodexProvider ? CODEX_COMMAND : codexCommand,
user_agent: customUa ? userAgent : '',
})
const save = useMutation({
mutationFn: () => api.saveAiSettings({
provider, base_url: baseUrl, api_key: apiKey || undefined, model,
// 关闭开关 → 提交空串,后端回退内置默认 UA
user_agent: customUa ? userAgent : '',
}),
onSuccess: () => {
setSaved(true); setApiKey('')
// 乐观更新:保存后立刻刷新连接状态 & 左侧菜单,不等异步 refetch
mutationFn: () => api.saveAiSettings(payload()),
onSuccess: (result) => {
setSaved(true)
setApiKey('')
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
...prev,
ai_provider: provider,
ai_provider: result.ai_provider ?? provider,
ai_base_url: baseUrl,
ai_model: model,
// 只在本次确实提交了新 Key 时才更新连接态(留空不修改)
ai_model: result.ai_model ?? model,
ai_codex_command: result.ai_codex_command ?? (isCodexProvider ? CODEX_COMMAND : codexCommand),
ai_configured: result.ai_configured ?? (isCodexProvider ? true : (apiKey ? true : prev.ai_configured)),
...(apiKey ? {
has_ai_key: true,
ai_api_key_masked: `${apiKey.slice(0, 4)}••••••${apiKey.slice(-4)}`,
ai_api_key_masked: `${apiKey.slice(0, 4)}......${apiKey.slice(-4)}`,
} : {}),
} : prev)
qc.invalidateQueries({ queryKey: QK.settings })
@@ -93,45 +106,62 @@ export function SettingsAIPanel() {
mutationFn: () => api.clearAiSettings(),
onSuccess: () => {
setConfirmClear(false)
// 同步清空本地表单(保留自定义 UA)
setProvider('openai_compat'); setBaseUrl(''); setApiKey(''); setModel('')
setProvider(OPENAI_PROVIDER)
setBaseUrl('')
setApiKey('')
setModel('')
setCodexCustomModel(false)
setCodexCommand(CODEX_COMMAND)
setTestResult(null)
// 乐观更新:立刻把连接状态/左侧菜单置为未配置
qc.setQueryData<SettingsState>(QK.settings, prev => prev ? {
...prev,
ai_provider: 'openai_compat',
ai_provider: OPENAI_PROVIDER,
ai_base_url: '',
ai_model: '',
ai_codex_command: CODEX_COMMAND,
has_ai_key: false,
ai_configured: false,
ai_api_key_masked: '',
} : prev)
qc.invalidateQueries({ queryKey: QK.settings })
},
})
const handleTest = async () => {
setTesting(true); setTestResult(null)
try {
// 先保存当前配置(不保存 Key 仅用于测试时临时存)
if (apiKey) await api.saveAiSettings({ provider, base_url: baseUrl, api_key: apiKey, model, user_agent: customUa ? userAgent : '' })
const r = await api.strategyAiTest()
setTestResult({ ok: r.ok, msg: r.ok ? `连通成功 · 模型: ${r.model}` : (r.error ?? '未知错误') })
} catch (e: any) {
setTestResult({ ok: false, msg: String(e?.message ?? '测试失败') })
} finally { setTesting(false) }
const genRandomUa = () => {
const major = 128 + Math.floor(Math.random() * 8)
const platforms = [
'Windows NT 10.0; Win64; x64',
'Macintosh; Intel Mac OS X 10_15_7',
'X11; Linux x86_64',
]
const pf = platforms[Math.floor(Math.random() * platforms.length)]
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]) => {
setBaseUrl(p.url); setModel(p.model)
setProvider(p.provider ?? OPENAI_PROVIDER)
setBaseUrl(p.url)
setModel(p.model)
setCodexCustomModel(false)
if (p.codexCommand) setCodexCommand(CODEX_COMMAND)
}
const configured = s?.has_ai_key
const selectedPreset = PRESETS.find(p => p.url === baseUrl)
const canSave = !!baseUrl && !!model
const handleTest = async () => {
setTesting(true)
setTestResult(null)
try {
if (canSave) await api.saveAiSettings(payload())
const r = await api.strategyAiTest()
setTestResult({ ok: r.ok, msg: r.ok ? `连通成功 · ${r.model ?? provider}` : (r.error ?? '未知错误') })
} catch (e: any) {
setTestResult({ ok: false, msg: String(e?.message ?? '测试失败') })
} finally {
setTesting(false)
}
}
return (
<div className="space-y-5 max-w-2xl">
{/* ===== ① 连接状态 ===== */}
<Card icon={Plug} title="连接状态" right={
configured && (
<button onClick={handleTest} disabled={testing}
@@ -146,16 +176,16 @@ export function SettingsAIPanel() {
{configured ? <Wifi className="h-4.5 w-4.5" /> : <WifiOff className="h-4.5 w-4.5" />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">
{configured ? 'AI 已连接' : 'AI 未配置'}
</div>
<div className="text-sm font-medium text-foreground">{configured ? 'AI 已连接' : 'AI 未配置'}</div>
<div className="text-xs text-muted mt-0.5 truncate">
{configured ? `${s?.ai_model} · ${s?.ai_api_key_masked}` : '配置 API Key 后即可使用 AI 策略定制'}
{configured
? (savedCodexProvider
? `${s?.ai_codex_command ?? CODEX_COMMAND} · ${s?.ai_model || '默认模型'}`
: `${s?.ai_model} · ${s?.ai_api_key_masked}`)
: (isCodexProvider ? '使用本机 codex exec, 此处无需填写 API Key。' : '配置 API Key 后即可使用 AI 功能。')}
</div>
</div>
</div>
{/* 测试结果 */}
{testResult && (
<div className={`mt-3 rounded-btn border px-3 py-2 text-xs flex items-center gap-2 ${testResult.ok ? 'border-emerald-400/20 bg-emerald-400/[0.04] text-emerald-400' : 'border-danger/20 bg-danger/[0.04] text-danger'}`}>
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${testResult.ok ? 'bg-emerald-400' : 'bg-danger'}`} />
@@ -164,14 +194,14 @@ export function SettingsAIPanel() {
)}
</Card>
{/* ===== ② 快速预设 ===== */}
<Card icon={Zap} title="快速预设">
<div className="flex flex-wrap items-start gap-2">
{PRESETS.map(p => (
<button key={p.label} onClick={() => handlePreset(p)}
className={`rounded-lg border px-3 py-2 text-left transition-all ${baseUrl === p.url ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-base text-secondary hover:border-accent/30'}`}>
className={`rounded-lg border px-3 py-2 text-left transition-all ${selectedPreset?.label === p.label ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-base text-secondary hover:border-accent/30'}`}>
<div className="flex items-center gap-1.5 text-xs font-medium">
<span>{p.label}</span>
{p.provider === CODEX_PROVIDER && <Terminal className="h-3 w-3" />}
{p.partner && <span className="rounded-full border border-orange-400/30 bg-orange-400/10 px-1.5 py-px text-[9px] text-orange-400"></span>}
</div>
</button>
@@ -185,135 +215,151 @@ export function SettingsAIPanel() {
</div>
<a href={selectedPreset.website} target="_blank" rel="noreferrer"
className="mt-1 inline-flex items-center gap-1 text-muted hover:text-accent transition-colors">
{selectedPreset.websiteLabel}
{selectedPreset.websiteLabel}
<ExternalLink className="h-3 w-3" />
</a>
</div>
)}
</Card>
{/* ===== ③ 自定义配置 ===== */}
<Card
icon={Settings2}
title="自定义配置"
right={
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted/60" title="使用 OpenAI 兼容的 Chat Completions 接口 (/v1/chat/completions)">
<span className="rounded-full border border-border/40 bg-base/50 px-1.5 py-px font-mono">Chat Completions</span>
<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>
{isCodexProvider ? 'CLI' : '接口'}
</span>
}
>
<div className="space-y-4">
{/* API 地址 + 模型 同行 */}
<div className="grid grid-cols-2 gap-4">
<Field label="API 地址">
<input type="text" value={baseUrl} onChange={e => setBaseUrl(e.target.value)}
placeholder="https://code.alysc.top"
className={INPUT_CLS} />
</Field>
<Field label="模型">
<input type="text" value={model} onChange={e => setModel(e.target.value)}
placeholder="gpt-5.5"
className={INPUT_CLS} />
</Field>
</div>
{/* API Key */}
<Field label="API Key">
<div className="flex gap-2">
<div className="flex-1 relative">
<input
type={showKey ? 'text' : 'password'}
value={apiKey} onChange={e => setApiKey(e.target.value)}
placeholder={configured ? `${s?.ai_api_key_masked} · 留空不修改` : 'sk-...'}
className={`${INPUT_CLS} pr-9`} />
<button onClick={() => setShowKey(v => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted/40 hover:text-muted"
tabIndex={-1} aria-label={showKey ? '隐藏' : '显示'}>
{showKey ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
<button onClick={handleTest} disabled={testing || !apiKey}
className="h-9 px-3 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 disabled:opacity-40 transition-all flex items-center gap-1.5 shrink-0">
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wifi className="h-3 w-3" />}
</button>
</div>
</Field>
{/* 分隔线 */}
<div className="border-t border-border/20" />
{/* 自定义 User-Agent */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Field label="自定义请求头 User-Agent" inline>
<Toggle checked={customUa} onChange={() => setCustomUa(v => !v)} />
{isCodexProvider ? (
<div className="grid grid-cols-2 gap-4">
<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}
</div>
</Field>
<Field
label="模型(可选)"
hint={codexCustomModel
? '留空则使用 Codex 默认模型'
: CODEX_MODEL_OPTIONS.find(o => o.value === model)?.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)
}
}}
className={INPUT_CLS}
>
{CODEX_MODEL_OPTIONS.map(option => (
<option key={option.label} 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>
<div className="text-[11px] text-muted/70 leading-relaxed">
{customUa
? '当前使用下方自定义 UA 调用 AI API。'
: '默认已使用内置浏览器标识,可绕过 Cloudflare 等 CDN/WAF 拦截。仅在默认标识被拦截时才需开启自定义。'}
</div>
{customUa && (
<div className="flex gap-2">
<input type="text" value={userAgent} onChange={e => setUserAgent(e.target.value)}
placeholder="留空点击「随机」或直接粘贴浏览器 UA"
className={`${INPUT_CLS} flex-1`} />
<button type="button" onClick={genRandomUa}
title="随机生成一条浏览器 UA"
className="h-9 px-2.5 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 transition-all flex items-center gap-1.5 shrink-0">
<Shuffle className="h-3 w-3" />
</button>
) : (
<>
<div className="grid grid-cols-2 gap-4">
<Field label="API 地址">
<input type="text" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} placeholder="https://code.alysc.top" className={INPUT_CLS} />
</Field>
<Field label="模型">
<input type="text" value={model} onChange={e => setModel(e.target.value)} placeholder="gpt-5.5" className={INPUT_CLS} />
</Field>
</div>
)}
</div>
<Field label="API Key">
<div className="flex gap-2">
<div className="flex-1 relative">
<input type={showKey ? 'text' : 'password'} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={configured ? `${s?.ai_api_key_masked} · 留空不修改` : 'sk-...'} className={`${INPUT_CLS} pr-9`} />
<button onClick={() => setShowKey(v => !v)} className="absolute right-2 top-1/2 -translate-y-1/2 text-muted/40 hover:text-muted" tabIndex={-1} aria-label={showKey ? '隐藏' : '显示'}>
{showKey ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
<button onClick={handleTest} disabled={testing || !apiKey} className="h-9 px-3 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 disabled:opacity-40 transition-all flex items-center gap-1.5 shrink-0">
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Wifi className="h-3 w-3" />}
</button>
</div>
</Field>
<div className="border-t border-border/20" />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Field label="自定义 User-Agent" inline>
<Toggle checked={customUa} onChange={() => setCustomUa(v => !v)} />
</Field>
</div>
{customUa && (
<div className="flex gap-2">
<input type="text" value={userAgent} onChange={e => setUserAgent(e.target.value)} placeholder="粘贴浏览器 User-Agent" className={`${INPUT_CLS} flex-1`} />
<button type="button" onClick={genRandomUa} title="随机生成浏览器 User-Agent" className="h-9 px-2.5 rounded-lg border border-border/50 text-xs text-secondary hover:text-accent hover:border-accent/30 transition-all flex items-center gap-1.5 shrink-0">
<Shuffle className="h-3 w-3" />
</button>
</div>
)}
</div>
</>
)}
</div>
</Card>
{/* ===== 安全提示 ===== */}
<div className="rounded-card border border-amber-400/20 bg-amber-400/[0.04] px-4 py-3 flex items-start gap-3">
<Shield className="h-4 w-4 text-amber-400/70 mt-0.5 shrink-0" />
<div className="text-[11px] text-amber-400/70 leading-relaxed">
API Key
{isCodexProvider
? 'Codex CLI 模式会复用本机已登录的 Codex 账户, 个股、财务、复盘等分析上下文会发送给 OpenAI/Codex。保存即表示确认仅在本机或可信内网使用。'
: 'API Key 仅保存在本机项目文件中, 不会上传到任何服务器。请妥善保管。'}
</div>
</div>
{/* ===== 保存 / 清空 ===== */}
<div className="flex gap-2">
<button onClick={() => save.mutate()} disabled={save.isPending || !canSave}
className="flex-1 h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all">
<button onClick={() => save.mutate()} disabled={save.isPending || !canSave} className="flex-1 h-10 rounded-xl bg-accent text-white text-sm font-semibold flex items-center justify-center gap-2 hover:bg-accent/90 disabled:opacity-40 transition-all">
{save.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : saved ? <Check className="h-4 w-4" /> : <Save className="h-4 w-4" />}
{save.isPending ? '保存中...' : saved ? '已保存' : '保存配置'}
</button>
{configured && (
<button onClick={() => setConfirmClear(true)} disabled={clear.isPending}
className="h-10 px-4 rounded-xl bg-elevated text-secondary hover:text-danger text-sm flex items-center justify-center gap-1.5 hover:bg-elevated/80 disabled:opacity-50 transition-all shrink-0"
title="清除 API Key、地址、模型等所有 AI 配置(保留自定义 UA)">
<button onClick={() => setConfirmClear(true)} disabled={clear.isPending} className="h-10 px-4 rounded-xl bg-elevated text-secondary hover:text-danger text-sm flex items-center justify-center gap-1.5 hover:bg-elevated/80 disabled:opacity-50 transition-all shrink-0" title="Clear AI provider configuration">
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
{/* 二次确认:清空 AI 配置 */}
{confirmClear && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setConfirmClear(false)} />
<div className="relative w-[90vw] max-w-[380px] rounded-card border border-border bg-base shadow-2xl p-6">
<h3 className="text-sm font-medium text-foreground mb-2"> AI </h3>
<p className="text-xs text-secondary mb-5 leading-relaxed">
API KeyAPI AI (User-Agent) AI ,
providerAPI KeyAPI Codex CLI
</p>
<div className="flex items-center justify-end gap-2">
<button onClick={() => setConfirmClear(false)}
className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors">
<button onClick={() => setConfirmClear(false)} className="px-3 py-1.5 rounded-btn bg-elevated text-secondary hover:bg-elevated/80 text-sm transition-colors">
</button>
<button onClick={() => clear.mutate()} disabled={clear.isPending}
className="px-3 py-1.5 rounded-btn bg-danger/15 text-danger hover:bg-danger/25 text-sm font-medium transition-colors disabled:opacity-50">
{clear.isPending ? '清空中...' : '确认清空'}
<button onClick={() => clear.mutate()} disabled={clear.isPending} className="px-3 py-1.5 rounded-btn bg-danger/15 text-danger hover:bg-danger/25 text-sm font-medium transition-colors disabled:opacity-50">
{clear.isPending ? '清空中...' : '确认'}
</button>
</div>
</div>
@@ -323,7 +369,6 @@ export function SettingsAIPanel() {
)
}
// ===== 通用卡片(与 Keys 页风格统一) =====
interface CardProps {