mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
Add Codex CLI AI provider
This commit is contained in:
@@ -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
@@ -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)}
|
||||
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
),
|
||||
|
||||
@@ -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
@@ -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 Key、API 地址、模型等所有 AI 配置。自定义请求头(User-Agent)会保留。清空后相关 AI 功能将不可用,需重新配置。
|
||||
这会清空已保存的 provider、API Key、API 地址、模型和 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 {
|
||||
|
||||
Reference in New Issue
Block a user