diff --git a/.env.example b/.env.example index 0fac35d..59db6f1 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,7 @@ LOG_LEVEL=INFO # 首次启动时预置访问密码(可选)。公网服务器部署时填入,免去 SSH 端口转发设密码。 # 仅在尚未设置密码时生效(一次性初始化);设过后改密码请用页面 UI, 此处不再读取。 # 建议至少 6 位。.env 文件权限保持 600 且不要提交到 Git。 -AUTH_PASSWORD= +AUTH_PASSWORD='' # Optional backend dependency extras for Docker and ./dev.sh / .\dev.ps1. # Set to legacy-cpu on older CPUs without AVX2/FMA support. diff --git a/Dockerfile b/Dockerfile index cb295bf..dd0d6d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -125,7 +125,8 @@ COPY --from=stocksdk-builder /build/node_modules ./app/plugins/stocksdk/node_mod COPY tiers.yaml /app/tiers.yaml ENV STATIC_DIR=/app/static \ TIERS_YAML=/app/tiers.yaml \ - DATA_DIR=/app/data + DATA_DIR=/app/data \ + TICKFLOW_ENV_FILE=/app/.env # Frontend 静态产物 COPY --from=frontend-builder /build/dist ./static diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index 14744f1..1187c66 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -930,7 +930,7 @@ async def sync_minute(request: Request): """ import asyncio - from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot, LONG_JOB_TIMEOUT_S + from app.services.pipeline_jobs import job_store, release_run_slot, try_acquire_run_slot from app.api.data import invalidate_storage_cache from app.services.preferences import get_minute_sync_days from app.tickflow.capabilities import Cap @@ -953,7 +953,7 @@ async def sync_minute(request: Request): extend_flag = body.get("extend") # 分钟K全市场同步是长任务(数据量是日K的 ~240 倍),用更宽松的卡死阈值 - job_id, is_new = job_store.create(timeout_s=LONG_JOB_TIMEOUT_S) + job_id, is_new = job_store.create(long_running=True) if not is_new: return {"status": "reused", "job_id": job_id} diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 98ccffc..6175b22 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -58,7 +58,10 @@ def get_settings() -> dict: ai_configured, current_ai_model, current_codex_command, + current_codex_model, current_codex_reasoning_effort, + current_openai_model, + current_openai_reasoning_effort, ) key = secrets_store.get_tickflow_key() @@ -81,6 +84,9 @@ def get_settings() -> dict: "has_ai_key": bool(secrets_store.get_ai_key()), "ai_configured": ai_configured(ai_provider), "ai_model": current_ai_model(), + "ai_openai_model": current_openai_model(), + "ai_reasoning_effort": current_openai_reasoning_effort(), + "ai_codex_model": current_codex_model(), "ai_codex_command": current_codex_command(), "ai_codex_reasoning_effort": current_codex_reasoning_effort(), "ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent), @@ -240,6 +246,7 @@ class AiSettingsIn(BaseModel): base_url: str = "" api_key: str | None = None model: str = "" + reasoning_effort: str = Field(default="high", max_length=64) codex_command: str = "" codex_reasoning_effort: str = "" user_agent: str = "" @@ -250,12 +257,17 @@ def save_ai_settings(req: AiSettingsIn) -> dict: """保存 AI 配置(全部持久化到 secrets.json)""" from app.config import settings from app.services.ai_provider import ( + OPENAI_PROVIDER, ai_configured, current_ai_model, current_ai_provider, current_codex_command, + current_codex_model, current_codex_reasoning_effort, + current_openai_model, + current_openai_reasoning_effort, normalize_codex_command, + normalize_codex_model, normalize_codex_reasoning_effort, ) @@ -263,23 +275,8 @@ def save_ai_settings(req: AiSettingsIn) -> dict: if req.provider: updates["ai_provider"] = req.provider settings.ai_provider = req.provider - if req.base_url: - updates["ai_base_url"] = req.base_url - settings.ai_base_url = req.base_url - if req.api_key is not None: - if req.api_key: - updates["ai_api_key"] = req.api_key - settings.ai_api_key = req.api_key - else: - secrets_store.clear("ai_api_key") - settings.ai_api_key = "" - 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": + updates["ai_codex_model"] = normalize_codex_model(req.model) try: codex_command = normalize_codex_command(req.codex_command) except ValueError as exc: @@ -289,6 +286,22 @@ def save_ai_settings(req: AiSettingsIn) -> dict: updates["ai_codex_reasoning_effort"] = codex_reasoning_effort settings.ai_codex_command = codex_command settings.ai_codex_reasoning_effort = codex_reasoning_effort + else: + if req.base_url: + updates["ai_base_url"] = req.base_url + settings.ai_base_url = req.base_url + if req.api_key is not None: + if req.api_key: + updates["ai_api_key"] = req.api_key + settings.ai_api_key = req.api_key + else: + secrets_store.clear("ai_api_key") + settings.ai_api_key = "" + if req.model: + updates["ai_model"] = req.model + settings.ai_model = req.model + if req.provider == OPENAI_PROVIDER: + updates["ai_reasoning_effort"] = req.reasoning_effort.strip() # user_agent 允许清空(回到默认浏览器 UA),故无条件持久化 updates["ai_user_agent"] = req.user_agent settings.ai_user_agent = req.user_agent @@ -301,6 +314,9 @@ def save_ai_settings(req: AiSettingsIn) -> dict: "ok": True, "ai_provider": provider, "ai_model": current_ai_model(), + "ai_openai_model": current_openai_model(), + "ai_reasoning_effort": current_openai_reasoning_effort(), + "ai_codex_model": current_codex_model(), "ai_codex_command": current_codex_command(), "ai_codex_reasoning_effort": current_codex_reasoning_effort(), "ai_configured": ai_configured(provider), @@ -315,7 +331,16 @@ def clear_ai_settings() -> dict: """ from app.config import settings - secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command", "ai_codex_reasoning_effort") + secrets_store.clear( + "ai_provider", + "ai_base_url", + "ai_api_key", + "ai_model", + "ai_reasoning_effort", + "ai_codex_model", + "ai_codex_command", + "ai_codex_reasoning_effort", + ) # 同步重置运行时内存(provider 回默认值,其余置空) settings.ai_provider = "openai_compat" settings.ai_base_url = "" @@ -350,6 +375,11 @@ class DataProvidersIn(BaseModel): financial_data_provider: str | None = None +class DataSourceJobTimeoutPrefs(BaseModel): + data_source_job_timeout_s: int = Field(ge=60) + data_source_long_job_timeout_s: int = Field(ge=60) + + class DatasetFieldMapItem(BaseModel): source: str target: str @@ -414,6 +444,8 @@ def get_preferences() -> dict: "minute_data_provider": preferences.get_minute_data_provider(), "realtime_data_provider": preferences.get_realtime_data_provider(), "financial_data_provider": preferences.get_financial_provider(), + "data_source_job_timeout_s": preferences.get_data_source_job_timeout_s(), + "data_source_long_job_timeout_s": preferences.get_data_source_long_job_timeout_s(), "realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(), **preferences.get_realtime_quote_scope(), "pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(), @@ -617,6 +649,14 @@ def update_data_providers(req: DataProvidersIn) -> dict: } +@router.put("/preferences/data-source-job-timeouts") +def update_data_source_job_timeouts(req: DataSourceJobTimeoutPrefs) -> dict: + """保存普通与长数据后台任务的卡死判定时间。""" + from app.services import preferences + preferences.save(req.model_dump()) + return req.model_dump() + + @router.get("/preferences/watchlist-columns") def get_watchlist_columns() -> dict: """返回自选列表列配置。""" diff --git a/backend/app/config.py b/backend/app/config.py index b57f305..38be513 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,6 +1,7 @@ """全局配置 — 从环境变量 / .env 读取。""" from __future__ import annotations +import os import sys from pathlib import Path @@ -63,11 +64,17 @@ def _project_root() -> Path: _PROJECT_ROOT = _project_root() _RESOURCE_ROOT = _resource_root() +_ENV_FILE = Path( + os.environ.get( + "TICKFLOW_ENV_FILE", + str(_RESOURCE_ROOT / ".env") if not _IS_FROZEN else ".env", + ) +) class Settings(BaseSettings): model_config = SettingsConfigDict( - env_file=str(_RESOURCE_ROOT / ".env") if not _IS_FROZEN else ".env", + env_file=str(_ENV_FILE), env_file_encoding="utf-8", extra="ignore", ) diff --git a/backend/app/services/ai_provider.py b/backend/app/services/ai_provider.py index 2886520..754a485 100644 --- a/backend/app/services/ai_provider.py +++ b/backend/app/services/ai_provider.py @@ -20,9 +20,11 @@ from app import secrets_store from app.config import settings OPENAI_COMPAT_PROVIDER = "openai_compat" +OPENAI_PROVIDER = "openai" CODEX_CLI_PROVIDER = "codex_cli" CODEX_DEFAULT_COMMAND = "codex" CODEX_SUPPORTED_REASONING_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh"} +OPENAI_DEFAULT_REASONING_EFFORT = "high" _CODEX_ENV_ALLOWLIST = ( "PATH", @@ -104,10 +106,31 @@ def current_ai_provider() -> str: return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER +def current_openai_model() -> str: + return secrets_store.get_ai_config("ai_model", settings.ai_model) + + +def current_codex_model() -> str: + stored = secrets_store.load() + model = stored.get("ai_codex_model") + # 旧版本的两种 provider 共用 ai_model。仅在旧配置仍启用 Codex 时回退读取, + # 避免把正常的 OpenAI-compatible 模型误当作 Codex 模型。 + if model is None and current_ai_provider() == CODEX_CLI_PROVIDER: + model = stored.get("ai_model") + return normalize_codex_model(str(model or "")) + + 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) + return current_codex_model() + return current_openai_model() + + +def current_openai_reasoning_effort() -> str: + stored = secrets_store.load() + if "ai_reasoning_effort" not in stored: + return OPENAI_DEFAULT_REASONING_EFFORT + return str(stored.get("ai_reasoning_effort") or "").strip() def current_codex_command() -> str: @@ -246,23 +269,20 @@ async def _run_openai_once( client = _openai_client(ai_key, timeout) model = current_ai_model() req_messages = list(messages) - try: - resp = await client.chat.completions.create( - model=model, - messages=req_messages, - **_openai_kwargs(temperature=temperature, max_tokens=max_tokens), - ) - except Exception as exc: - # Reasoning 类模型 (如 kimi-k2.7-code, deepseek-r1, o 系列) 拒绝非约定 - # temperature (Moonshot 报 "only 1 is allowed for this model")。不再靠 - # 模型名猜测, 而是捕获该错误后去掉 temperature 重试一次 —— 对所有此类模型都稳。 - if temperature is not None and _is_temperature_rejected(exc): + kwargs = _openai_kwargs(temperature=temperature, max_tokens=max_tokens) + while True: + try: resp = await client.chat.completions.create( model=model, messages=req_messages, - **_openai_kwargs(temperature=None, max_tokens=max_tokens), + **kwargs, ) - else: + break + except Exception as exc: + retry_kwargs = _openai_retry_kwargs(exc, kwargs) + if retry_kwargs is not None: + kwargs = retry_kwargs + continue if _is_openai_transport_error(exc): raise RuntimeError(_format_openai_error(exc)) from exc raise @@ -292,23 +312,22 @@ async def _stream_openai( if delta and delta.content: yield delta.content - try: - stream = await client.chat.completions.create( - model=model, - messages=req_messages, - **_openai_kwargs(temperature=temperature, max_tokens=max_tokens), - stream=True, - ) - except Exception as exc: - # 流尚未开始 yield, 可安全重建: 去掉 temperature 后重开 stream。 - if temperature is not None and _is_temperature_rejected(exc): + kwargs = _openai_kwargs(temperature=temperature, max_tokens=max_tokens) + while True: + try: stream = await client.chat.completions.create( model=model, messages=req_messages, - **_openai_kwargs(temperature=None, max_tokens=max_tokens), + **kwargs, stream=True, ) - else: + break + except Exception as exc: + # 流尚未开始 yield, 可安全移除被拒绝的可选参数后重建。 + retry_kwargs = _openai_retry_kwargs(exc, kwargs) + if retry_kwargs is not None: + kwargs = retry_kwargs + continue if _is_openai_transport_error(exc): raise RuntimeError(_format_openai_error(exc)) from exc raise @@ -335,11 +354,10 @@ def _openai_client(api_key: str, timeout: float): ) -# Reasoning / thinking 类模型 (kimi-k2.7-code, deepseek-r1, OpenAI o 系列等) 不接受 -# 任意 temperature, 上游会以 400 拒绝 (如 Moonshot: "only 1 is allowed for this model")。 -# 这里不靠模型名猜测, 而是在真正命中该错误后自动去掉 temperature 重试 (见 -# _run_openai_once / _stream_openai), 对任意 reasoning 模型都稳健。 -_TEMP_REJECT_HINTS = ("temperature", "only 1 is allowed", "unsupported parameter") +# 不同模型可能拒绝 temperature 或 reasoning_effort。这里不靠模型名猜测, +# 只在 400 明确指出对应参数时移除该参数并重试; 每个参数最多移除一次。 +_TEMP_REJECT_HINTS = ("temperature", "only 1 is allowed") +_REASONING_EFFORT_REJECT_HINTS = ("reasoning_effort", "reasoning effort") def _is_temperature_rejected(exc: Exception) -> bool: @@ -347,14 +365,52 @@ def _is_temperature_rejected(exc: Exception) -> bool: if getattr(exc, "status_code", None) != 400: return False text = _openai_error_detail(exc) or str(exc) - return any(h in text.lower() for h in _TEMP_REJECT_HINTS) + return _openai_error_param(exc) == "temperature" or any( + h in text.lower() for h in _TEMP_REJECT_HINTS + ) + + +def _is_reasoning_effort_rejected(exc: Exception) -> bool: + """True if the upstream 400 specifically rejects reasoning_effort.""" + if getattr(exc, "status_code", None) != 400: + return False + text = _openai_error_detail(exc) or str(exc) + return _openai_error_param(exc) == "reasoning_effort" or any( + h in text.lower() for h in _REASONING_EFFORT_REJECT_HINTS + ) + + +def _openai_error_param(exc: Exception) -> str: + body = getattr(exc, "body", None) + if not isinstance(body, dict): + return "" + error = body.get("error") + if isinstance(error, dict): + body = error + return str(body.get("param") or "").strip().lower() + + +def _openai_retry_kwargs(exc: Exception, kwargs: dict) -> dict | None: + """Remove one explicitly rejected optional argument for a bounded retry.""" + retry_kwargs = dict(kwargs) + if "temperature" in retry_kwargs and _is_temperature_rejected(exc): + retry_kwargs.pop("temperature") + return retry_kwargs + if "reasoning_effort" in retry_kwargs and _is_reasoning_effort_rejected(exc): + retry_kwargs.pop("reasoning_effort") + return retry_kwargs + return None def _openai_kwargs(*, temperature: float | None, max_tokens: int) -> dict: - """Build OpenAI create() kwargs; temperature omitted when None.""" + """Build OpenAI create() kwargs; optional parameters are omitted when empty.""" kwargs: dict = {"max_tokens": max_tokens} if temperature is not None: kwargs["temperature"] = temperature + if current_ai_provider() == OPENAI_PROVIDER: + reasoning_effort = current_openai_reasoning_effort() + if reasoning_effort: + kwargs["reasoning_effort"] = reasoning_effort return kwargs @@ -700,10 +756,14 @@ def _codex_home() -> Path: def _write_compatible_codex_config(path: Path) -> None: config = _read_codex_config() lines: list[str] = [] - local_provider = _docker_codex_local_provider(config) + active_provider = _active_codex_provider(config) - if local_provider: - lines.append(_toml_string("model_provider", "codex_local_access")) + if active_provider: + lines.append(_toml_string("model_provider", active_provider[0])) + + openai_base_url = config.get("openai_base_url") + if isinstance(openai_base_url, str) and openai_base_url: + lines.append(_toml_string("openai_base_url", openai_base_url)) model = current_ai_model() or normalize_codex_model(str(config.get("model") or "")) if model: @@ -718,41 +778,43 @@ def _write_compatible_codex_config(path: Path) -> None: lines.append(_toml_string("approval_policy", "never")) lines.append(_toml_string("sandbox_mode", "read-only")) - if local_provider: + if active_provider: + provider_name, provider = active_provider lines.append("") - lines.append("[model_providers.codex_local_access]") + lines.append(f"[model_providers.{_toml_key(provider_name)}]") for key in ("name", "base_url", "wire_api", "experimental_bearer_token"): - value = local_provider.get(key) + value = provider.get(key) if isinstance(value, str) and value: lines.append(_toml_string(key, value)) for key in ("requires_openai_auth", "supports_websockets"): - value = local_provider.get(key) + value = provider.get(key) if isinstance(value, bool): lines.append(f"{key} = {'true' if value else 'false'}") path.write_text("\n".join(lines) + "\n", encoding="utf-8") -def _docker_codex_local_provider(config: dict) -> dict | None: - """Return the local-access provider adapted to Docker's host gateway.""" - docker_host = os.environ.get("CODEX_DOCKER_HOST", "").strip() - if not docker_host or config.get("model_provider") != "codex_local_access": +def _active_codex_provider(config: dict) -> tuple[str, dict] | None: + """Return the active custom provider, adapting loopback URLs for Docker.""" + provider_name = config.get("model_provider") + if not isinstance(provider_name, str) or not provider_name: return None providers = config.get("model_providers") if not isinstance(providers, dict): return None - source = providers.get("codex_local_access") + source = providers.get(provider_name) if not isinstance(source, dict): return None provider = dict(source) base_url = str(provider.get("base_url") or "").strip() parsed = urlsplit(base_url) - if parsed.hostname in {"localhost", "127.0.0.1", "::1"}: + docker_host = os.environ.get("CODEX_DOCKER_HOST", "").strip() + if docker_host and parsed.hostname in {"localhost", "127.0.0.1", "::1"}: port = f":{parsed.port}" if parsed.port else "" provider["base_url"] = urlunsplit(parsed._replace(netloc=f"{docker_host}{port}")) - return provider + return provider_name, provider def _read_codex_config() -> dict: @@ -786,6 +848,13 @@ def _toml_string(key: str, value: str) -> str: return f'{key} = "{escaped}"' +def _toml_key(value: str) -> str: + if re.fullmatch(r"[A-Za-z0-9_-]+", value): + return value + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + def _clean_process_text(raw: bytes) -> str: text = raw.decode("utf-8", errors="replace") return _ANSI_RE.sub("", text).strip() diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index dbfcbad..951956a 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -131,9 +131,17 @@ def bootstrap_from_env() -> bool: Returns: True 表示本次用环境变量初始化了密码; False 表示无需初始化。 """ - from app.config import settings + from app.config import _ENV_FILE, settings pwd = (settings.auth_password or "").strip() + # Compose 会对 env_file 中未加单引号的 $VAR 做插值。Docker 部署时同时 + # 只读挂载原始 .env,首次初始化密码直接按 dotenv 语义读取,避免特殊字符被截断。 + if _ENV_FILE.is_file(): + from dotenv import dotenv_values + + raw_pwd = dotenv_values(_ENV_FILE, encoding="utf-8", interpolate=False).get("AUTH_PASSWORD") + if isinstance(raw_pwd, str) and raw_pwd.strip(): + pwd = raw_pwd.strip() if not pwd: return False if is_configured(): diff --git a/backend/app/services/pipeline_jobs.py b/backend/app/services/pipeline_jobs.py index 53f3397..c4b99eb 100644 --- a/backend/app/services/pipeline_jobs.py +++ b/backend/app/services/pipeline_jobs.py @@ -27,7 +27,7 @@ JobStatus = Literal["pending", "running", "succeeded", "failed"] # 由 reap_stale() 在 /run 和 /jobs/{id} 轮询端点检查 — 保证卡死后能自愈, # 无需用户再次点击「同步」。 # -# 超时阈值按任务类型区分: +# 默认超时阈值按任务类型区分,可在 Web 数据源设置中调整: # - 普通任务(日K管道/扩展/修正/重算): 1200s (20 分钟) # - 长任务(分钟K全市场同步,数据量是日K的 ~240 倍): 1800s (30 分钟) # 分钟K即使流式落盘后仍可能跑十几到数十分钟(限速 sleep 是主因), @@ -105,7 +105,12 @@ class JobStore: # ===== lifecycle ===== - def create(self, timeout_s: int = DEFAULT_JOB_TIMEOUT_S) -> tuple[str, bool]: + def create( + self, + timeout_s: int | None = None, + *, + long_running: bool = False, + ) -> tuple[str, bool]: """单飞创建任务。返回 (job_id, is_new)。 去重条件为 **pending ∨ running**(而非仅 running):`/run` 先 create() 再在 @@ -115,9 +120,17 @@ class JobStore: is_new=False 表示复用了已有活跃任务,调用方**不得**再调度新的后台任务。 - timeout_s: reap_stale 判定卡死的阈值。普通任务默认 1200s; - 分钟K全市场同步等长任务传 LONG_JOB_TIMEOUT_S (1800s)。 + timeout_s: reap_stale 判定卡死的阈值。None 时读取用户配置。 + long_running: timeout_s 为 None 时,是否读取长任务配置;普通任务默认 + 1200s,分钟K全市场同步等长任务默认 1800s。 """ + if timeout_s is None: + from app.services import preferences + if long_running: + timeout_s = preferences.get_data_source_long_job_timeout_s() + else: + timeout_s = preferences.get_data_source_job_timeout_s() + with self._lock: if self._active_id: active = self._active_jobs.get(self._active_id) diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 9eb9b90..8bb2fb2 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -191,6 +191,32 @@ def get_minute_sync_segment_days() -> int: # ===== 数据源选择 (默认 TickFlow;第一阶段仅日K切换入口) ===== _ALLOWED_DATA_PROVIDERS = {"tickflow"} +DATA_SOURCE_JOB_TIMEOUT_MIN_S = 60 + + +def get_data_source_job_timeout_s() -> int: + """返回普通数据后台任务的卡死判定时间(秒)。""" + from app.services.pipeline_jobs import DEFAULT_JOB_TIMEOUT_S + raw = load().get("data_source_job_timeout_s", DEFAULT_JOB_TIMEOUT_S) + try: + timeout_s = int(raw) + except (TypeError, ValueError): + timeout_s = DEFAULT_JOB_TIMEOUT_S + return max(DATA_SOURCE_JOB_TIMEOUT_MIN_S, timeout_s) + + +def get_data_source_long_job_timeout_s() -> int: + """返回分钟 K 全市场等长任务的卡死判定时间(秒)。""" + from app.services.pipeline_jobs import LONG_JOB_TIMEOUT_S + raw = load().get( + "data_source_long_job_timeout_s", + LONG_JOB_TIMEOUT_S, + ) + try: + timeout_s = int(raw) + except (TypeError, ValueError): + timeout_s = LONG_JOB_TIMEOUT_S + return max(DATA_SOURCE_JOB_TIMEOUT_MIN_S, timeout_s) def _allowed_data_providers() -> set[str]: diff --git a/backend/tests/test_ai_provider.py b/backend/tests/test_ai_provider.py index 1b5a89c..75930fb 100644 --- a/backend/tests/test_ai_provider.py +++ b/backend/tests/test_ai_provider.py @@ -5,6 +5,9 @@ import tomllib import httpx import openai +from app import secrets_store +from app.api import settings as settings_api +from app.config import settings from app.services import ai_provider from app.services.ai_provider import ( _format_openai_error, @@ -108,7 +111,7 @@ def test_is_temperature_rejected_matches_moonshot_message(): assert _is_temperature_rejected(exc) is True -def test_is_temperature_rejected_matches_generic_temperature_hint(): +def test_optional_openai_params_use_targeted_400_fallbacks(): response = httpx.Response( 400, json={"error": {"message": "unsupported parameter: temperature"}}, @@ -120,6 +123,29 @@ def test_is_temperature_rejected_matches_generic_temperature_hint(): ) assert _is_temperature_rejected(exc) is True + kwargs = {"max_tokens": 1000, "temperature": 0.3, "reasoning_effort": "high"} + assert ai_provider._openai_retry_kwargs(exc, kwargs) == { + "max_tokens": 1000, + "reasoning_effort": "high", + } + + response = httpx.Response( + 400, + json={"error": {"message": "unrecognized request argument", "param": "reasoning_effort"}}, + request=httpx.Request("POST", "https://example.com/v1/chat/completions"), + ) + exc = openai.BadRequestError( + "bad request", response=response, + body={"error": {"message": "unrecognized request argument", "param": "reasoning_effort"}}, + ) + assert _is_temperature_rejected(exc) is False + assert ai_provider._is_reasoning_effort_rejected(exc) is True + assert ai_provider._openai_retry_kwargs(exc, kwargs) == { + "max_tokens": 1000, + "temperature": 0.3, + } + assert kwargs == {"max_tokens": 1000, "temperature": 0.3, "reasoning_effort": "high"} + def test_is_temperature_rejected_false_for_other_400(): """非 temperature 相关的 400 (如 model not found) 不应触发去 temperature 重试。""" @@ -145,6 +171,96 @@ def test_is_temperature_rejected_false_for_non_400(): assert _is_temperature_rejected(exc) is False +def test_openai_kwargs_include_configured_reasoning_effort(monkeypatch): + stored = {"ai_provider": "openai_compat"} + monkeypatch.setattr(secrets_store, "load", lambda: stored) + + assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000) + + stored["ai_provider"] = "openai" + assert ai_provider._openai_kwargs(temperature=None, max_tokens=1000)["reasoning_effort"] == "high" + + stored["ai_reasoning_effort"] = "custom-high" + kwargs = ai_provider._openai_kwargs(temperature=0.3, max_tokens=1000) + + assert kwargs == { + "max_tokens": 1000, + "temperature": 0.3, + "reasoning_effort": "custom-high", + } + + stored["ai_reasoning_effort"] = "" + assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000) + + stored["ai_reasoning_effort"] = "custom-high" + stored["ai_provider"] = "openai_compat" + assert "reasoning_effort" not in ai_provider._openai_kwargs(temperature=None, max_tokens=1000) + + +def test_ai_settings_keep_provider_models_separate(monkeypatch): + stored = { + "ai_provider": "openai_compat", + "ai_model": "custom-api-model", + } + + def save(updates: dict) -> dict: + stored.update(updates) + return stored + + def clear(*keys: str) -> dict: + for key in keys: + stored.pop(key, None) + return stored + + monkeypatch.setattr(secrets_store, "load", lambda: stored) + monkeypatch.setattr(secrets_store, "save", save) + monkeypatch.setattr(secrets_store, "clear", clear) + monkeypatch.setattr(ai_provider, "ai_configured", lambda provider=None: True) + monkeypatch.setattr(settings, "ai_provider", "openai_compat") + monkeypatch.setattr(settings, "ai_base_url", "") + monkeypatch.setattr(settings, "ai_model", "") + monkeypatch.setattr(settings, "ai_codex_command", "codex") + monkeypatch.setattr(settings, "ai_codex_reasoning_effort", "") + monkeypatch.setattr(settings, "ai_user_agent", "") + + settings_api.save_ai_settings( + settings_api.AiSettingsIn( + provider="codex_cli", + model="gpt-5.6-sol", + codex_command="codex", + codex_reasoning_effort="high", + ) + ) + + assert stored["ai_model"] == "custom-api-model" + assert stored["ai_codex_model"] == "gpt-5.6-sol" + + settings_api.save_ai_settings( + settings_api.AiSettingsIn( + provider="openai", + base_url="https://api.openai.com/v1", + model="openai-model", + reasoning_effort="vendor-high", + ) + ) + + assert stored["ai_model"] == "openai-model" + assert stored["ai_reasoning_effort"] == "vendor-high" + assert stored["ai_codex_model"] == "gpt-5.6-sol" + + settings_api.save_ai_settings( + settings_api.AiSettingsIn( + provider="openai_compat", + base_url="https://example.com/v1", + model="new-custom-model", + ) + ) + + assert stored["ai_model"] == "new-custom-model" + assert stored["ai_reasoning_effort"] == "vendor-high" + assert stored["ai_codex_model"] == "gpt-5.6-sol" + + def test_codex_process_env_excludes_application_secrets(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "test-path") monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example") @@ -201,7 +317,7 @@ def test_codex_config_adapts_local_access_provider_for_docker(monkeypatch, tmp_p assert provider["supports_websockets"] is False -def test_codex_config_does_not_copy_provider_without_docker_opt_in(monkeypatch, tmp_path): +def test_codex_config_preserves_remote_provider_without_docker_rewrite(monkeypatch, tmp_path): monkeypatch.delenv("CODEX_DOCKER_HOST", raising=False) monkeypatch.setattr(ai_provider, "current_ai_model", lambda: "") monkeypatch.setattr(ai_provider, "current_codex_reasoning_effort", lambda: "") @@ -209,11 +325,13 @@ def test_codex_config_does_not_copy_provider_without_docker_opt_in(monkeypatch, ai_provider, "_read_codex_config", lambda: { - "model_provider": "codex_local_access", + "model_provider": "remote-api", + "openai_base_url": "https://builtin.example/v1", "model_providers": { - "codex_local_access": { - "base_url": "http://localhost:62678/v1", - "experimental_bearer_token": "must-not-leak", + "remote-api": { + "base_url": "https://custom.example/v1", + "wire_api": "responses", + "requires_openai_auth": True, } }, }, @@ -222,7 +340,11 @@ def test_codex_config_does_not_copy_provider_without_docker_opt_in(monkeypatch, ai_provider._write_compatible_codex_config(path) - text = path.read_text(encoding="utf-8") - assert "model_provider" not in text - assert "model_providers" not in text - assert "must-not-leak" not in text + with path.open("rb") as f: + config = tomllib.load(f) + assert config["model_provider"] == "remote-api" + assert config["openai_base_url"] == "https://builtin.example/v1" + provider = config["model_providers"]["remote-api"] + assert provider["base_url"] == "https://custom.example/v1" + assert provider["wire_api"] == "responses" + assert provider["requires_openai_auth"] is True diff --git a/backend/tests/test_auth_env_bootstrap.py b/backend/tests/test_auth_env_bootstrap.py new file mode 100644 index 0000000..efbb41c --- /dev/null +++ b/backend/tests/test_auth_env_bootstrap.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path +from types import ModuleType + +import pytest + +from app import config as app_config +from app.config import Settings + + +@pytest.fixture(autouse=True) +def isolated_auth_store( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> Iterator[tuple[ModuleType, Path, Path]]: + monkeypatch.setattr(app_config.settings, "data_dir", tmp_path) + from app.services import auth + + auth_path = tmp_path / "user_data" / "auth.json" + env_path = tmp_path / ".env" + monkeypatch.setattr(app_config, "_ENV_FILE", env_path) + monkeypatch.setattr(app_config.settings, "auth_password", "") + auth._sessions.clear() + auth._configured_cache = None + yield auth, auth_path, env_path + auth._sessions.clear() + auth._configured_cache = None + + +def test_bootstrap_recovers_compose_interpolated_password_from_raw_env( + isolated_auth_store: tuple[ModuleType, Path, Path], + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + auth, auth_path, env_path = isolated_auth_store + password = "pw${special}-secret" + env_path.write_text( + f"AUTH_PASSWORD={password}\nDATA_DIR={tmp_path}\n", + encoding="utf-8", + ) + configured = Settings(_env_file=env_path) + configured.auth_password = "pw-secret" # 模拟 Compose 将未定义的 ${special} 插值为空串 + monkeypatch.setattr(app_config, "settings", configured) + + assert auth.bootstrap_from_env() is True + assert auth_path.exists() + assert password not in auth_path.read_text(encoding="utf-8") + assert auth.verify_and_create_session(password) is not None + assert auth.verify_and_create_session("pw-secret") is None + + +def test_bootstrap_from_env_does_not_override_existing_password( + isolated_auth_store: tuple[ModuleType, Path, Path], +) -> None: + auth, auth_path, env_path = isolated_auth_store + auth.set_password("web-managed-secret") + before = auth_path.read_bytes() + env_path.write_text("AUTH_PASSWORD=replacement-secret\n", encoding="utf-8") + app_config.settings.auth_password = "replacement-secret" + + assert auth.bootstrap_from_env() is False + assert auth_path.read_bytes() == before + assert auth.verify_and_create_session("web-managed-secret") is not None + assert auth.verify_and_create_session("replacement-secret") is None diff --git a/backend/tests/test_config_env.py b/backend/tests/test_config_env.py new file mode 100644 index 0000000..1239d6e --- /dev/null +++ b/backend/tests/test_config_env.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from app.config import Settings + + +def test_settings_reads_server_and_auth_values_from_env( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + for name in ("HOST", "PORT", "LOG_LEVEL", "AUTH_PASSWORD"): + monkeypatch.delenv(name, raising=False) + env_path = tmp_path / ".env" + env_path.write_text( + "HOST=127.0.0.1\n" + "PORT=4318\n" + "LOG_LEVEL=DEBUG\n" + "AUTH_PASSWORD=config-secret\n", + encoding="utf-8", + ) + + configured = Settings(_env_file=env_path) + + assert configured.host == "127.0.0.1" + assert configured.port == 4318 + assert configured.log_level == "DEBUG" + assert configured.auth_password == "config-secret" diff --git a/backend/tests/test_pipeline_and_monitor_fixes.py b/backend/tests/test_pipeline_and_monitor_fixes.py index a3dd75e..3b3615c 100644 --- a/backend/tests/test_pipeline_and_monitor_fixes.py +++ b/backend/tests/test_pipeline_and_monitor_fixes.py @@ -8,7 +8,7 @@ import polars as pl import pytest from app.jobs import daily_pipeline -from app.services import pipeline_jobs, quote_service +from app.services import pipeline_jobs, preferences, quote_service from app.services.pipeline_jobs import JobStore from app.services.quote_service import QuoteService from app.strategy import monitor_rules @@ -16,12 +16,14 @@ from app.strategy.monitor import MonitorRuleEngine # ── JobStore 单飞 ──────────────────────────────────────────────────────── -def test_create_singleflight_dedupes_pending_window(tmp_path): +def test_create_singleflight_dedupes_pending_window(monkeypatch, tmp_path): """两次快速 create() 在 pending 窗口内应复用同一 job(is_new=False)。""" + monkeypatch.setattr(preferences, "load", lambda: {"data_source_job_timeout_s": 3600}) store = JobStore(store_dir=tmp_path / "jobs") jid1, new1 = store.create() assert new1 is True + assert store.get(jid1)["timeout_s"] == 3600 # 尚未 start(), job 仍是 pending —— 旧实现会在此另起新 job(并发双跑根因) jid2, new2 = store.create() @@ -35,10 +37,12 @@ def test_create_singleflight_dedupes_pending_window(tmp_path): assert new3 is False -def test_create_new_after_terminal(tmp_path): +def test_create_new_after_terminal(monkeypatch, tmp_path): """job 终态(succeed/fail)后, create() 应给出新 job。""" + monkeypatch.setattr(preferences, "load", lambda: {"data_source_long_job_timeout_s": 5400}) store = JobStore(store_dir=tmp_path / "jobs") - jid1, _ = store.create() + jid1, _ = store.create(long_running=True) + assert store.get(jid1)["timeout_s"] == 5400 store.start(jid1) store.succeed(jid1, {"ok": True}) diff --git a/dev.ps1 b/dev.ps1 index a89464f..1f9046c 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -18,8 +18,42 @@ param( $ErrorActionPreference = 'Stop' -# Port precedence: CLI arg > env var > default -if ($BackendPort -le 0) { $BackendPort = if ($env:BACKEND_PORT) { [int]$env:BACKEND_PORT } else { 3018 } } +$Root = Split-Path -Parent $MyInvocation.MyCommand.Path +$BackendDir = Join-Path $Root 'backend' +$FrontendDir = Join-Path $Root 'frontend' +$EnvFile = Join-Path $Root '.env' + +# Read only launcher-owned keys. Do not execute .env as PowerShell code. +function Read-DotEnvValue($Path, $Name) { + if (-not (Test-Path $Path)) { return $null } + $escaped = [Regex]::Escape($Name) + foreach ($line in Get-Content $Path) { + if ($line -match "^\s*$escaped\s*=\s*(.*?)\s*$") { + $value = $Matches[1].Trim() + $value = ($value -replace '\s+#.*$', '').Trim() + if ($value.Length -ge 2 -and + (($value.StartsWith('"') -and $value.EndsWith('"')) -or + ($value.StartsWith("'") -and $value.EndsWith("'")))) { + return $value.Substring(1, $value.Length - 2) + } + return $value + } + } + return $null +} + +$DotEnvHost = Read-DotEnvValue $EnvFile 'HOST' +$DotEnvPort = Read-DotEnvValue $EnvFile 'PORT' +$BindAddress = if ($env:HOST) { $env:HOST } elseif ($DotEnvHost) { $DotEnvHost } else { '0.0.0.0' } +$DisplayHost = if ($BindAddress -in @('0.0.0.0', '::')) { 'localhost' } else { $BindAddress } + +# Port precedence: CLI arg > BACKEND_PORT env > PORT env > .env PORT > default +if ($BackendPort -le 0) { + if ($env:BACKEND_PORT) { $BackendPort = [int]$env:BACKEND_PORT } + elseif ($env:PORT) { $BackendPort = [int]$env:PORT } + elseif ($DotEnvPort) { $BackendPort = [int]$DotEnvPort } + else { $BackendPort = 3018 } +} if ($FrontendPort -le 0) { $FrontendPort = if ($env:FRONTEND_PORT) { [int]$env:FRONTEND_PORT } else { 3011 } } # Force UTF-8 console output so child process logs aren't garbled @@ -28,10 +62,6 @@ try { $OutputEncoding = New-Object System.Text.UTF8Encoding $false } catch {} -$Root = Split-Path -Parent $MyInvocation.MyCommand.Path -$BackendDir = Join-Path $Root 'backend' -$FrontendDir = Join-Path $Root 'frontend' - function Log-Info($m) { Write-Host "[dev] $m" -ForegroundColor DarkGray } function Log-Ok ($m) { Write-Host "[dev] $m" -ForegroundColor Green } function Log-Warn($m) { Write-Host "[dev] $m" -ForegroundColor Yellow } @@ -113,15 +143,7 @@ Free-Port 'frontend' $FrontendPort # select Polars' rtcompat runtime before the backend starts. $BackendExtras = $env:BACKEND_EXTRAS if (-not (Test-Path Env:BACKEND_EXTRAS)) { - $envFile = Join-Path $Root '.env' - if (Test-Path $envFile) { - foreach ($line in Get-Content $envFile) { - if ($line -match '^\s*BACKEND_EXTRAS\s*=\s*(.*?)\s*$') { - $BackendExtras = $Matches[1] - break - } - } - } + $BackendExtras = Read-DotEnvValue $EnvFile 'BACKEND_EXTRAS' } $BackendExtraArgs = @() @@ -156,8 +178,8 @@ Write-Host '' Write-Host '+----------------------------------------------+' -ForegroundColor Blue Write-Host '| tickflow-stock-panel |' -ForegroundColor Blue Write-Host '| |' -ForegroundColor Blue -Write-Host "| backend http://localhost:$BackendPort" -ForegroundColor Blue -Write-Host "| frontend http://localhost:$FrontendPort" -ForegroundColor Blue +Write-Host "| backend http://${DisplayHost}:$BackendPort" -ForegroundColor Blue +Write-Host "| frontend http://${DisplayHost}:$FrontendPort" -ForegroundColor Blue Write-Host '| |' -ForegroundColor Blue Write-Host '| Ctrl-C closes both |' -ForegroundColor Blue Write-Host '+----------------------------------------------+' -ForegroundColor Blue @@ -170,7 +192,7 @@ $backendPidFile = [System.IO.Path]::GetTempFileName() $frontendPidFile = [System.IO.Path]::GetTempFileName() $backendJob = Start-Job -Name 'backend' -ScriptBlock { - param($pidFile, $dir, $port) + param($pidFile, $dir, $envFile, $bindAddress, $port) # Start-Job 开的是全新 powershell.exe 子进程, 不继承主进程的 UTF-8 设置, # 默认用系统 ANSI (中文 Windows = GBK/cp936) 解码后端 UTF-8 输出 → 中文乱码。 # 这里强制子进程用 UTF-8, 与 app/__init__.py 的 stdout/stderr 编码对齐。 @@ -179,18 +201,21 @@ $backendJob = Start-Job -Name 'backend' -ScriptBlock { $PID | Out-File -FilePath $pidFile -Encoding ascii -Force $env:PYTHONUNBUFFERED = '1' Set-Location $dir - & .\.venv\Scripts\python.exe -m uvicorn app.main:app --reload --host 0.0.0.0 --port $port 2>&1 -} -ArgumentList $backendPidFile, $BackendDir, $BackendPort + $envArgs = if (Test-Path $envFile) { @('--env-file', $envFile) } else { @() } + & .\.venv\Scripts\python.exe -m uvicorn app.main:app @envArgs --reload --host $bindAddress --port $port 2>&1 +} -ArgumentList $backendPidFile, $BackendDir, $EnvFile, $BindAddress, $BackendPort $frontendJob = Start-Job -Name 'frontend' -ScriptBlock { - param($pidFile, $dir, $port) + param($pidFile, $dir, $bindAddress, $backendPort, $port) # 同上: job 子进程默认 GBK, pnpm/前端工具链也是 UTF-8 输出, 需对齐。 [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false $OutputEncoding = New-Object System.Text.UTF8Encoding $false $PID | Out-File -FilePath $pidFile -Encoding ascii -Force Set-Location $dir - & pnpm dev --host 0.0.0.0 --port $port 2>&1 -} -ArgumentList $frontendPidFile, $FrontendDir, $FrontendPort + $env:BACKEND_HOST = $bindAddress + $env:BACKEND_PORT = [string]$backendPort + & pnpm dev --host $bindAddress --port $port 2>&1 +} -ArgumentList $frontendPidFile, $FrontendDir, $BindAddress, $BackendPort, $FrontendPort # Wait up to 5 seconds for the PID files to materialise function Read-JobPid($file) { diff --git a/dev.sh b/dev.sh index b8deca7..09f8a67 100755 --- a/dev.sh +++ b/dev.sh @@ -13,13 +13,48 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BACKEND_DIR="$ROOT/backend" FRONTEND_DIR="$ROOT/frontend" -BACKEND_PORT="${BACKEND_PORT:-3018}" + +# Read only the launcher-owned keys from .env. Do not source the whole file: +# .env is data, not a shell script, and may contain values that are unsafe or +# invalid as Bash syntax. Exported environment variables keep highest priority. +read_dotenv_value() { + local key="$1" + if [[ ! -f "$ROOT/.env" ]]; then + return 0 + fi + awk -v wanted="$key" ' + $0 ~ "^[[:space:]]*" wanted "[[:space:]]*=" { + sub(/^[^=]*=/, "") + sub(/[[:space:]]+#.*$/, "") + gsub(/^[[:space:]]+|[[:space:]]+$/, "") + if (($0 ~ /^".*"$/) || ($0 ~ /^\047.*\047$/)) { + $0 = substr($0, 2, length($0) - 2) + } + print + exit + } + ' "$ROOT/.env" +} + +ENV_HOST="$(read_dotenv_value HOST)" +ENV_PORT="$(read_dotenv_value PORT)" +BACKEND_HOST="${HOST:-${ENV_HOST:-0.0.0.0}}" +# Keep BACKEND_PORT as a backwards-compatible explicit override. +BACKEND_PORT="${BACKEND_PORT:-${PORT:-${ENV_PORT:-3018}}}" FRONTEND_PORT="${FRONTEND_PORT:-3011}" +UVICORN_ENV_ARGS=() +if [[ -f "$ROOT/.env" ]]; then + UVICORN_ENV_ARGS=(--env-file "$ROOT/.env") +fi +DISPLAY_HOST="$BACKEND_HOST" +if [[ "$DISPLAY_HOST" == "0.0.0.0" || "$DISPLAY_HOST" == "::" ]]; then + DISPLAY_HOST="localhost" +fi # Match Docker's BACKEND_EXTRAS behavior so old CPUs can select Polars' # rtcompat runtime before the backend starts. An exported value wins over .env. if [[ -z "${BACKEND_EXTRAS+x}" && -f "$ROOT/.env" ]]; then - BACKEND_EXTRAS="$(awk '/^[[:space:]]*BACKEND_EXTRAS[[:space:]]*=/ {sub(/^[^=]*=/, ""); gsub(/^[[:space:]]+|[[:space:]]+$/, ""); print; exit}' "$ROOT/.env")" + BACKEND_EXTRAS="$(read_dotenv_value BACKEND_EXTRAS)" fi BACKEND_EXTRAS="${BACKEND_EXTRAS:-}" BACKEND_EXTRA_ARGS=() @@ -129,8 +164,8 @@ echo echo -e "${BLUE}╭──────────────────────────────────────────────╮${NC}" echo -e "${BLUE}│${NC} ${GREEN}tickflow-stock-panel${NC} ${BLUE}│${NC}" echo -e "${BLUE}│${NC} ${BLUE}│${NC}" -echo -e "${BLUE}│${NC} backend ${YELLOW}http://localhost:$BACKEND_PORT${NC} ${BLUE}│${NC}" -echo -e "${BLUE}│${NC} frontend ${YELLOW}http://localhost:$FRONTEND_PORT${NC} ${BLUE}│${NC}" +echo -e "${BLUE}│${NC} backend ${YELLOW}http://$DISPLAY_HOST:$BACKEND_PORT${NC} ${BLUE}│${NC}" +echo -e "${BLUE}│${NC} frontend ${YELLOW}http://$DISPLAY_HOST:$FRONTEND_PORT${NC} ${BLUE}│${NC}" echo -e "${BLUE}│${NC} ${BLUE}│${NC}" echo -e "${BLUE}│${NC} Ctrl-C 同时关闭两端 ${BLUE}│${NC}" echo -e "${BLUE}╰──────────────────────────────────────────────╯${NC}" @@ -138,14 +173,16 @@ echo ( cd "$BACKEND_DIR" - uv run uvicorn app.main:app --reload --host 0.0.0.0 --port "$BACKEND_PORT" 2>&1 \ + uv run uvicorn app.main:app "${UVICORN_ENV_ARGS[@]}" --reload \ + --host "$BACKEND_HOST" --port "$BACKEND_PORT" 2>&1 \ | prefix_awk "$(printf "${BLUE}[backend ]${NC} ")" ) & PIDS+=("$!") ( cd "$FRONTEND_DIR" - pnpm dev --host 0.0.0.0 --port "$FRONTEND_PORT" 2>&1 \ + BACKEND_HOST="$BACKEND_HOST" BACKEND_PORT="$BACKEND_PORT" \ + pnpm dev --host "$BACKEND_HOST" --port "$FRONTEND_PORT" 2>&1 \ | prefix_awk "$(printf "${GREEN}[frontend]${NC} ")" ) & PIDS+=("$!") diff --git a/docker-compose.yml b/docker-compose.yml index f705549..c4ed028 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: CODEX_CLI_VERSION: ${CODEX_CLI_VERSION:-0.144.3} container_name: TickFlow_Stock_Panel ports: - - "${PORT:-3018}:3018" + - "${HOST:-0.0.0.0}:${PORT:-3018}:3018" extra_hosts: - "host.docker.internal:host-gateway" env_file: @@ -26,6 +26,8 @@ services: volumes: - ./data:/app/data - ./tiers.yaml:/app/tiers.yaml:ro + # 保留原始 dotenv 值供首次密码初始化读取,避免 Compose 展开密码中的 $VAR。 + - ./.env:/app/.env:ro # 复用主机 Codex 登录态;后端只读后复制到单次请求的临时 CODEX_HOME。 # Windows PowerShell/CMD 下 HOME 常未设置, 可通过 .env 里 CODEX_HOME_HOST 覆盖。 - ${CODEX_HOME_HOST:-${HOME}/.codex}:/root/.codex:ro diff --git a/docs/configuration.md b/docs/configuration.md index ca774cf..65546a8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -58,13 +58,13 @@ AI_DAILY_TOKEN_BUDGET=500000 # 每日 token 预算上限 ## 服务 ```ini -HOST=0.0.0.0 # 监听地址 -PORT=3018 # 服务端口 +HOST=0.0.0.0 # 开发服务监听地址 / Docker 主机绑定地址 +PORT=3018 # 开发后端端口 / Docker 主机映射端口 LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR ``` - `HOST`:`0.0.0.0` 监听所有网卡(容器/公网部署需要);仅本机用可设 `127.0.0.1` -- `PORT`:默认 `3018`,改端口后 Docker 映射、SSH 转发命令里的端口也要同步改 +- `PORT`:默认 `3018`;开发模式兼容显式的 `BACKEND_PORT` 覆盖,改端口后 SSH 转发命令也要同步改 - `LOG_LEVEL`:排查问题时改 `DEBUG` --- @@ -84,10 +84,11 @@ DATA_DIR=./data # Parquet / DuckDB 数据存储目录 ## 访问密码(公网部署) ```ini -AUTH_PASSWORD=你的密码 # 至少 6 位;仅首次生效,已设过则不覆盖 +AUTH_PASSWORD='你的密码' # 至少 6 位;仅首次生效,已设过则不覆盖 ``` 面板首次设置访问密码时,出于安全考虑**仅允许本机或内网访问**(防公网陌生人抢先设置锁死面板)。公网服务器部署可通过此环境变量预置首个密码。 +密码建议使用单引号包裹,Docker 启动时会把整个原始 `.env` 只读挂载到容器内 `/app/.env`,兼容已有的未加引号配置。容器可以读取其中的密钥但不能修改该文件,请保持主机文件权限为 `600` 并仅运行可信镜像。 详细步骤、SSH 转发方案、重置密码方法见 [deployment.md → 访问密码设置](./deployment.md#访问密码设置公网部署必读)。 diff --git a/docs/deploy-password.md b/docs/deploy-password.md index c828b31..9a1c053 100644 --- a/docs/deploy-password.md +++ b/docs/deploy-password.md @@ -16,7 +16,7 @@ ```bash # 编辑服务器上的 .env (通常在项目根目录或 backend/ 下) -AUTH_PASSWORD=你的密码 +AUTH_PASSWORD='你的密码' ``` 然后重启服务。启动时会自动: @@ -30,6 +30,7 @@ AUTH_PASSWORD=你的密码 - **密码至少 6 位**,否则会被跳过并记一条 warning 日志 - **仅在未设过密码时生效**。已设过密码后,改这里不会覆盖(避免重启时重置你在 UI 改的密码) +- 密码建议使用单引号包裹,避免 Docker Compose 插值 `$VAR`;启动时也会从只读挂载的原始 `.env` 初始化,兼容已有的未加引号配置 - `.env` 文件权限保持 `600`,**不要提交到 Git** - 明文密码只存在于 `.env` / 环境变量中,落盘的是哈希,安全性等同 `auth.json` diff --git a/docs/deployment.md b/docs/deployment.md index 8e006a5..af16e40 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -123,7 +123,7 @@ git pull 在 `.env` 文件(或 Docker / 系统环境变量)里设置 `AUTH_PASSWORD`: ```bash -AUTH_PASSWORD=你的密码 +AUTH_PASSWORD='你的密码' ``` 然后重启服务。启动时会自动: @@ -138,6 +138,7 @@ AUTH_PASSWORD=你的密码 - **密码至少 6 位**,否则会被跳过并记一条 warning 日志 - **仅在未设过密码时生效**。已设过密码后,改这里不会覆盖(避免重启时重置你在 UI 改的密码) +- 密码建议使用单引号包裹,避免 Docker Compose 插值 `$VAR`;启动时也会从只读挂载的原始 `.env` 初始化,兼容已有的未加引号配置 - `.env` 文件权限保持 `600`,**不要提交到 Git** - 明文密码只存在于 `.env` / 环境变量中,落盘的是哈希,安全性等同 `auth.json` diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 51ecb30..b9e72d2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,6 +1,6 @@ // 后端 API 客户端 — 全项目统一入口 // -// Dev:Vite 代理 /api 到 :3018 +// Dev: Vite 按启动脚本解析出的 BACKEND_HOST/BACKEND_PORT 代理 /api // Prod:同源(FastAPI 托管前端 dist) import { toast } from '@/components/Toast' @@ -922,6 +922,9 @@ export interface SettingsState { has_ai_key: boolean ai_configured?: boolean ai_model: string + ai_openai_model?: string + ai_reasoning_effort?: string + ai_codex_model?: string ai_codex_command?: string ai_codex_reasoning_effort?: string ai_user_agent: string @@ -1032,6 +1035,8 @@ export interface Preferences { minute_data_provider?: string realtime_data_provider?: string financial_data_provider?: string + data_source_job_timeout_s: number + data_source_long_job_timeout_s: number realtime_watchlist_symbols?: string[] realtime_pull_stock?: boolean realtime_pull_etf?: boolean @@ -1139,8 +1144,8 @@ export const api = { ), /** 保存 AI 配置 */ - saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; codex_command?: string; codex_reasoning_effort?: string; user_agent?: string }) => - request<{ ok: boolean; ai_provider?: string; ai_model?: string; ai_codex_command?: string; ai_codex_reasoning_effort?: string; ai_configured?: boolean }>('/api/settings/ai', { + saveAiSettings: (ai: { provider?: string; base_url?: string; api_key?: string; model?: string; reasoning_effort?: string; codex_command?: string; codex_reasoning_effort?: string; user_agent?: string }) => + request<{ ok: boolean; ai_provider?: string; ai_model?: string; ai_openai_model?: string; ai_reasoning_effort?: string; ai_codex_model?: string; ai_codex_command?: string; ai_codex_reasoning_effort?: string; ai_configured?: boolean }>('/api/settings/ai', { method: 'POST', body: JSON.stringify(ai), }), @@ -1189,6 +1194,17 @@ export const api = { '/api/settings/preferences/data-providers', { method: 'PUT', body: JSON.stringify(cfg) }, ), + updateDataSourceJobTimeouts: (dataSourceJobTimeoutS: number, dataSourceLongJobTimeoutS: number) => + request>( + '/api/settings/preferences/data-source-job-timeouts', + { + method: 'PUT', + body: JSON.stringify({ + data_source_job_timeout_s: dataSourceJobTimeoutS, + data_source_long_job_timeout_s: dataSourceLongJobTimeoutS, + }), + }, + ), updateMinuteSync: (enabled: boolean, days: number, segmentDays?: number) => request('/api/settings/preferences/minute-sync', { method: 'PUT', diff --git a/frontend/src/pages/settings/AI.tsx b/frontend/src/pages/settings/AI.tsx index ed7b9fb..76f302d 100644 --- a/frontend/src/pages/settings/AI.tsx +++ b/frontend/src/pages/settings/AI.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { Save, Loader2, Check, Wifi, WifiOff, Eye, EyeOff, Shield, @@ -14,10 +14,13 @@ 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 CODEX_PROVIDER = 'codex_cli' -const OPENAI_PROVIDER = 'openai_compat' +const OPENAI_PROVIDER = 'openai' +const OPENAI_COMPAT_PROVIDER = 'openai_compat' const CODEX_COMMAND = 'codex' const DEFAULT_CODEX_MODEL = 'gpt-5.6-sol' const DEFAULT_CODEX_REASONING_EFFORT = 'xhigh' +const DEFAULT_OPENAI_MODEL = 'gpt-5.5' +const DEFAULT_REASONING_EFFORT = 'high' const SAVED_CODEX_OPTION_VALUE = '__saved_codex_config__' const CODEX_REASONING_LABELS: Record = { high: '高', @@ -42,8 +45,11 @@ const codexModelLabel = (model?: string, effort?: string) => { return effortLabel ? `${modelLabel} · ${effortLabel}` : modelLabel } -const PRESETS: { label: string; provider?: string; url: string; model: string; codexCommand?: string; website: string; websiteLabel: string; description: string; custom?: boolean }[] = [ +type AiPreset = { label: string; provider?: string; url: string; model: string; codexCommand?: string; website: string; websiteLabel: string; description: string; custom?: boolean } + +const PRESETS: AiPreset[] = [ { label: '自定义', url: '', model: '', website: '', websiteLabel: '', description: '不自动填充任何配置,完全手动填写 API 地址、模型和密钥。', custom: true }, + { label: 'OpenAI', provider: OPENAI_PROVIDER, url: 'https://api.openai.com/v1', model: DEFAULT_OPENAI_MODEL, website: 'https://platform.openai.com/', websiteLabel: 'platform.openai.com', description: 'OpenAI 官方接口,可单独配置模型支持的推理强度。' }, { 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 兼容接口。' }, @@ -52,15 +58,23 @@ const PRESETS: { label: string; provider?: string; url: string; model: string; c { label: '炸鸡中转站', url: 'https://api.zhaji.dev/v1', model: 'gpt-5.5', website: 'https://api.zhaji.dev', websiteLabel: 'api.zhaji.dev', description: 'OpenAI 兼容中转服务,适合直接使用国际模型。' }, ] +const findPreset = (provider: string, baseUrl: string, codexCommand: string) => PRESETS.find(p => { + if (p.custom || (p.provider ?? OPENAI_COMPAT_PROVIDER) !== provider) return false + if (provider === OPENAI_PROVIDER) return true + return provider === CODEX_PROVIDER ? p.codexCommand === codexCommand : p.url === baseUrl +}) ?? PRESETS[0] + export function SettingsAIPanel() { const qc = useQueryClient() const settings = useSettings() const s = settings.data - const [provider, setProvider] = useState(OPENAI_PROVIDER) + const [provider, setProvider] = useState(OPENAI_COMPAT_PROVIDER) const [baseUrl, setBaseUrl] = useState('') const [apiKey, setApiKey] = useState('') const [model, setModel] = useState('') + const [reasoningEffort, setReasoningEffort] = useState(DEFAULT_REASONING_EFFORT) + const [codexModel, setCodexModel] = useState('') const [codexReasoningEffort, setCodexReasoningEffort] = useState('') const [codexCommand, setCodexCommand] = useState(CODEX_COMMAND) const [customUa, setCustomUa] = useState(false) @@ -70,15 +84,21 @@ export function SettingsAIPanel() { const [confirmClear, setConfirmClear] = useState(false) const [testing, setTesting] = useState(false) const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null) + const [selectedPresetLabel, setSelectedPresetLabel] = useState(PRESETS[0].label) + const directDrafts = useRef({ + custom: { baseUrl: '', model: '' }, + openai: { baseUrl: 'https://api.openai.com/v1', model: DEFAULT_OPENAI_MODEL }, + }) + const draftsInitialized = useRef(false) const isCodexProvider = provider === CODEX_PROVIDER + const isOpenAIProvider = provider === OPENAI_PROVIDER const savedCodexProvider = s?.ai_provider === CODEX_PROVIDER const configured = s?.ai_configured ?? (savedCodexProvider ? !!(s?.ai_codex_command ?? CODEX_COMMAND) : s?.has_ai_key) - // 选中的预设: 精确匹配 provider+url/codexCommand; 匹配不上时默认"自定义" - const matchedPreset = PRESETS.find(p => (p.provider ?? OPENAI_PROVIDER) === provider && (isCodexProvider ? p.codexCommand === codexCommand : p.url === baseUrl)) - const selectedPreset = matchedPreset ?? PRESETS.find(p => p.custom) - const savedCodexModel = savedCodexProvider ? (s?.ai_model ?? '') : '' - const savedCodexEffort = savedCodexProvider ? (s?.ai_codex_reasoning_effort ?? '') : '' + const selectedPreset = PRESETS.find(p => p.label === selectedPresetLabel) ?? PRESETS[0] + const configTitle = isCodexProvider ? 'Codex CLI 配置' : isOpenAIProvider ? 'OpenAI 配置' : selectedPreset.custom ? '自定义配置' : `${selectedPreset.label} 配置` + const savedCodexModel = s?.ai_codex_model ?? (savedCodexProvider ? (s?.ai_model ?? '') : '') + const savedCodexEffort = s?.ai_codex_reasoning_effort ?? '' const savedCodexOptionKnown = CODEX_MODEL_OPTIONS.some(option => option.model === savedCodexModel && option.effort === savedCodexEffort, ) @@ -96,7 +116,7 @@ export function SettingsAIPanel() { ? [savedCodexOption, ...CODEX_MODEL_OPTIONS] : CODEX_MODEL_OPTIONS const selectedCodexModelOption = codexModelOptions.find(option => - option.model === model && option.effort === codexReasoningEffort, + option.model === codexModel && option.effort === codexReasoningEffort, ) ?? CODEX_MODEL_OPTIONS[0] const codexModelSelectValue = selectedCodexModelOption.value const canSave = isCodexProvider ? true : !!baseUrl.trim() && !!model.trim() @@ -105,11 +125,26 @@ export function SettingsAIPanel() { if (!s) return // 未配置过 AI (无 api_key): 字段留空, 默认选中"自定义"预设, 不预填充后端默认值 const unconfigured = !s.has_ai_key && !s.ai_configured - const savedProvider = s.ai_provider ?? OPENAI_PROVIDER + const savedProvider = s.ai_provider ?? OPENAI_COMPAT_PROVIDER + const savedBaseUrl = unconfigured ? '' : (s.ai_base_url ?? '') + const savedOpenAIModel = unconfigured ? '' : (s.ai_openai_model ?? (savedProvider !== CODEX_PROVIDER ? s.ai_model : '') ?? '') + const savedPreset = unconfigured ? PRESETS[0] : findPreset(savedProvider, savedBaseUrl, s.ai_codex_command ?? CODEX_COMMAND) + if (!draftsInitialized.current) { + const officialOpenAI = PRESETS.find(p => p.provider === OPENAI_PROVIDER) + if (savedProvider === OPENAI_PROVIDER || (savedProvider === CODEX_PROVIDER && savedBaseUrl === officialOpenAI?.url)) { + directDrafts.current.openai = { baseUrl: savedBaseUrl, model: savedOpenAIModel } + } else if (findPreset(OPENAI_COMPAT_PROVIDER, savedBaseUrl, CODEX_COMMAND).custom) { + directDrafts.current.custom = { baseUrl: savedBaseUrl, model: savedOpenAIModel } + } + draftsInitialized.current = true + } setProvider(savedProvider) - setBaseUrl(unconfigured ? '' : (s.ai_base_url ?? '')) - setModel(unconfigured ? '' : (s.ai_model ?? '')) - setCodexReasoningEffort(unconfigured ? '' : (s.ai_codex_reasoning_effort ?? '')) + setSelectedPresetLabel(savedPreset.label) + setBaseUrl(savedBaseUrl) + setModel(savedOpenAIModel) + setReasoningEffort(s.ai_reasoning_effort ?? DEFAULT_REASONING_EFFORT) + setCodexModel(s.ai_codex_model ?? (savedProvider === CODEX_PROVIDER ? s.ai_model : '') ?? '') + setCodexReasoningEffort(s.ai_codex_reasoning_effort ?? '') setCodexCommand(s.ai_codex_command ?? CODEX_COMMAND) const ua = s.ai_user_agent ?? '' setCustomUa(!!ua) @@ -120,7 +155,8 @@ export function SettingsAIPanel() { provider, base_url: baseUrl, api_key: apiKey || undefined, - model, + model: isCodexProvider ? codexModel : model, + ...(isOpenAIProvider ? { reasoning_effort: reasoningEffort } : {}), codex_command: isCodexProvider ? CODEX_COMMAND : codexCommand, codex_reasoning_effort: isCodexProvider ? codexReasoningEffort : '', user_agent: customUa ? userAgent : '', @@ -135,7 +171,10 @@ export function SettingsAIPanel() { ...prev, ai_provider: result.ai_provider ?? provider, ai_base_url: baseUrl, - ai_model: result.ai_model ?? model, + ai_model: result.ai_model ?? (isCodexProvider ? codexModel : model), + ai_openai_model: result.ai_openai_model ?? model, + ai_reasoning_effort: result.ai_reasoning_effort ?? reasoningEffort, + ai_codex_model: result.ai_codex_model ?? codexModel, ai_codex_command: result.ai_codex_command ?? (isCodexProvider ? CODEX_COMMAND : codexCommand), ai_codex_reasoning_effort: result.ai_codex_reasoning_effort ?? (isCodexProvider ? codexReasoningEffort : ''), ai_configured: result.ai_configured ?? (isCodexProvider ? true : (apiKey ? true : prev.ai_configured)), @@ -153,18 +192,28 @@ export function SettingsAIPanel() { mutationFn: () => api.clearAiSettings(), onSuccess: () => { setConfirmClear(false) - setProvider(OPENAI_PROVIDER) + setProvider(OPENAI_COMPAT_PROVIDER) + setSelectedPresetLabel(PRESETS[0].label) setBaseUrl('') setApiKey('') setModel('') + setReasoningEffort(DEFAULT_REASONING_EFFORT) + setCodexModel('') setCodexReasoningEffort('') setCodexCommand(CODEX_COMMAND) + directDrafts.current = { + custom: { baseUrl: '', model: '' }, + openai: { baseUrl: 'https://api.openai.com/v1', model: DEFAULT_OPENAI_MODEL }, + } setTestResult(null) qc.setQueryData(QK.settings, prev => prev ? { ...prev, - ai_provider: OPENAI_PROVIDER, + ai_provider: OPENAI_COMPAT_PROVIDER, ai_base_url: '', ai_model: '', + ai_openai_model: '', + ai_reasoning_effort: DEFAULT_REASONING_EFFORT, + ai_codex_model: '', ai_codex_command: CODEX_COMMAND, ai_codex_reasoning_effort: '', has_ai_key: false, @@ -186,20 +235,42 @@ export function SettingsAIPanel() { 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]) => { + const handlePreset = (p: AiPreset) => { + setSelectedPresetLabel(p.label) if (p.custom) { - // 自定义: 清空所有自动填充字段, 由用户完全手动填写 - setProvider(OPENAI_PROVIDER) - setBaseUrl('') - setModel('') - setCodexReasoningEffort('') + setProvider(OPENAI_COMPAT_PROVIDER) + setBaseUrl(directDrafts.current.custom.baseUrl) + setModel(directDrafts.current.custom.model) return } - setProvider(p.provider ?? OPENAI_PROVIDER) - setBaseUrl(p.url) - setModel(p.model) - setCodexReasoningEffort(p.provider === CODEX_PROVIDER ? DEFAULT_CODEX_REASONING_EFFORT : '') - if (p.codexCommand) setCodexCommand(CODEX_COMMAND) + if (p.provider === CODEX_PROVIDER) { + setProvider(CODEX_PROVIDER) + setCodexModel(p.model) + setCodexReasoningEffort(DEFAULT_CODEX_REASONING_EFFORT) + setCodexCommand(CODEX_COMMAND) + return + } + const nextProvider = p.provider ?? OPENAI_COMPAT_PROVIDER + setProvider(nextProvider) + if (nextProvider === OPENAI_PROVIDER) { + setBaseUrl(directDrafts.current.openai.baseUrl) + setModel(directDrafts.current.openai.model) + } else { + setBaseUrl(p.url) + setModel(p.model) + } + } + + const handleBaseUrlChange = (value: string) => { + setBaseUrl(value) + if (selectedPreset.custom) directDrafts.current.custom.baseUrl = value + if (isOpenAIProvider) directDrafts.current.openai.baseUrl = value + } + + const handleModelChange = (value: string) => { + setModel(value) + if (selectedPreset.custom) directDrafts.current.custom.model = value + if (isOpenAIProvider) directDrafts.current.openai.model = value } const handleTest = async () => { @@ -280,7 +351,7 @@ export function SettingsAIPanel() { {isCodexProvider ? 'codex exec' : 'Chat Completions'} @@ -290,7 +361,7 @@ export function SettingsAIPanel() { >
{isCodexProvider ? ( -
+
{CODEX_COMMAND} @@ -305,7 +376,7 @@ export function SettingsAIPanel() { onChange={e => { const value = e.target.value const option = codexModelOptions.find(item => item.value === value) ?? CODEX_MODEL_OPTIONS[0] - setModel(option.model) + setCodexModel(option.model) setCodexReasoningEffort(option.effort) }} className={INPUT_CLS} @@ -318,15 +389,28 @@ export function SettingsAIPanel() {
) : ( <> -
+
- setBaseUrl(e.target.value)} placeholder="https://api.zhaji.dev/v1" className={INPUT_CLS} /> + handleBaseUrlChange(e.target.value)} placeholder="https://api.zhaji.dev/v1" className={INPUT_CLS} /> - setModel(e.target.value)} placeholder="gpt-5.6-sol" className={INPUT_CLS} /> + handleModelChange(e.target.value)} placeholder="gpt-5.6-sol" className={INPUT_CLS} />
+ {isOpenAIProvider && ( +
+
+ OpenAI 专属 +
+
+ + setReasoningEffort(e.target.value)} placeholder={DEFAULT_REASONING_EFFORT} className={INPUT_CLS} /> + +
+
+ )} +
diff --git a/frontend/src/pages/settings/DataSources.tsx b/frontend/src/pages/settings/DataSources.tsx index 59d40b6..bc8f76c 100644 --- a/frontend/src/pages/settings/DataSources.tsx +++ b/frontend/src/pages/settings/DataSources.tsx @@ -1,8 +1,8 @@ import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' -import { Check, Database, Plus, RefreshCw, Zap, FileWarning } from 'lucide-react' -import { api, type DataSourceItem, type PluginDataSourceItem } from '@/lib/api' +import { Check, Clock3, Database, Plus, RefreshCw, Zap, FileWarning } from 'lucide-react' +import { api, type DataSourceItem, type PluginDataSourceItem, type Preferences } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { usePreferences } from '@/lib/useSharedQueries' import { toast } from '@/components/Toast' @@ -16,12 +16,65 @@ const DATASET_LABEL: Record = { minute: '分钟', } +type TimeoutUnit = 'second' | 'minute' | 'hour' + +const TIMEOUT_UNIT_SECONDS: Record = { + second: 1, + minute: 60, + hour: 3600, +} + +function preferredTimeoutUnit(seconds: number): TimeoutUnit { + if (seconds >= 3600 && seconds % 1800 === 0) return 'hour' + if (seconds % 60 === 0) return 'minute' + return 'second' +} + +function formatTimeoutValue(seconds: number, unit: TimeoutUnit): string { + if (!Number.isFinite(seconds)) return '' + const value = seconds / TIMEOUT_UNIT_SECONDS[unit] + return String(Number(value.toFixed(4))) +} + export function SettingsDataSourcesPanel() { const qc = useQueryClient() const prefs = usePreferences() const sources = useQuery({ queryKey: QK.dataSources, queryFn: api.dataSources }) const [selected, setSelected] = useState('tickflow') // 当前在右侧编辑的源 name const [confirmDelete, setConfirmDelete] = useState(null) + const [timeoutDraft, setTimeoutDraft] = useState<{ regular: string; long: string } | null>(null) + const [regularUnitOverride, setRegularUnitOverride] = useState(null) + const [longUnitOverride, setLongUnitOverride] = useState(null) + + const currentRegularTimeout = prefs.data?.data_source_job_timeout_s ?? 1200 + const currentLongTimeout = prefs.data?.data_source_long_job_timeout_s ?? 1800 + const regularTimeoutUnit = regularUnitOverride ?? preferredTimeoutUnit(currentRegularTimeout) + const longTimeoutUnit = longUnitOverride ?? preferredTimeoutUnit(currentLongTimeout) + const regularTimeoutInput = timeoutDraft?.regular + ?? formatTimeoutValue(currentRegularTimeout, regularTimeoutUnit) + const longTimeoutInput = timeoutDraft?.long + ?? formatTimeoutValue(currentLongTimeout, longTimeoutUnit) + const regularInputNumber = Number(regularTimeoutInput) + const longInputNumber = Number(longTimeoutInput) + const regularTimeout = Math.round(regularInputNumber * TIMEOUT_UNIT_SECONDS[regularTimeoutUnit]) + const longTimeout = Math.round(longInputNumber * TIMEOUT_UNIT_SECONDS[longTimeoutUnit]) + const timeoutValuesValid = Number.isFinite(regularInputNumber) && regularInputNumber > 0 + && Number.isFinite(longInputNumber) && longInputNumber > 0 + && regularTimeout >= 60 && longTimeout >= 60 + const timeoutValuesChanged = regularTimeout !== currentRegularTimeout + || longTimeout !== currentLongTimeout + + const saveJobTimeouts = useMutation({ + mutationFn: () => api.updateDataSourceJobTimeouts(regularTimeout, longTimeout), + onSuccess: (saved) => { + qc.setQueryData(QK.preferences, current => ( + current ? { ...current, ...saved } : current + )) + setTimeoutDraft(null) + toast('任务超时配置已保存', 'success') + }, + onError: (e: Error) => toast(`保存失败: ${e.message}`, 'error'), + }) const reload = useMutation({ mutationFn: api.reloadDataSources, @@ -313,6 +366,93 @@ export function SettingsDataSourcesPanel() {
+
+
+
+ +
+

数据任务超时

+

+ 后台任务运行超过对应时间后将判定为疑似卡死。保存时自动换算为秒,修改后对新建任务生效。 +

+
+
+ +
+ +
+ + + +
+
+ {/* ===== 下方: 编辑区 ===== */} { - proxy.on('proxyReq', (_proxyReq, req) => { - if (req.url?.includes('/stream')) { - _proxyReq.setHeader('Accept', 'text/event-stream'); - _proxyReq.setHeader('Cache-Control', 'no-cache'); - _proxyReq.setHeader('Connection', 'keep-alive'); - } - }); - }, - }, - '/health': 'http://localhost:3018', - }, - }, - build: { - outDir: 'dist', - sourcemap: false, - rollupOptions: { - output: { - // 把重型图表库拆到独立 chunk, 避免打进主包 + 让页面按需加载。 - // 用函数形式按 node_modules 路径匹配, 比对象形式更可靠。 - manualChunks(id) { - if (id.includes('node_modules')) { - if (id.includes('echarts')) - return 'echarts'; - if (id.includes('lightweight-charts')) - return 'lightweight-charts'; - } - }, - }, - }, - }, -}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 554d93d..51d5a55 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -2,6 +2,11 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'node:path' +const backendHost = process.env.BACKEND_HOST || '127.0.0.1' +const proxyHost = ['0.0.0.0', '::'].includes(backendHost) ? '127.0.0.1' : backendHost +const backendPort = process.env.BACKEND_PORT || '3018' +const backendTarget = `http://${proxyHost}:${backendPort}` + export default defineConfig({ plugins: [react()], resolve: { @@ -10,12 +15,12 @@ export default defineConfig({ }, }, server: { - host: '0.0.0.0', // 允许局域网访问 + host: '0.0.0.0', // dev.sh / dev.ps1 会用 CLI --host 覆盖 port: 3011, proxy: { - // dev 时 /api 转发到 FastAPI + // dev 时 /api 转发到与启动脚本相同的 FastAPI 地址 '/api': { - target: 'http://localhost:3018', + target: backendTarget, // SSE 端点需要禁用缓冲 configure: (proxy) => { proxy.on('proxyReq', (_proxyReq, req) => { @@ -27,7 +32,7 @@ export default defineConfig({ }) }, }, - '/health': 'http://localhost:3018', + '/health': backendTarget, }, }, build: {