From ec6380376d9f68bd6738c425b578edbfc4f91092 Mon Sep 17 00:00:00 2001 From: Lzwaang Date: Mon, 29 Jun 2026 00:39:58 +0800 Subject: [PATCH 1/3] Add Codex CLI AI provider --- backend/app/api/settings.py | 34 +- backend/app/api/strategy.py | 41 +- backend/app/config.py | 1 + backend/app/services/ai_provider.py | 419 +++++++++++++++++++++ backend/app/services/financial_analyzer.py | 32 +- backend/app/services/market_recap.py | 36 +- backend/app/services/stock_analyzer.py | 38 +- backend/app/strategy/ai_generator.py | 36 +- frontend/src/components/Layout.tsx | 2 +- frontend/src/lib/aiReportStore.ts | 10 +- frontend/src/lib/api.ts | 10 +- frontend/src/lib/stockAnalysisStore.ts | 10 +- frontend/src/pages/settings/AI.tsx | 329 +++++++++------- 13 files changed, 695 insertions(+), 303 deletions(-) create mode 100644 backend/app/services/ai_provider.py diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 8612846..2fe3f43 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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} diff --git a/backend/app/api/strategy.py b/backend/app/api/strategy.py index 09f9f80..b355209 100644 --- a/backend/app/api/strategy.py +++ b/backend/app/api/strategy.py @@ -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)} diff --git a/backend/app/config.py b/backend/app/config.py index 91d987b..f6c4f43 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 = ( diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py new file mode 100644 index 0000000..c5b617b --- /dev/null +++ b/backend/app/services/ai_provider.py @@ -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") + 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 "" diff --git a/backend/app/services/financial_analyzer.py b/backend/app/services/financial_analyzer.py index 772de9b..8dcd57a 100644 --- a/backend/app/services/financial_analyzer.py +++ b/backend/app/services/financial_analyzer.py @@ -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) diff --git a/backend/app/services/market_recap.py b/backend/app/services/market_recap.py index eb4dc2a..2fe0d7d 100644 --- a/backend/app/services/market_recap.py +++ b/backend/app/services/market_recap.py @@ -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) diff --git a/backend/app/services/stock_analyzer.py b/backend/app/services/stock_analyzer.py index 88e4d9d..c312459 100644 --- a/backend/app/services/stock_analyzer.py +++ b/backend/app/services/stock_analyzer.py @@ -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) diff --git a/backend/app/strategy/ai_generator.py b/backend/app/strategy/ai_generator.py index f774a4c..4db6a5e 100644 --- a/backend/app/strategy/ai_generator.py +++ b/backend/app/strategy/ai_generator.py @@ -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: diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 819ac15..a557511 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -409,7 +409,7 @@ export function Layout() { hasKey={settingsState?.mode !== 'none'} /> diff --git a/frontend/src/lib/aiReportStore.ts b/frontend/src/lib/aiReportStore.ts index 38985f5..c7f8f5e 100644 --- a/frontend/src/lib/aiReportStore.ts +++ b/frontend/src/lib/aiReportStore.ts @@ -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), }) } } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index dc41613..04229d5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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' }, ), diff --git a/frontend/src/lib/stockAnalysisStore.ts b/frontend/src/lib/stockAnalysisStore.ts index 7c31645..ac88e9f 100644 --- a/frontend/src/lib/stockAnalysisStore.ts +++ b/frontend/src/lib/stockAnalysisStore.ts @@ -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), }) } } diff --git a/frontend/src/pages/settings/AI.tsx b/frontend/src/pages/settings/AI.tsx index abd9827..8e8bf80 100644 --- a/frontend/src/pages/settings/AI.tsx +++ b/frontend/src/pages/settings/AI.tsx @@ -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(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(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 (
- {/* ===== ① 连接状态 ===== */} : }
-
- {configured ? 'AI 已连接' : 'AI 未配置'} -
+
{configured ? 'AI 已连接' : 'AI 未配置'}
- {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 功能。')}
- - {/* 测试结果 */} {testResult && (
@@ -164,14 +194,14 @@ export function SettingsAIPanel() { )} - {/* ===== ② 快速预设 ===== */}
{PRESETS.map(p => ( @@ -185,135 +215,151 @@ export function SettingsAIPanel() {
- 官网:{selectedPreset.websiteLabel} + {selectedPreset.websiteLabel}
)} - {/* ===== ③ 自定义配置 ===== */} - Chat Completions - 接口 + + {isCodexProvider ? 'codex exec' : 'Chat Completions'} + {isCodexProvider ? 'CLI' : '接口'} } >
- {/* API 地址 + 模型 同行 */} -
- - setBaseUrl(e.target.value)} - placeholder="https://code.alysc.top" - className={INPUT_CLS} /> - - - setModel(e.target.value)} - placeholder="gpt-5.5" - className={INPUT_CLS} /> - -
- - {/* API Key */} - -
-
- setApiKey(e.target.value)} - placeholder={configured ? `${s?.ai_api_key_masked} · 留空不修改` : 'sk-...'} - className={`${INPUT_CLS} pr-9`} /> - -
- -
-
- - {/* 分隔线 */} -
- - {/* 自定义 User-Agent */} -
-
- - setCustomUa(v => !v)} /> + {isCodexProvider ? ( +
+ +
+ {CODEX_COMMAND} +
+
+ o.value === model)?.hint} + > + + {codexCustomModel && ( + setModel(e.target.value)} + placeholder="例如 gpt-5.5" + className={`${INPUT_CLS} mt-2`} + /> + )}
-
- {customUa - ? '当前使用下方自定义 UA 调用 AI API。' - : '默认已使用内置浏览器标识,可绕过 Cloudflare 等 CDN/WAF 拦截。仅在默认标识被拦截时才需开启自定义。'} -
- {customUa && ( -
- setUserAgent(e.target.value)} - placeholder="留空点击「随机」或直接粘贴浏览器 UA" - className={`${INPUT_CLS} flex-1`} /> - + ) : ( + <> +
+ + setBaseUrl(e.target.value)} placeholder="https://code.alysc.top" className={INPUT_CLS} /> + + + setModel(e.target.value)} placeholder="gpt-5.5" className={INPUT_CLS} /> +
- )} -
+ + +
+
+ setApiKey(e.target.value)} placeholder={configured ? `${s?.ai_api_key_masked} · 留空不修改` : 'sk-...'} className={`${INPUT_CLS} pr-9`} /> + +
+ +
+
+ +
+ +
+
+ + setCustomUa(v => !v)} /> + +
+ {customUa && ( +
+ setUserAgent(e.target.value)} placeholder="粘贴浏览器 User-Agent" className={`${INPUT_CLS} flex-1`} /> + +
+ )} +
+ + )}
- {/* ===== 安全提示 ===== */}
- API Key 仅保存在本机项目文件,不上传至任何服务器。请妥善保管,勿泄露给他人。 + {isCodexProvider + ? 'Codex CLI 模式会复用本机已登录的 Codex 账户, 个股、财务、复盘等分析上下文会发送给 OpenAI/Codex。保存即表示确认仅在本机或可信内网使用。' + : 'API Key 仅保存在本机项目文件中, 不会上传到任何服务器。请妥善保管。'}
- {/* ===== 保存 / 清空 ===== */}
- {configured && ( - )}
- {/* 二次确认:清空 AI 配置 */} {confirmClear && (
setConfirmClear(false)} />

清空 AI 配置

- 将清除 API Key、API 地址、模型等所有 AI 配置。自定义请求头(User-Agent)会保留。清空后相关 AI 功能将不可用,需重新配置。 + 这会清空已保存的 provider、API Key、API 地址、模型和 Codex CLI 命令。之后可以重新配置。

- -
@@ -323,7 +369,6 @@ export function SettingsAIPanel() { ) } - // ===== 通用卡片(与 Keys 页风格统一) ===== interface CardProps { From ab9480b3f328a5eb488d04d78c0479d2d79e2a28 Mon Sep 17 00:00:00 2001 From: Lzwaang Date: Mon, 29 Jun 2026 22:44:31 +0800 Subject: [PATCH 2/3] Harden Codex CLI workspace isolation --- backend/app/services/ai_provider.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index c5b617b..2c7ce10 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -191,8 +191,12 @@ async def _run_codex_cli( 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) + with tempfile.TemporaryDirectory(prefix="tickflow-codex-run-") as run_dir: + run_path = Path(run_dir) + codex_home_path = run_path / "codex-home" + workspace_path = run_path / "workspace" + codex_home_path.mkdir() + workspace_path.mkdir() output_path = codex_home_path / "last-message.txt" _prepare_codex_home(codex_home_path) @@ -211,7 +215,7 @@ async def _run_codex_cli( model = current_ai_model().strip() if model: args.extend(["--model", model]) - args.extend(["--cd", str(_project_root()), "-"]) + args.extend(["--cd", str(workspace_path), "-"]) env = os.environ.copy() env.setdefault("NO_COLOR", "1") @@ -249,7 +253,8 @@ async def _run_codex_cli( 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.", + "This is a text-generation task. The working directory is intentionally empty.", + "Use only the user-provided prompt content below; do not inspect or modify local files.", "Return only the final requested content; do not include execution logs.", ] if max_tokens > 0: @@ -345,10 +350,6 @@ def _resolve_windows_desktop_codex() -> str | None: 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() From 2d86dea7212386ef8b9542db2b10d6b3dbbbaead Mon Sep 17 00:00:00 2001 From: Lzwaang Date: Mon, 29 Jun 2026 22:48:52 +0800 Subject: [PATCH 3/3] Avoid copying Codex env file --- backend/app/services/ai_provider.py | 7 +++---- frontend/src/pages/settings/AI.tsx | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index 2c7ce10..16af234 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -353,10 +353,9 @@ def _resolve_windows_desktop_codex() -> str | None: 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) + auth_file = source / "auth.json" + if auth_file.exists(): + shutil.copy2(auth_file, target / "auth.json") _write_compatible_codex_config(target / "config.toml") diff --git a/frontend/src/pages/settings/AI.tsx b/frontend/src/pages/settings/AI.tsx index 8e8bf80..96d5b55 100644 --- a/frontend/src/pages/settings/AI.tsx +++ b/frontend/src/pages/settings/AI.tsx @@ -29,8 +29,8 @@ const PRESETS: { label: string; provider?: string; url: string; model: string; c { 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 的本地环境。' }, + { 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倍率' }, ] export function SettingsAIPanel() {