This commit is contained in:
qq564020069
2026-08-20 23:03:07 +08:00
parent 3e18e7f74b
commit 92aa08db22
10 changed files with 358 additions and 44 deletions
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+12
View File
@@ -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;
+2
View File
@@ -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": [],