1.23
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
"""Custom OpenAI-compatible base_url / model resolution."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from web.ai_providers import (
|
||||
DEEPSEEK_BASE_URL,
|
||||
DEEPSEEK_MODEL,
|
||||
_clamp_max_tokens,
|
||||
_normalize_base_url,
|
||||
resolve_provider,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_base_url_defaults_and_strips() -> None:
|
||||
assert _normalize_base_url(None) == DEEPSEEK_BASE_URL
|
||||
assert _normalize_base_url("") == DEEPSEEK_BASE_URL
|
||||
assert _normalize_base_url("https://token.sensenova.cn/v1/") == (
|
||||
"https://token.sensenova.cn/v1"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_base_url_rejects_non_http() -> None:
|
||||
with pytest.raises(ValueError, match="http://"):
|
||||
_normalize_base_url("token.sensenova.cn/v1")
|
||||
|
||||
|
||||
def test_resolve_provider_custom_sensenova_url() -> None:
|
||||
resolved = resolve_provider(
|
||||
"deepseek",
|
||||
"sk-test-key",
|
||||
base_url="https://token.sensenova.cn/v1",
|
||||
model="deepseek-v4-flash",
|
||||
)
|
||||
assert resolved.provider == "deepseek"
|
||||
assert resolved.base_url == "https://token.sensenova.cn/v1"
|
||||
assert resolved.model == "deepseek-v4-flash"
|
||||
assert resolved.label == "SenseNova"
|
||||
assert resolved.api_key == "sk-test-key"
|
||||
|
||||
|
||||
def test_resolve_provider_defaults_when_url_model_empty() -> None:
|
||||
resolved = resolve_provider("deepseek", "sk-test-key", base_url="", model="")
|
||||
assert resolved.base_url == DEEPSEEK_BASE_URL
|
||||
assert resolved.model == DEEPSEEK_MODEL
|
||||
assert resolved.label == "DeepSeek"
|
||||
|
||||
|
||||
def test_clamp_max_tokens_sensenova() -> None:
|
||||
url = "https://token.sensenova.cn/v1"
|
||||
assert _clamp_max_tokens(url, "deepseek-v4-flash", 4096) == 4096
|
||||
assert _clamp_max_tokens(url, "deepseek-v4-flash", 999_999) == 65_536
|
||||
assert _clamp_max_tokens(url, "glm-5.2", 999_999) == 131_072
|
||||
assert _clamp_max_tokens(DEEPSEEK_BASE_URL, DEEPSEEK_MODEL, 999_999) == 999_999
|
||||
+12
-2
@@ -109,12 +109,18 @@ def analyze_training(
|
||||
*,
|
||||
provider: str,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
model: str | None = None,
|
||||
symbol: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
answer_parts: list[str] = []
|
||||
meta: dict[str, Any] = {}
|
||||
for event in analyze_training_stream(
|
||||
provider=provider, api_key=api_key, symbol=symbol
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
symbol=symbol,
|
||||
):
|
||||
if event.get("type") == "meta":
|
||||
meta = event
|
||||
@@ -141,6 +147,8 @@ def analyze_training_stream(
|
||||
*,
|
||||
provider: str,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
model: str | None = None,
|
||||
symbol: str | None = None,
|
||||
):
|
||||
"""Yield SSE-ready event dicts: meta / delta / done / error."""
|
||||
@@ -149,7 +157,9 @@ def analyze_training_stream(
|
||||
try:
|
||||
snapshot = build_training_snapshot(symbol)
|
||||
prior = load_prior_analyses(snapshot["symbol"], snapshot["timeframe"])
|
||||
resolved = resolve_provider(provider, api_key)
|
||||
resolved = resolve_provider(
|
||||
provider, api_key, base_url=base_url, model=model
|
||||
)
|
||||
except Exception as exc:
|
||||
yield {"type": "error", "message": str(exc)}
|
||||
return
|
||||
|
||||
+68
-11
@@ -1,7 +1,9 @@
|
||||
"""AI provider resolution for training analysis.
|
||||
|
||||
Supports:
|
||||
- deepseek: fixed model deepseek-v4-flash @ https://api.deepseek.com (user API key)
|
||||
- deepseek / OpenAI-compatible: user API key + configurable base_url / model
|
||||
(defaults: deepseek-v4-flash @ https://api.deepseek.com;也可填
|
||||
https://token.sensenova.cn/v1 等兼容网关)
|
||||
- openclaw: local QClaw gateway Agent (token from ~/.qclaw/openclaw.json)
|
||||
- openclaw_wb: WorkBuddy / copilot.tencent.com (local session token)
|
||||
|
||||
@@ -20,6 +22,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
DEEPSEEK_MODEL = "deepseek-v4-flash"
|
||||
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
|
||||
# SenseNova OpenAI-compatible gateway caps (see PA_Agent deepseek_client).
|
||||
_SENSENOVA_GLM_MAX_OUTPUT_TOKENS = 131_072
|
||||
_SENSENOVA_DEFAULT_MAX_OUTPUT_TOKENS = 65_536
|
||||
|
||||
_QCLAW_CONFIG_CANDIDATES = (
|
||||
Path.home() / ".qclaw" / "openclaw.json",
|
||||
@@ -150,10 +155,15 @@ def provider_status() -> dict[str, Any]:
|
||||
"providers": [
|
||||
{
|
||||
"id": "deepseek",
|
||||
"label": "DeepSeek (deepseek-v4-flash)",
|
||||
"label": "OpenAI 兼容(DeepSeek / SenseNova 等)",
|
||||
"available": True,
|
||||
"needs_user_key": True,
|
||||
"hint": "固定模型 deepseek-v4-flash · https://api.deepseek.com",
|
||||
"hint": (
|
||||
"可自定义 Base URL + 模型。默认 DeepSeek;"
|
||||
"SenseNova 示例:https://token.sensenova.cn/v1"
|
||||
),
|
||||
"default_base_url": DEEPSEEK_BASE_URL,
|
||||
"default_model": DEEPSEEK_MODEL,
|
||||
},
|
||||
{
|
||||
"id": "openclaw",
|
||||
@@ -187,7 +197,48 @@ def _alias_provider_from_key(api_key: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def resolve_provider(provider: str, api_key: str | None = None) -> ResolvedProvider:
|
||||
def _normalize_base_url(base_url: str | None) -> str:
|
||||
url = (base_url or "").strip()
|
||||
if not url:
|
||||
return DEEPSEEK_BASE_URL
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
raise ValueError(
|
||||
f"Base URL 必须以 http:// 或 https:// 开头(当前: {url})。"
|
||||
"示例:https://api.deepseek.com 或 https://token.sensenova.cn/v1"
|
||||
)
|
||||
return url.rstrip("/")
|
||||
|
||||
|
||||
def _normalize_model(model: str | None) -> str:
|
||||
name = (model or "").strip()
|
||||
return name or DEEPSEEK_MODEL
|
||||
|
||||
|
||||
def _is_sensenova(base_url: str) -> bool:
|
||||
return "sensenova.cn" in (base_url or "").lower()
|
||||
|
||||
|
||||
def _clamp_max_tokens(base_url: str, model: str, max_tokens: int) -> int:
|
||||
"""Avoid 400 from gateway-specific max_tokens caps (e.g. SenseNova)."""
|
||||
if max_tokens <= 0:
|
||||
return max_tokens
|
||||
if _is_sensenova(base_url):
|
||||
cap = (
|
||||
_SENSENOVA_GLM_MAX_OUTPUT_TOKENS
|
||||
if "glm" in (model or "").lower()
|
||||
else _SENSENOVA_DEFAULT_MAX_OUTPUT_TOKENS
|
||||
)
|
||||
return min(max_tokens, cap)
|
||||
return max_tokens
|
||||
|
||||
|
||||
def resolve_provider(
|
||||
provider: str,
|
||||
api_key: str | None = None,
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> ResolvedProvider:
|
||||
pid = (provider or "deepseek").strip().lower()
|
||||
key = (api_key or "").strip()
|
||||
|
||||
@@ -202,13 +253,18 @@ def resolve_provider(provider: str, api_key: str | None = None) -> ResolvedProvi
|
||||
|
||||
if pid == "deepseek":
|
||||
if not key:
|
||||
raise ValueError("请填写 DeepSeek API Key")
|
||||
raise ValueError("请填写 API Key")
|
||||
resolved_url = _normalize_base_url(base_url)
|
||||
resolved_model = _normalize_model(model)
|
||||
label = "SenseNova" if _is_sensenova(resolved_url) else "OpenAI 兼容"
|
||||
if "deepseek.com" in resolved_url.lower():
|
||||
label = "DeepSeek"
|
||||
return ResolvedProvider(
|
||||
provider="deepseek",
|
||||
model=DEEPSEEK_MODEL,
|
||||
base_url=DEEPSEEK_BASE_URL,
|
||||
model=resolved_model,
|
||||
base_url=resolved_url,
|
||||
api_key=key,
|
||||
label="DeepSeek",
|
||||
label=label,
|
||||
needs_user_key=True,
|
||||
)
|
||||
|
||||
@@ -226,10 +282,10 @@ def resolve_provider(provider: str, api_key: str | None = None) -> ResolvedProvi
|
||||
f"已读取 QClaw 配置,但暂时无法连接 Gateway({base})。"
|
||||
"请确认 QClaw 已打开;若刚启动请稍等几秒后重试。"
|
||||
)
|
||||
model = str(_pick_openclaw_model(base, token) or _OPENCLAW_MODEL)
|
||||
resolved_model = str(_pick_openclaw_model(base, token) or _OPENCLAW_MODEL)
|
||||
return ResolvedProvider(
|
||||
provider="openclaw",
|
||||
model=model,
|
||||
model=resolved_model,
|
||||
base_url=base,
|
||||
api_key=token,
|
||||
label="openclaw (QClaw)",
|
||||
@@ -283,10 +339,11 @@ def stream_chat_completions(
|
||||
import urllib.request
|
||||
|
||||
url = resolved.base_url.rstrip("/") + "/chat/completions"
|
||||
capped = _clamp_max_tokens(resolved.base_url, resolved.model, max_tokens)
|
||||
payload: dict[str, Any] = {
|
||||
"model": resolved.model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"max_tokens": capped,
|
||||
"stream": True,
|
||||
}
|
||||
if resolved.provider == "openclaw":
|
||||
|
||||
+22
-2
@@ -83,6 +83,8 @@ class SettingsRequest(BaseModel):
|
||||
debug_mode: bool | None = None
|
||||
ai_provider: str | None = None
|
||||
ai_api_key: str | None = None
|
||||
ai_base_url: str | None = None
|
||||
ai_model: str | None = None
|
||||
bt_commission_pct: float | None = None
|
||||
bt_slippage_pct: float | None = None
|
||||
|
||||
@@ -90,6 +92,8 @@ class SettingsRequest(BaseModel):
|
||||
class AnalyzeTrainingRequest(BaseModel):
|
||||
provider: str | None = None
|
||||
api_key: str | None = None
|
||||
base_url: str | None = None
|
||||
model: str | None = None
|
||||
symbol: str | None = None
|
||||
|
||||
|
||||
@@ -349,6 +353,10 @@ def api_put_settings(req: SettingsRequest) -> dict[str, Any]:
|
||||
payload["ai_provider"] = req.ai_provider
|
||||
if req.ai_api_key is not None:
|
||||
payload["ai_api_key"] = req.ai_api_key
|
||||
if req.ai_base_url is not None:
|
||||
payload["ai_base_url"] = req.ai_base_url
|
||||
if req.ai_model is not None:
|
||||
payload["ai_model"] = req.ai_model
|
||||
if req.bt_commission_pct is not None:
|
||||
payload["bt_commission_pct"] = req.bt_commission_pct
|
||||
if req.bt_slippage_pct is not None:
|
||||
@@ -386,8 +394,10 @@ def api_config() -> dict[str, Any]:
|
||||
"last_strategy_file": strat_ctx["last_strategy_file"],
|
||||
"strategy_file": strat_ctx["strategy_file"],
|
||||
"debug_mode": load_settings().get("debug_mode", False),
|
||||
"ai_provider": load_settings().get("ai_provider", "deepseek"),
|
||||
"ai_api_key": load_settings().get("ai_api_key", ""),
|
||||
"ai_provider": settings.get("ai_provider", "deepseek"),
|
||||
"ai_api_key": settings.get("ai_api_key", ""),
|
||||
"ai_base_url": settings.get("ai_base_url", "https://api.deepseek.com"),
|
||||
"ai_model": settings.get("ai_model", "deepseek-v4-flash"),
|
||||
"bt_commission_pct": settings.get("bt_commission_pct", 0.02),
|
||||
"bt_slippage_pct": settings.get("bt_slippage_pct", 0.01),
|
||||
"server_log": snap["server_log"],
|
||||
@@ -415,6 +425,12 @@ def api_ai_analyze_training(req: AnalyzeTrainingRequest):
|
||||
settings = load_settings()
|
||||
raw_key = req.api_key if req.api_key is not None else settings.get("ai_api_key") or ""
|
||||
key_lower = str(raw_key).strip().lower()
|
||||
base_url = (
|
||||
req.base_url
|
||||
if req.base_url is not None
|
||||
else settings.get("ai_base_url") or ""
|
||||
)
|
||||
model = req.model if req.model is not None else settings.get("ai_model") or ""
|
||||
|
||||
# openclaw_wb 必须先于 openclaw 判断
|
||||
if key_lower in ("openclaw_wb",) or key_lower.startswith("openclaw_wb/"):
|
||||
@@ -427,6 +443,8 @@ def api_ai_analyze_training(req: AnalyzeTrainingRequest):
|
||||
save_settings({
|
||||
"ai_provider": provider,
|
||||
"ai_api_key": str(raw_key).strip(),
|
||||
"ai_base_url": str(base_url).strip(),
|
||||
"ai_model": str(model).strip(),
|
||||
})
|
||||
|
||||
def event_gen():
|
||||
@@ -434,6 +452,8 @@ def api_ai_analyze_training(req: AnalyzeTrainingRequest):
|
||||
for event in analyze_training_stream(
|
||||
provider=provider,
|
||||
api_key=str(raw_key).strip() or None,
|
||||
base_url=str(base_url).strip() or None,
|
||||
model=str(model).strip() or None,
|
||||
symbol=req.symbol,
|
||||
):
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
|
||||
+112
-20
@@ -1,8 +1,97 @@
|
||||
"""Native file picker for local training UI."""
|
||||
"""Native file picker for local training UI.
|
||||
|
||||
Windows 优先走 comdlg32.GetOpenFileNameW(不依赖 tkinter/Tcl/Tk),
|
||||
其它平台或 Win32 失败时再回退到 tkinter。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Sequence
|
||||
|
||||
def pick_parquet_file() -> str | None:
|
||||
|
||||
def _pick_with_win32(
|
||||
title: str,
|
||||
filetypes: Sequence[tuple[str, str]],
|
||||
) -> str | None:
|
||||
"""Windows 系统原生打开对话框(ctypes + comdlg32)。"""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
class OPENFILENAMEW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("lStructSize", wintypes.DWORD),
|
||||
("hwndOwner", wintypes.HWND),
|
||||
("hInstance", wintypes.HINSTANCE),
|
||||
("lpstrFilter", wintypes.LPCWSTR),
|
||||
("lpstrCustomFilter", wintypes.LPWSTR),
|
||||
("nMaxCustFilter", wintypes.DWORD),
|
||||
("nFilterIndex", wintypes.DWORD),
|
||||
("lpstrFile", wintypes.LPWSTR),
|
||||
("nMaxFile", wintypes.DWORD),
|
||||
("lpstrFileTitle", wintypes.LPWSTR),
|
||||
("nMaxFileTitle", wintypes.DWORD),
|
||||
("lpstrInitialDir", wintypes.LPCWSTR),
|
||||
("lpstrTitle", wintypes.LPCWSTR),
|
||||
("Flags", wintypes.DWORD),
|
||||
("nFileOffset", wintypes.WORD),
|
||||
("nFileExtension", wintypes.WORD),
|
||||
("lpstrDefExt", wintypes.LPCWSTR),
|
||||
("lCustData", wintypes.LPARAM),
|
||||
("lpfnHook", ctypes.c_void_p),
|
||||
("lpTemplateName", wintypes.LPCWSTR),
|
||||
("pvReserved", ctypes.c_void_p),
|
||||
("dwReserved", wintypes.DWORD),
|
||||
("FlagsEx", wintypes.DWORD),
|
||||
]
|
||||
|
||||
OFN_FILEMUSTEXIST = 0x00001000
|
||||
OFN_PATHMUSTEXIST = 0x00000800
|
||||
OFN_EXPLORER = 0x00080000
|
||||
OFN_NOCHANGEDIR = 0x00000008
|
||||
OFN_HIDEREADONLY = 0x00000004
|
||||
|
||||
# Filter: "Label\0pattern\0Label2\0pattern2\0\0"
|
||||
parts: list[str] = []
|
||||
for label, pattern in filetypes:
|
||||
parts.append(label)
|
||||
parts.append(pattern)
|
||||
filter_buf = ctypes.create_unicode_buffer("\0".join(parts) + "\0")
|
||||
|
||||
max_path = 32768
|
||||
file_buf = ctypes.create_unicode_buffer(max_path)
|
||||
|
||||
ofn = OPENFILENAMEW()
|
||||
ofn.lStructSize = ctypes.sizeof(OPENFILENAMEW)
|
||||
ofn.lpstrFilter = ctypes.cast(filter_buf, wintypes.LPCWSTR)
|
||||
ofn.nFilterIndex = 1
|
||||
# create_unicode_buffer 直接赋给 LPWSTR 在部分 Python 会报类型不兼容
|
||||
ofn.lpstrFile = ctypes.cast(file_buf, wintypes.LPWSTR)
|
||||
ofn.nMaxFile = max_path
|
||||
ofn.lpstrTitle = title
|
||||
ofn.Flags = (
|
||||
OFN_EXPLORER
|
||||
| OFN_FILEMUSTEXIST
|
||||
| OFN_PATHMUSTEXIST
|
||||
| OFN_NOCHANGEDIR
|
||||
| OFN_HIDEREADONLY
|
||||
)
|
||||
|
||||
comdlg32 = ctypes.WinDLL("comdlg32", use_last_error=True)
|
||||
GetOpenFileNameW = comdlg32.GetOpenFileNameW
|
||||
GetOpenFileNameW.argtypes = [ctypes.POINTER(OPENFILENAMEW)]
|
||||
GetOpenFileNameW.restype = wintypes.BOOL
|
||||
|
||||
ok = GetOpenFileNameW(ctypes.byref(ofn))
|
||||
if not ok:
|
||||
return None
|
||||
path = file_buf.value.strip()
|
||||
return path or None
|
||||
|
||||
|
||||
def _pick_with_tkinter(
|
||||
title: str,
|
||||
filetypes: Sequence[tuple[str, str]],
|
||||
) -> str | None:
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
@@ -15,36 +104,39 @@ def pick_parquet_file() -> str | None:
|
||||
root.attributes("-topmost", True)
|
||||
except Exception:
|
||||
pass
|
||||
path = filedialog.askopenfilename(
|
||||
path = filedialog.askopenfilename(title=title, filetypes=list(filetypes))
|
||||
root.destroy()
|
||||
return path or None
|
||||
|
||||
|
||||
def _pick_open_file(
|
||||
title: str,
|
||||
filetypes: Sequence[tuple[str, str]],
|
||||
) -> str | None:
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
return _pick_with_win32(title, filetypes)
|
||||
except Exception:
|
||||
# Win32 失败时再试 tkinter(完整 Python 安装可用)
|
||||
pass
|
||||
return _pick_with_tkinter(title, filetypes)
|
||||
|
||||
|
||||
def pick_parquet_file() -> str | None:
|
||||
return _pick_open_file(
|
||||
title="选择 K 线 Parquet 文件",
|
||||
filetypes=[
|
||||
("Parquet K线", "*.parquet"),
|
||||
("所有文件", "*.*"),
|
||||
],
|
||||
)
|
||||
root.destroy()
|
||||
return path or None
|
||||
|
||||
|
||||
def pick_strategy_file() -> str | None:
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
except ImportError:
|
||||
raise RuntimeError("当前环境不支持图形文件选择(缺少 tkinter)")
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
try:
|
||||
root.attributes("-topmost", True)
|
||||
except Exception:
|
||||
pass
|
||||
path = filedialog.askopenfilename(
|
||||
return _pick_open_file(
|
||||
title="选择策略 JSON 文件",
|
||||
filetypes=[
|
||||
("策略 JSON", "*.json"),
|
||||
("所有文件", "*.*"),
|
||||
],
|
||||
)
|
||||
root.destroy()
|
||||
return path or None
|
||||
|
||||
@@ -14,6 +14,9 @@ _DEFAULT = {
|
||||
"debug_mode": False,
|
||||
"ai_provider": "deepseek",
|
||||
"ai_api_key": "",
|
||||
# OpenAI 兼容网关(DeepSeek / SenseNova 等):可自定义 base_url + model
|
||||
"ai_base_url": "https://api.deepseek.com",
|
||||
"ai_model": "deepseek-v4-flash",
|
||||
# 回测单边成本(单位 %):手续费 0.02% + 滑点 0.01% ≈ 常见加密货币轻度成本
|
||||
"bt_commission_pct": 0.02,
|
||||
"bt_slippage_pct": 0.01,
|
||||
@@ -118,6 +121,10 @@ def load_settings() -> dict:
|
||||
if out["ai_provider"] not in ("deepseek", "openclaw", "openclaw_wb"):
|
||||
out["ai_provider"] = "deepseek"
|
||||
out["ai_api_key"] = str(out.get("ai_api_key") or "").strip()
|
||||
out["ai_base_url"] = str(
|
||||
out.get("ai_base_url") or _DEFAULT["ai_base_url"]
|
||||
).strip()
|
||||
out["ai_model"] = str(out.get("ai_model") or _DEFAULT["ai_model"]).strip()
|
||||
out["bt_commission_pct"] = _as_pct(
|
||||
out.get("bt_commission_pct"), _DEFAULT["bt_commission_pct"]
|
||||
)
|
||||
@@ -181,6 +188,12 @@ def save_settings(data: dict) -> dict:
|
||||
)
|
||||
if "ai_api_key" in data:
|
||||
current["ai_api_key"] = str(data["ai_api_key"] or "").strip()
|
||||
if "ai_base_url" in data:
|
||||
url = str(data["ai_base_url"] or "").strip()
|
||||
current["ai_base_url"] = url or _DEFAULT["ai_base_url"]
|
||||
if "ai_model" in data:
|
||||
model = str(data["ai_model"] or "").strip()
|
||||
current["ai_model"] = model or _DEFAULT["ai_model"]
|
||||
if "bt_commission_pct" in data:
|
||||
current["bt_commission_pct"] = _as_pct(
|
||||
data["bt_commission_pct"], _DEFAULT["bt_commission_pct"]
|
||||
|
||||
+51
-7
@@ -736,20 +736,37 @@ async function refreshAiProviderStatus() {
|
||||
|
||||
async function initAiPanel(cfg) {
|
||||
const keyInput = $("aiApiKeyInput");
|
||||
const baseUrlInput = $("aiBaseUrlInput");
|
||||
const modelInput = $("aiModelInput");
|
||||
if (!keyInput) return;
|
||||
|
||||
if (cfg?.ai_api_key) keyInput.value = cfg.ai_api_key;
|
||||
else if (cfg?.ai_provider === "openclaw" || cfg?.ai_provider === "openclaw_wb") {
|
||||
keyInput.value = cfg.ai_provider;
|
||||
}
|
||||
if (baseUrlInput) {
|
||||
baseUrlInput.value = cfg?.ai_base_url || "https://api.deepseek.com";
|
||||
}
|
||||
if (modelInput) {
|
||||
modelInput.value = cfg?.ai_model || "deepseek-v4-flash";
|
||||
}
|
||||
|
||||
await refreshAiProviderStatus();
|
||||
if (!keyInput.dataset.aiStatusBound) {
|
||||
keyInput.dataset.aiStatusBound = "1";
|
||||
keyInput.addEventListener("input", () => {
|
||||
const refreshHint = () => {
|
||||
updateAiChannelHint();
|
||||
refreshAiProviderStatus();
|
||||
});
|
||||
};
|
||||
keyInput.addEventListener("input", refreshHint);
|
||||
if (baseUrlInput) {
|
||||
baseUrlInput.addEventListener("input", updateAiChannelHint);
|
||||
baseUrlInput.addEventListener("change", updateAiChannelHint);
|
||||
}
|
||||
if (modelInput) {
|
||||
modelInput.addEventListener("input", updateAiChannelHint);
|
||||
modelInput.addEventListener("change", updateAiChannelHint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,6 +782,12 @@ function resolveAiFromKey(raw) {
|
||||
return { provider: "deepseek", apiKey: (raw || "").trim(), isAlias: false };
|
||||
}
|
||||
|
||||
function readAiEndpointFields() {
|
||||
const baseUrl = ($("aiBaseUrlInput")?.value || "").trim() || "https://api.deepseek.com";
|
||||
const model = ($("aiModelInput")?.value || "").trim() || "deepseek-v4-flash";
|
||||
return { baseUrl, model };
|
||||
}
|
||||
|
||||
function updateAiChannelHint() {
|
||||
const hint = $("aiChannelHint");
|
||||
const headHint = $("aiProviderHint");
|
||||
@@ -774,18 +797,21 @@ function updateAiChannelHint() {
|
||||
const resolved = resolveAiFromKey(keyInput.value);
|
||||
const status = window.__aiProviderStatus;
|
||||
const row = (status?.providers || []).find((p) => p.id === resolved.provider);
|
||||
const { baseUrl, model } = readAiEndpointFields();
|
||||
const isSenseNova = /sensenova\.cn/i.test(baseUrl);
|
||||
|
||||
if (resolved.provider === "deepseek") {
|
||||
if (headHint) headHint.textContent = "DeepSeek · deepseek-v4-flash";
|
||||
hint.textContent = "当前:DeepSeek(deepseek-v4-flash · https://api.deepseek.com)。";
|
||||
const label = isSenseNova ? "SenseNova" : (/deepseek\.com/i.test(baseUrl) ? "DeepSeek" : "OpenAI 兼容");
|
||||
if (headHint) headHint.textContent = `${label} · ${model}`;
|
||||
hint.textContent = `当前:${label}(${model} · ${baseUrl})。可改 Base URL 对接 SenseNova 等兼容网关;Key 填 openclaw / openclaw_wb 可走本地通道。`;
|
||||
} else if (resolved.provider === "openclaw") {
|
||||
if (headHint) {
|
||||
headHint.textContent = row?.available ? "openclaw (QClaw) · 已匹配" : "openclaw (QClaw) · 未就绪";
|
||||
}
|
||||
hint.textContent = row?.hint || "已匹配 openclaw:将自动使用本地 QClaw token。";
|
||||
hint.textContent = row?.hint || "已匹配 openclaw:将自动使用本地 QClaw token(忽略上方 Base URL)。";
|
||||
} else {
|
||||
if (headHint) headHint.textContent = row?.available ? "openclaw_wb · 已匹配" : "openclaw_wb · 未就绪";
|
||||
hint.textContent = row?.hint || "已匹配 openclaw_wb:将自动使用 WorkBuddy token。";
|
||||
hint.textContent = row?.hint || "已匹配 openclaw_wb:将自动使用 WorkBuddy token(忽略上方 Base URL)。";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -804,13 +830,21 @@ async function runAiAnalyze() {
|
||||
const view = $("aiAnswerView");
|
||||
const rawKey = $("aiApiKeyInput")?.value || "";
|
||||
const resolved = resolveAiFromKey(rawKey);
|
||||
const { baseUrl, model } = readAiEndpointFields();
|
||||
if (!view) return;
|
||||
|
||||
if (resolved.provider === "deepseek" && !resolved.apiKey) {
|
||||
view.className = "ai-answer error";
|
||||
view.textContent = "请填写 DeepSeek API Key";
|
||||
view.textContent = "请填写 API Key";
|
||||
return;
|
||||
}
|
||||
if (resolved.provider === "deepseek") {
|
||||
if (!/^https?:\/\//i.test(baseUrl)) {
|
||||
view.className = "ai-answer error";
|
||||
view.textContent = "Base URL 必须以 http:// 或 https:// 开头(例如 https://token.sensenova.cn/v1)";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await refreshAiProviderStatus();
|
||||
|
||||
@@ -828,6 +862,8 @@ async function runAiAnalyze() {
|
||||
body: JSON.stringify({
|
||||
provider: resolved.provider,
|
||||
api_key: resolved.apiKey,
|
||||
base_url: baseUrl,
|
||||
model,
|
||||
symbol: selectedSymbol || null,
|
||||
}),
|
||||
});
|
||||
@@ -2533,6 +2569,14 @@ async function init() {
|
||||
$("aiApiKeyInput").addEventListener("input", updateAiChannelHint);
|
||||
$("aiApiKeyInput").addEventListener("change", updateAiChannelHint);
|
||||
}
|
||||
if ($("aiBaseUrlInput")) {
|
||||
$("aiBaseUrlInput").addEventListener("input", updateAiChannelHint);
|
||||
$("aiBaseUrlInput").addEventListener("change", updateAiChannelHint);
|
||||
}
|
||||
if ($("aiModelInput")) {
|
||||
$("aiModelInput").addEventListener("input", updateAiChannelHint);
|
||||
$("aiModelInput").addEventListener("change", updateAiChannelHint);
|
||||
}
|
||||
if ($("aiAnalyzeBtn")) $("aiAnalyzeBtn").addEventListener("click", runAiAnalyze);
|
||||
if ($("aiUnlimitedBtn")) $("aiUnlimitedBtn").addEventListener("click", openUnlimitedModal);
|
||||
document.querySelectorAll("[data-close-unlimited]").forEach((el) => {
|
||||
|
||||
+12
-2
@@ -149,15 +149,25 @@
|
||||
<span class="hint" id="aiProviderHint">DeepSeek · deepseek-v4-flash</span>
|
||||
</div>
|
||||
<div class="ai-form">
|
||||
<div class="ai-row">
|
||||
<label class="ai-field ai-url-field">
|
||||
<span>Base URL</span>
|
||||
<input type="text" id="aiBaseUrlInput" placeholder="https://api.deepseek.com" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
<label class="ai-field ai-model-field">
|
||||
<span>模型 (model)</span>
|
||||
<input type="text" id="aiModelInput" placeholder="deepseek-v4-flash" autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="ai-row">
|
||||
<label class="ai-field ai-key-field" id="aiKeyField">
|
||||
<span>API Key</span>
|
||||
<input type="password" id="aiApiKeyInput" placeholder="填入 DeepSeek API Key" autocomplete="off" />
|
||||
<input type="password" id="aiApiKeyInput" placeholder="填入 API Key(或 openclaw / openclaw_wb)" autocomplete="off" />
|
||||
</label>
|
||||
<button type="button" class="btn btn-primary" id="aiAnalyzeBtn">开始分析</button>
|
||||
<button type="button" class="btn btn-unlimited" id="aiUnlimitedBtn">获取无限分析功能</button>
|
||||
</div>
|
||||
<small class="field-hint" id="aiChannelHint">使用 DeepSeek(deepseek-v4-flash · https://api.deepseek.com),填入你的 API Key 即可。</small>
|
||||
<small class="field-hint" id="aiChannelHint">OpenAI 兼容网关:可改 Base URL(如 https://token.sensenova.cn/v1)与模型名,再填 API Key。</small>
|
||||
</div>
|
||||
<div class="ai-answer" id="aiAnswerView">点击「开始分析」后,AI 将回答:当前训练情况怎么样,是否值得继续,以及最新因子的含义与原理。</div>
|
||||
</section>
|
||||
|
||||
@@ -1072,6 +1072,18 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
|
||||
|
||||
.ai-key-field input { width: 100%; }
|
||||
|
||||
.ai-url-field {
|
||||
flex: 1 1 280px;
|
||||
}
|
||||
|
||||
.ai-url-field input { width: 100%; min-width: 260px; }
|
||||
|
||||
.ai-model-field {
|
||||
flex: 0 1 220px;
|
||||
}
|
||||
|
||||
.ai-model-field input { width: 100%; min-width: 180px; }
|
||||
|
||||
.ai-answer {
|
||||
min-height: 180px;
|
||||
max-height: 480px;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
"debug_mode": false,
|
||||
"ai_provider": "deepseek",
|
||||
"ai_api_key": "",
|
||||
"ai_base_url": "https://api.deepseek.com",
|
||||
"ai_model": "deepseek-v4-flash",
|
||||
"bt_commission_pct": 0.02,
|
||||
"bt_slippage_pct": 0.01,
|
||||
"realtime_watches": [],
|
||||
|
||||
Reference in New Issue
Block a user