mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
Merge branch 'pr-169' into v0.2
This commit is contained in:
+1
-1
@@ -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.
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
+57
-17
@@ -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:
|
||||
"""返回自选列表列配置。"""
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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+=("$!")
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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#访问密码设置公网部署必读)。
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
+2
-1
@@ -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`
|
||||
|
||||
|
||||
+19
-3
@@ -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<Pick<Preferences, 'data_source_job_timeout_s' | 'data_source_long_job_timeout_s'>>(
|
||||
'/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<Preferences>('/api/settings/preferences/minute-sync', {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<SettingsState>(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() {
|
||||
|
||||
<Card
|
||||
icon={Settings2}
|
||||
title="自定义配置"
|
||||
title={configTitle}
|
||||
right={
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] text-muted/60" title={isCodexProvider ? 'Use local Codex CLI via codex exec' : 'Use OpenAI-compatible Chat Completions API'}>
|
||||
<span className="rounded-full border border-border/40 bg-base/50 px-1.5 py-px font-mono">{isCodexProvider ? 'codex exec' : 'Chat Completions'}</span>
|
||||
@@ -290,7 +361,7 @@ export function SettingsAIPanel() {
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{isCodexProvider ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="CLI 命令" hint="固定使用默认 codex 命令, 由后端自动解析本机 Codex Desktop/CLI, 不支持自定义可执行路径。">
|
||||
<div className={`${INPUT_CLS} flex items-center text-muted/80 select-none`} aria-label="Codex CLI command">
|
||||
{CODEX_COMMAND}
|
||||
@@ -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() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="API 地址">
|
||||
<input type="text" value={baseUrl} onChange={e => setBaseUrl(e.target.value)} placeholder="https://api.zhaji.dev/v1" className={INPUT_CLS} />
|
||||
<input type="text" value={baseUrl} onChange={e => handleBaseUrlChange(e.target.value)} placeholder="https://api.zhaji.dev/v1" className={INPUT_CLS} />
|
||||
</Field>
|
||||
<Field label="模型">
|
||||
<input type="text" value={model} onChange={e => setModel(e.target.value)} placeholder="gpt-5.6-sol" className={INPUT_CLS} />
|
||||
<input type="text" value={model} onChange={e => handleModelChange(e.target.value)} placeholder="gpt-5.6-sol" className={INPUT_CLS} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{isOpenAIProvider && (
|
||||
<div className="rounded-lg border border-accent/15 bg-accent/[0.03] p-3">
|
||||
<div className="mb-2.5">
|
||||
<span className="rounded-full bg-accent/10 px-2 py-0.5 text-[10px] font-medium text-accent">OpenAI 专属</span>
|
||||
</div>
|
||||
<div className="max-w-xs">
|
||||
<Field label="推理强度">
|
||||
<input type="text" value={reasoningEffort} onChange={e => setReasoningEffort(e.target.value)} placeholder={DEFAULT_REASONING_EFFORT} className={INPUT_CLS} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label="API Key">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
|
||||
@@ -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<string, string> = {
|
||||
minute: '分钟',
|
||||
}
|
||||
|
||||
type TimeoutUnit = 'second' | 'minute' | 'hour'
|
||||
|
||||
const TIMEOUT_UNIT_SECONDS: Record<TimeoutUnit, number> = {
|
||||
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<string>('tickflow') // 当前在右侧编辑的源 name
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
const [timeoutDraft, setTimeoutDraft] = useState<{ regular: string; long: string } | null>(null)
|
||||
const [regularUnitOverride, setRegularUnitOverride] = useState<TimeoutUnit | null>(null)
|
||||
const [longUnitOverride, setLongUnitOverride] = useState<TimeoutUnit | null>(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<Preferences>(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() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-card border border-border bg-surface p-5">
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock3 className="h-4 w-4 text-secondary mt-0.5" />
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-foreground">数据任务超时</h2>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
||||
后台任务运行超过对应时间后将判定为疑似卡死。保存时自动换算为秒,修改后对新建任务生效。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => saveJobTimeouts.mutate()}
|
||||
disabled={!timeoutValuesValid || !timeoutValuesChanged || saveJobTimeouts.isPending}
|
||||
className="shrink-0 px-3 py-1.5 rounded-btn bg-accent text-white text-xs font-medium hover:bg-accent/90 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{saveJobTimeouts.isPending ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<label className="rounded-lg border border-border/60 bg-elevated/20 px-3.5 py-3">
|
||||
<span className="block text-xs font-medium text-foreground mb-1">普通任务超时</span>
|
||||
<span className="block text-[10px] text-muted mb-2">日 K 管道、扩展、修正与重算任务</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={regularTimeoutUnit === 'second' ? 60 : regularTimeoutUnit === 'minute' ? 1 : 1 / 60}
|
||||
step={regularTimeoutUnit === 'second' ? 60 : regularTimeoutUnit === 'minute' ? 1 : 0.5}
|
||||
value={regularTimeoutInput}
|
||||
onChange={e => setTimeoutDraft({ regular: e.target.value, long: longTimeoutInput })}
|
||||
className="w-full rounded-btn border border-border bg-base px-2.5 py-1.5 text-sm text-foreground font-mono outline-none focus:border-accent"
|
||||
/>
|
||||
<select
|
||||
value={regularTimeoutUnit}
|
||||
onChange={e => {
|
||||
const nextUnit = e.target.value as TimeoutUnit
|
||||
setTimeoutDraft({
|
||||
regular: formatTimeoutValue(regularTimeout, nextUnit),
|
||||
long: longTimeoutInput,
|
||||
})
|
||||
setRegularUnitOverride(nextUnit)
|
||||
}}
|
||||
className="w-20 shrink-0 rounded-btn border border-border bg-base px-2 py-1.5 text-xs text-foreground outline-none focus:border-accent"
|
||||
>
|
||||
<option value="second">秒</option>
|
||||
<option value="minute">分钟</option>
|
||||
<option value="hour">小时</option>
|
||||
</select>
|
||||
</div>
|
||||
<span className="block text-[10px] text-muted/60 mt-1.5">默认 20 分钟,最小 1 分钟</span>
|
||||
</label>
|
||||
|
||||
<label className="rounded-lg border border-border/60 bg-elevated/20 px-3.5 py-3">
|
||||
<span className="block text-xs font-medium text-foreground mb-1">长任务超时</span>
|
||||
<span className="block text-[10px] text-muted mb-2">分钟 K 全市场同步任务</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={longTimeoutUnit === 'second' ? 60 : longTimeoutUnit === 'minute' ? 1 : 1 / 60}
|
||||
step={longTimeoutUnit === 'second' ? 60 : longTimeoutUnit === 'minute' ? 1 : 0.5}
|
||||
value={longTimeoutInput}
|
||||
onChange={e => setTimeoutDraft({ regular: regularTimeoutInput, long: e.target.value })}
|
||||
className="w-full rounded-btn border border-border bg-base px-2.5 py-1.5 text-sm text-foreground font-mono outline-none focus:border-accent"
|
||||
/>
|
||||
<select
|
||||
value={longTimeoutUnit}
|
||||
onChange={e => {
|
||||
const nextUnit = e.target.value as TimeoutUnit
|
||||
setTimeoutDraft({
|
||||
regular: regularTimeoutInput,
|
||||
long: formatTimeoutValue(longTimeout, nextUnit),
|
||||
})
|
||||
setLongUnitOverride(nextUnit)
|
||||
}}
|
||||
className="w-20 shrink-0 rounded-btn border border-border bg-base px-2 py-1.5 text-xs text-foreground outline-none focus:border-accent"
|
||||
>
|
||||
<option value="second">秒</option>
|
||||
<option value="minute">分钟</option>
|
||||
<option value="hour">小时</option>
|
||||
</select>
|
||||
</div>
|
||||
<span className="block text-[10px] text-muted/60 mt-1.5">默认 30 分钟,最小 1 分钟</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ===== 下方: 编辑区 ===== */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"composite": true
|
||||
"composite": true,
|
||||
"emitDeclarationOnly": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0', // 允许局域网访问
|
||||
port: 3011,
|
||||
proxy: {
|
||||
// dev 时 /api 转发到 FastAPI
|
||||
'/api': {
|
||||
target: 'http://localhost:3018',
|
||||
// SSE 端点需要禁用缓冲
|
||||
configure: (proxy) => {
|
||||
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';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user