增加大模型
This commit is contained in:
@@ -9,7 +9,7 @@ import httpx
|
|||||||
|
|
||||||
from .chart_image import render_daily_chart_png_async
|
from .chart_image import render_daily_chart_png_async
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import get_llm_interpretation, save_llm_interpretation
|
from .db import save_llm_interpretation
|
||||||
from .stats import compute_three_day_stats
|
from .stats import compute_three_day_stats
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -29,6 +29,33 @@ def get_interpret_state() -> dict:
|
|||||||
return dict(_interpret_state)
|
return dict(_interpret_state)
|
||||||
|
|
||||||
|
|
||||||
|
def init_interpret_batch() -> dict:
|
||||||
|
"""同步初始化批次(API 立即返回 batch_id,避免前端刷新拉错旧批次)。"""
|
||||||
|
if _interpret_lock.locked() or _interpret_state.get("running"):
|
||||||
|
return {"ok": False, "message": "解读任务进行中", **get_interpret_state()}
|
||||||
|
|
||||||
|
stats = compute_three_day_stats()
|
||||||
|
if not stats.get("ok"):
|
||||||
|
return {"ok": False, "message": stats.get("message", "统计数据未就绪")}
|
||||||
|
|
||||||
|
sym_list = stats.get("symbols") or [x["symbol"] for x in stats.get("items", [])]
|
||||||
|
if not sym_list:
|
||||||
|
return {"ok": False, "message": "三日交集为空"}
|
||||||
|
|
||||||
|
bid = datetime.now().strftime("%Y-%m-%d-%H%M")
|
||||||
|
_interpret_state.update(
|
||||||
|
{
|
||||||
|
"running": True,
|
||||||
|
"current_symbol": "",
|
||||||
|
"done": 0,
|
||||||
|
"total": len(sym_list),
|
||||||
|
"batch_id": bid,
|
||||||
|
"last_error": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"ok": True, "batch_id": bid, "total": len(sym_list), **get_interpret_state()}
|
||||||
|
|
||||||
|
|
||||||
def _api_url() -> str:
|
def _api_url() -> str:
|
||||||
base = settings.llm_base_url.rstrip("/")
|
base = settings.llm_base_url.rstrip("/")
|
||||||
if base.endswith("/v1"):
|
if base.endswith("/v1"):
|
||||||
@@ -110,21 +137,21 @@ async def run_interpretation_batch(
|
|||||||
*,
|
*,
|
||||||
batch_id: str | None = None,
|
batch_id: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
global _interpret_state
|
|
||||||
|
|
||||||
if _interpret_lock.locked():
|
if _interpret_lock.locked():
|
||||||
return {"ok": False, "message": "解读任务进行中"}
|
return {"ok": False, "message": "解读任务进行中"}
|
||||||
|
|
||||||
stats = compute_three_day_stats()
|
stats = compute_three_day_stats()
|
||||||
if not stats.get("ok"):
|
if not stats.get("ok"):
|
||||||
|
_interpret_state["running"] = False
|
||||||
return {"ok": False, "message": stats.get("message", "统计数据未就绪")}
|
return {"ok": False, "message": stats.get("message", "统计数据未就绪")}
|
||||||
|
|
||||||
sym_list = symbols or stats.get("symbols") or [x["symbol"] for x in stats.get("items", [])]
|
sym_list = symbols or stats.get("symbols") or [x["symbol"] for x in stats.get("items", [])]
|
||||||
if not sym_list:
|
if not sym_list:
|
||||||
|
_interpret_state["running"] = False
|
||||||
return {"ok": False, "message": "三日交集为空"}
|
return {"ok": False, "message": "三日交集为空"}
|
||||||
|
|
||||||
stats_map = {x["symbol"]: x for x in stats.get("items", [])}
|
stats_map = {x["symbol"]: x for x in stats.get("items", [])}
|
||||||
bid = batch_id or datetime.now().strftime("%Y-%m-%d-%H%M")
|
bid = batch_id or _interpret_state.get("batch_id") or datetime.now().strftime("%Y-%m-%d-%H%M")
|
||||||
interval = settings.llm_symbol_interval_sec
|
interval = settings.llm_symbol_interval_sec
|
||||||
|
|
||||||
async with _interpret_lock:
|
async with _interpret_lock:
|
||||||
@@ -132,10 +159,9 @@ async def run_interpretation_batch(
|
|||||||
{
|
{
|
||||||
"running": True,
|
"running": True,
|
||||||
"current_symbol": "",
|
"current_symbol": "",
|
||||||
"done": 0,
|
"done": _interpret_state.get("done", 0),
|
||||||
"total": len(sym_list),
|
"total": len(sym_list),
|
||||||
"batch_id": bid,
|
"batch_id": bid,
|
||||||
"last_error": "",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for i, sym in enumerate(sym_list):
|
for i, sym in enumerate(sym_list):
|
||||||
@@ -164,11 +190,17 @@ async def run_interpretation_batch(
|
|||||||
|
|
||||||
def schedule_interpret_background(symbols: list[str] | None = None) -> None:
|
def schedule_interpret_background(symbols: list[str] | None = None) -> None:
|
||||||
"""后台启动解读,不阻塞请求。"""
|
"""后台启动解读,不阻塞请求。"""
|
||||||
|
info = init_interpret_batch()
|
||||||
|
if not info.get("ok"):
|
||||||
|
logger.info("Startup LLM skip: %s", info.get("message"))
|
||||||
|
return
|
||||||
|
bid = info.get("batch_id")
|
||||||
|
|
||||||
async def _run():
|
async def _run():
|
||||||
try:
|
try:
|
||||||
await run_interpretation_batch(symbols)
|
await run_interpretation_batch(symbols, batch_id=bid)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Background LLM batch failed: %s", e)
|
logger.error("Background LLM batch failed: %s", e)
|
||||||
|
_interpret_state["running"] = False
|
||||||
|
|
||||||
asyncio.create_task(_run())
|
asyncio.create_task(_run())
|
||||||
|
|||||||
+27
-8
@@ -14,7 +14,7 @@ from .exceptions import BinanceRateLimitedError
|
|||||||
from .period_api import get_period_top30
|
from .period_api import get_period_top30
|
||||||
from .periods import get_daybefore_period, get_today_period, get_yesterday_period
|
from .periods import get_daybefore_period, get_today_period, get_yesterday_period
|
||||||
from .chart_image import render_daily_chart_png_async
|
from .chart_image import render_daily_chart_png_async
|
||||||
from .llm_service import get_interpret_state, run_interpretation_batch
|
from .llm_service import get_interpret_state, init_interpret_batch, run_interpretation_batch
|
||||||
from .scheduler import job_finalize_yesterday, job_push_wecom, job_refresh_today, start_scheduler, startup_tasks, stop_scheduler
|
from .scheduler import job_finalize_yesterday, job_push_wecom, job_refresh_today, start_scheduler, startup_tasks, stop_scheduler
|
||||||
from .stats import compute_three_day_stats
|
from .stats import compute_three_day_stats
|
||||||
from .aggregator import aggregate_period
|
from .aggregator import aggregate_period
|
||||||
@@ -207,19 +207,38 @@ async def api_llm_status():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/llm/interpretations")
|
@app.get("/api/llm/interpretations")
|
||||||
async def api_llm_interpretations(batch_id: str | None = None, limit: int = 50):
|
async def api_llm_interpretations(batch_id: str | None = None, limit: int = 100):
|
||||||
return {"items": get_llm_interpretations(batch_id, limit)}
|
"""返回解读列表;进行中时优先当前批次(即使尚无记录)。"""
|
||||||
|
st = get_interpret_state()
|
||||||
|
bid = batch_id or (st.get("batch_id") if st.get("running") else None)
|
||||||
|
items = get_llm_interpretations(bid, limit) if bid else get_llm_interpretations(None, limit)
|
||||||
|
if not bid and items:
|
||||||
|
bid = items[0].get("batch_id", "")
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"batch_id": bid or st.get("batch_id", ""),
|
||||||
|
"running": st.get("running", False),
|
||||||
|
"done": st.get("done", 0),
|
||||||
|
"total": st.get("total", 0),
|
||||||
|
"current_symbol": st.get("current_symbol", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/llm/interpret/run")
|
@app.post("/api/llm/interpret/run")
|
||||||
async def api_llm_interpret_run(background_tasks: BackgroundTasks):
|
async def api_llm_interpret_run(background_tasks: BackgroundTasks):
|
||||||
if not settings.llm_api_key.strip():
|
if not settings.llm_api_key.strip():
|
||||||
raise HTTPException(400, "LLM_API_KEY 未配置")
|
raise HTTPException(400, "LLM_API_KEY 未配置")
|
||||||
state = get_interpret_state()
|
info = init_interpret_batch()
|
||||||
if state.get("running"):
|
if not info.get("ok"):
|
||||||
return {"ok": False, "message": "解读任务进行中", **state}
|
return info
|
||||||
background_tasks.add_task(run_interpretation_batch)
|
bid = info.get("batch_id")
|
||||||
return {"ok": True, "message": "已启动三日交集解读队列", **get_interpret_state()}
|
background_tasks.add_task(run_interpretation_batch, batch_id=bid)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"message": "已启动三日交集解读队列",
|
||||||
|
"batch_id": bid,
|
||||||
|
**get_interpret_state(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/chart/{symbol}/daily/refresh")
|
@app.post("/api/chart/{symbol}/daily/refresh")
|
||||||
|
|||||||
+199
-66
@@ -17,6 +17,16 @@ let statsData = null;
|
|||||||
let currentView = "today";
|
let currentView = "today";
|
||||||
let llmPollTimer = null;
|
let llmPollTimer = null;
|
||||||
let llmInterpretMap = {};
|
let llmInterpretMap = {};
|
||||||
|
let llmRunState = {
|
||||||
|
running: false,
|
||||||
|
batch_id: "",
|
||||||
|
current_symbol: "",
|
||||||
|
done: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
let llmSymbolOrder = [];
|
||||||
|
const llmExpandedSymbols = new Set();
|
||||||
|
const llmSeenDone = new Set();
|
||||||
|
|
||||||
const SORT_KEYS = {
|
const SORT_KEYS = {
|
||||||
rank: (r) => Number(r.rank) || 0,
|
rank: (r) => Number(r.rank) || 0,
|
||||||
@@ -287,15 +297,17 @@ function renderStatsTable() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
llmSymbolOrder = items.map((r) => r.symbol);
|
||||||
|
|
||||||
wrap.innerHTML = `
|
wrap.innerHTML = `
|
||||||
<table data-table="stats">
|
<table data-table="stats" class="stats-table">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th>合约</th>
|
<th>合约</th>
|
||||||
<th>今日排名</th><th>今日涨跌</th><th>今日成交额</th>
|
<th>今日排名</th><th>今日涨跌</th><th>今日成交额</th>
|
||||||
<th>昨日排名</th><th>昨日涨跌</th><th>昨日成交额</th>
|
<th>昨日排名</th><th>昨日涨跌</th><th>昨日成交额</th>
|
||||||
<th>前日排名</th><th>前日涨跌</th><th>前日成交额</th>
|
<th>前日排名</th><th>前日涨跌</th><th>前日成交额</th>
|
||||||
<th>三日总成交额</th>
|
<th>三日总成交额</th>
|
||||||
<th>AI解读</th>
|
<th class="llm-col-head">AI解读</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="stats-body"></tbody>
|
<tbody id="stats-body"></tbody>
|
||||||
</table>`;
|
</table>`;
|
||||||
@@ -309,20 +321,103 @@ function renderStatsTable() {
|
|||||||
const pct = x.price_change_pct ?? 0;
|
const pct = x.price_change_pct ?? 0;
|
||||||
return `<td class="${f === "pct" ? pctClass(pct) : ""}">${f === "pct" ? x.price_change_pct_fmt || "—" : f === "rank" ? x.rank ?? "—" : x.quote_volume_fmt || "—"}</td>`;
|
return `<td class="${f === "pct" ? pctClass(pct) : ""}">${f === "pct" ? x.price_change_pct_fmt || "—" : f === "rank" ? x.rank ?? "—" : x.quote_volume_fmt || "—"}</td>`;
|
||||||
};
|
};
|
||||||
const llm = llmInterpretMap[row.symbol];
|
return `<tr class="row-highlight stats-row" data-symbol="${row.symbol}">
|
||||||
const llmCell = llm
|
|
||||||
? `<details class="llm-inline"><summary>AI解读</summary><div class="llm-text">${escapeHtml(llm.content)}</div><small>${llm.created_at?.slice(0, 19) || ""}</small></details>`
|
|
||||||
: '<span class="muted">—</span>';
|
|
||||||
return `<tr class="row-highlight">
|
|
||||||
<td><strong>${row.symbol}</strong></td>
|
<td><strong>${row.symbol}</strong></td>
|
||||||
${cell("today", "rank")}${cell("today", "pct")}${cell("today", "vol")}
|
${cell("today", "rank")}${cell("today", "pct")}${cell("today", "vol")}
|
||||||
${cell("yesterday", "rank")}${cell("yesterday", "pct")}${cell("yesterday", "vol")}
|
${cell("yesterday", "rank")}${cell("yesterday", "pct")}${cell("yesterday", "vol")}
|
||||||
${cell("daybefore", "rank")}${cell("daybefore", "pct")}${cell("daybefore", "vol")}
|
${cell("daybefore", "rank")}${cell("daybefore", "pct")}${cell("daybefore", "vol")}
|
||||||
<td>${formatVol(row.total_quote_volume)}</td>
|
<td class="stats-total-vol">${formatVol(row.total_quote_volume)}</td>
|
||||||
<td class="llm-col">${llmCell}</td>
|
<td class="llm-col">${buildLlmCellHtml(row.symbol)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
bindLlmFoldHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLlmCellState(symbol) {
|
||||||
|
const llm = llmInterpretMap[symbol];
|
||||||
|
if (llm?.content) {
|
||||||
|
const failed = String(llm.content).startsWith("[解读失败]");
|
||||||
|
return { kind: failed ? "failed" : "done", llm };
|
||||||
|
}
|
||||||
|
if (!llmRunState.running) return { kind: "idle" };
|
||||||
|
if (symbol === llmRunState.current_symbol) return { kind: "running" };
|
||||||
|
const idx = llmSymbolOrder.indexOf(symbol);
|
||||||
|
if (idx >= 0 && idx < llmRunState.done) return { kind: "done", llm: null };
|
||||||
|
return { kind: "pending" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLlmCellHtml(symbol) {
|
||||||
|
const st = getLlmCellState(symbol);
|
||||||
|
if (st.kind === "done" && !st.llm) {
|
||||||
|
return `<div class="llm-placeholder done">已完成,点「刷新解读」</div>`;
|
||||||
|
}
|
||||||
|
if (st.kind === "done" && st.llm) {
|
||||||
|
const t = (st.llm.created_at || "").replace("T", " ").slice(0, 19);
|
||||||
|
const open = llmExpandedSymbols.has(symbol) ? " open" : "";
|
||||||
|
return `<details class="llm-fold"${open} data-symbol="${symbol}">
|
||||||
|
<summary class="llm-summary done">查看解读 <span class="llm-time">${t}</span></summary>
|
||||||
|
<div class="llm-text">${escapeHtml(st.llm.content)}</div>
|
||||||
|
</details>`;
|
||||||
|
}
|
||||||
|
if (st.kind === "failed" && st.llm) {
|
||||||
|
return `<details class="llm-fold" data-symbol="${symbol}">
|
||||||
|
<summary class="llm-summary failed">解读失败</summary>
|
||||||
|
<div class="llm-text llm-err">${escapeHtml(st.llm.content)}</div>
|
||||||
|
</details>`;
|
||||||
|
}
|
||||||
|
if (st.kind === "running") {
|
||||||
|
return `<div class="llm-placeholder running">解读中…</div>`;
|
||||||
|
}
|
||||||
|
if (st.kind === "pending") {
|
||||||
|
return `<div class="llm-placeholder pending">排队等待</div>`;
|
||||||
|
}
|
||||||
|
return `<span class="muted">—</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindLlmFoldHandlers() {
|
||||||
|
document.querySelectorAll("details.llm-fold").forEach((el) => {
|
||||||
|
el.ontoggle = () => {
|
||||||
|
const sym = el.dataset.symbol;
|
||||||
|
if (!sym) return;
|
||||||
|
if (el.open) llmExpandedSymbols.add(sym);
|
||||||
|
else llmExpandedSymbols.delete(sym);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatsLlmRows() {
|
||||||
|
const body = document.getElementById("stats-body");
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
document.querySelectorAll("tr.stats-row[data-symbol]").forEach((tr) => {
|
||||||
|
const sym = tr.dataset.symbol;
|
||||||
|
const td = tr.querySelector("td.llm-col");
|
||||||
|
if (!td) return;
|
||||||
|
const wasOpen = td.querySelector("details.llm-fold")?.open;
|
||||||
|
td.innerHTML = buildLlmCellHtml(sym);
|
||||||
|
const det = td.querySelector("details.llm-fold");
|
||||||
|
if (det) {
|
||||||
|
det.ontoggle = () => {
|
||||||
|
if (det.open) llmExpandedSymbols.add(sym);
|
||||||
|
else llmExpandedSymbols.delete(sym);
|
||||||
|
};
|
||||||
|
if (wasOpen || llmExpandedSymbols.has(sym)) det.open = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const sym of Object.keys(llmInterpretMap)) {
|
||||||
|
if (llmSeenDone.has(sym)) continue;
|
||||||
|
const llm = llmInterpretMap[sym];
|
||||||
|
if (llm?.content && !String(llm.content).startsWith("[解读失败]")) {
|
||||||
|
llmSeenDone.add(sym);
|
||||||
|
const det = document.querySelector(`details.llm-fold[data-symbol="${sym}"]`);
|
||||||
|
if (det) {
|
||||||
|
det.open = true;
|
||||||
|
llmExpandedSymbols.add(sym);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function escapeHtml(s) {
|
||||||
@@ -339,71 +434,106 @@ function formatVol(v) {
|
|||||||
return String(Math.round(v));
|
return String(Math.round(v));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLlmInterpretations() {
|
function applyLlmPayload(data) {
|
||||||
try {
|
if (data.batch_id) llmRunState.batch_id = data.batch_id;
|
||||||
const res = await fetch("/api/llm/interpretations");
|
if (data.running != null) {
|
||||||
const data = await res.json();
|
llmRunState.running = !!data.running;
|
||||||
llmInterpretMap = {};
|
llmRunState.batch_id = data.batch_id || llmRunState.batch_id;
|
||||||
|
llmRunState.current_symbol = data.current_symbol || "";
|
||||||
|
llmRunState.done = data.done ?? llmRunState.done;
|
||||||
|
llmRunState.total = data.total ?? llmRunState.total;
|
||||||
|
}
|
||||||
|
const nextMap = { ...llmInterpretMap };
|
||||||
for (const item of data.items || []) {
|
for (const item of data.items || []) {
|
||||||
llmInterpretMap[item.symbol] = item;
|
if (item.symbol) nextMap[item.symbol] = item;
|
||||||
}
|
}
|
||||||
renderLlmList(data.items || []);
|
llmInterpretMap = nextMap;
|
||||||
if (statsData?.ok) renderStatsTable();
|
if (document.getElementById("stats-body")) {
|
||||||
} catch {
|
updateStatsLlmRows();
|
||||||
/* ignore */
|
} else if (statsData?.ok) {
|
||||||
|
renderStatsTable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLlmList(items) {
|
async function loadLlmInterpretations() {
|
||||||
const el = document.getElementById("llm-interpret-list");
|
const q = llmRunState.batch_id
|
||||||
if (!el) return;
|
? `?batch_id=${encodeURIComponent(llmRunState.batch_id)}`
|
||||||
if (!items.length) {
|
: "";
|
||||||
el.innerHTML = '<p class="loading">暂无解读记录</p>';
|
const res = await fetch(`/api/llm/interpretations${q}`);
|
||||||
return;
|
if (!res.ok) throw new Error("加载解读失败");
|
||||||
}
|
const data = await res.json();
|
||||||
el.innerHTML = items
|
applyLlmPayload(data);
|
||||||
.map(
|
return data;
|
||||||
(it) => `
|
|
||||||
<article class="llm-card">
|
|
||||||
<h4>${it.symbol} <small>${it.batch_id}</small></h4>
|
|
||||||
<div class="llm-text">${escapeHtml(it.content)}</div>
|
|
||||||
<time>${(it.created_at || "").replace("T", " ").slice(0, 19)}</time>
|
|
||||||
</article>`
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshLlmStatus() {
|
function updateLlmStatusText(st) {
|
||||||
try {
|
|
||||||
const res = await fetch("/api/llm/status");
|
|
||||||
const st = await res.json();
|
|
||||||
const label = document.getElementById("llm-model-label");
|
const label = document.getElementById("llm-model-label");
|
||||||
const text = document.getElementById("llm-status-text");
|
const text = document.getElementById("llm-status-text");
|
||||||
if (label) label.textContent = st.enabled ? st.model : "未配置";
|
if (label) label.textContent = st.enabled ? st.model : "未配置";
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (st.running) {
|
if (st.running) {
|
||||||
text.textContent = `解读中 ${st.done}/${st.total} · 当前 ${st.current_symbol || "—"} · 批次 ${st.batch_id}`;
|
text.textContent = `解读中 ${st.done}/${st.total} · 当前 ${st.current_symbol || "—"}`;
|
||||||
if (!llmPollTimer) {
|
|
||||||
llmPollTimer = setInterval(async () => {
|
|
||||||
await refreshLlmStatus();
|
|
||||||
await loadLlmInterpretations();
|
|
||||||
if (!(await fetch("/api/llm/status").then((r) => r.json())).running) {
|
|
||||||
clearInterval(llmPollTimer);
|
|
||||||
llmPollTimer = null;
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
text.textContent = st.enabled
|
text.textContent = st.enabled
|
||||||
? `就绪 · 每币 ${st.interval_sec}s · 最近批次 ${st.batch_id || "—"}`
|
? `就绪 · 已完成 ${Object.keys(llmInterpretMap).length} 条 · 批次 ${st.batch_id || dataBatchId(st)}`
|
||||||
: "请在 .env 配置 LLM_API_KEY";
|
: "请在 .env 配置 LLM_API_KEY";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataBatchId(st) {
|
||||||
|
const keys = Object.keys(llmInterpretMap);
|
||||||
|
return keys.length ? llmInterpretMap[keys[0]]?.batch_id || "—" : "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshLlmStatus() {
|
||||||
|
const res = await fetch("/api/llm/status");
|
||||||
|
if (!res.ok) throw new Error("状态获取失败");
|
||||||
|
const st = await res.json();
|
||||||
|
llmRunState.running = !!st.running;
|
||||||
|
llmRunState.batch_id = st.batch_id || llmRunState.batch_id;
|
||||||
|
llmRunState.current_symbol = st.current_symbol || "";
|
||||||
|
llmRunState.done = st.done ?? 0;
|
||||||
|
llmRunState.total = st.total ?? 0;
|
||||||
|
updateLlmStatusText(st);
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startLlmPolling() {
|
||||||
|
if (llmPollTimer) return;
|
||||||
|
llmPollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await refreshLlmStatus();
|
||||||
|
await loadLlmInterpretations();
|
||||||
|
const st = await fetch("/api/llm/status").then((r) => r.json());
|
||||||
|
if (!st.running) {
|
||||||
|
clearInterval(llmPollTimer);
|
||||||
|
llmPollTimer = null;
|
||||||
|
await loadLlmInterpretations();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("LLM poll:", e);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLlmPolling() {
|
||||||
if (llmPollTimer) {
|
if (llmPollTimer) {
|
||||||
clearInterval(llmPollTimer);
|
clearInterval(llmPollTimer);
|
||||||
llmPollTimer = null;
|
llmPollTimer = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
/* ignore */
|
async function refreshLlmAll() {
|
||||||
|
const text = document.getElementById("llm-status-text");
|
||||||
|
if (text) text.textContent = "刷新中…";
|
||||||
|
try {
|
||||||
|
const st = await refreshLlmStatus();
|
||||||
|
await loadLlmInterpretations();
|
||||||
|
if (st.running) startLlmPolling();
|
||||||
|
else stopLlmPolling();
|
||||||
|
} catch (e) {
|
||||||
|
if (text) text.textContent = "刷新失败";
|
||||||
|
console.error(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,8 +543,18 @@ async function runLlmInterpret() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch("/api/llm/interpret/run", { method: "POST" });
|
const res = await fetch("/api/llm/interpret/run", { method: "POST" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!data.ok) alert(data.message || "启动失败");
|
if (!data.ok) {
|
||||||
|
alert(data.message || "启动失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
llmInterpretMap = {};
|
||||||
|
llmSeenDone.clear();
|
||||||
|
if (data.batch_id) llmRunState.batch_id = data.batch_id;
|
||||||
|
llmRunState.running = true;
|
||||||
await refreshLlmStatus();
|
await refreshLlmStatus();
|
||||||
|
await loadLlmInterpretations();
|
||||||
|
startLlmPolling();
|
||||||
|
if (document.getElementById("stats-body")) updateStatsLlmRows();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e.message);
|
alert(e.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -512,9 +652,8 @@ async function loadStats() {
|
|||||||
try {
|
try {
|
||||||
const res = await fetch("/api/stats/three-day");
|
const res = await fetch("/api/stats/three-day");
|
||||||
statsData = await res.json();
|
statsData = await res.json();
|
||||||
await loadLlmInterpretations();
|
|
||||||
renderStatsTable();
|
renderStatsTable();
|
||||||
await refreshLlmStatus();
|
await refreshLlmAll();
|
||||||
await loadWecomPreview();
|
await loadWecomPreview();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById("stats-table-wrap").innerHTML = `<p class="error">${e.message}</p>`;
|
document.getElementById("stats-table-wrap").innerHTML = `<p class="error">${e.message}</p>`;
|
||||||
@@ -551,10 +690,7 @@ function switchView(view) {
|
|||||||
|
|
||||||
if (view === "stats") {
|
if (view === "stats") {
|
||||||
if (!statsData) loadStats();
|
if (!statsData) loadStats();
|
||||||
else {
|
else refreshLlmAll();
|
||||||
refreshLlmStatus();
|
|
||||||
loadLlmInterpretations();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,10 +724,7 @@ document.getElementById("btn-refresh").addEventListener("click", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("btn-llm-run")?.addEventListener("click", runLlmInterpret);
|
document.getElementById("btn-llm-run")?.addEventListener("click", runLlmInterpret);
|
||||||
document.getElementById("btn-llm-refresh")?.addEventListener("click", async () => {
|
document.getElementById("btn-llm-refresh")?.addEventListener("click", () => refreshLlmAll());
|
||||||
await loadLlmInterpretations();
|
|
||||||
await refreshLlmStatus();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("btn-reload-stats")?.addEventListener("click", () => {
|
document.getElementById("btn-reload-stats")?.addEventListener("click", () => {
|
||||||
statsData = null;
|
statsData = null;
|
||||||
|
|||||||
+6
-12
@@ -75,9 +75,15 @@
|
|||||||
<button type="button" class="btn-secondary" id="btn-export-stats">导出 CSV</button>
|
<button type="button" class="btn-secondary" id="btn-export-stats">导出 CSV</button>
|
||||||
<button type="button" class="btn-secondary" id="btn-push-preview">预览企微推送</button>
|
<button type="button" class="btn-secondary" id="btn-push-preview">预览企微推送</button>
|
||||||
<button type="button" class="btn-secondary" id="btn-push-test">测试推送企微</button>
|
<button type="button" class="btn-secondary" id="btn-push-test">测试推送企微</button>
|
||||||
|
<button type="button" class="btn-secondary" id="btn-llm-run">开始解读</button>
|
||||||
|
<button type="button" class="btn-secondary" id="btn-llm-refresh">刷新解读</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="stats-desc" id="stats-desc"></p>
|
<p class="stats-desc" id="stats-desc"></p>
|
||||||
|
<p class="stats-desc llm-status-line">
|
||||||
|
大模型 <span class="llm-model" id="llm-model-label">—</span>
|
||||||
|
· <span id="llm-status-text">—</span>
|
||||||
|
</p>
|
||||||
<section class="wecom-preview-panel hidden" id="wecom-preview-panel">
|
<section class="wecom-preview-panel hidden" id="wecom-preview-panel">
|
||||||
<h3>企微推送预览 <span class="wecom-preview-meta" id="wecom-preview-meta"></span></h3>
|
<h3>企微推送预览 <span class="wecom-preview-meta" id="wecom-preview-meta"></span></h3>
|
||||||
<p class="stats-desc">仅包含「三日 Top30 交集」币种;实际发到企微为下方同款列表排版(非宽表格)。</p>
|
<p class="stats-desc">仅包含「三日 Top30 交集」币种;实际发到企微为下方同款列表排版(非宽表格)。</p>
|
||||||
@@ -85,18 +91,6 @@
|
|||||||
</section>
|
</section>
|
||||||
<div class="table-wrap" id="stats-table-wrap"></div>
|
<div class="table-wrap" id="stats-table-wrap"></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel llm-panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<h2>大模型解读 <span class="llm-model" id="llm-model-label">—</span></h2>
|
|
||||||
<span class="updated" id="llm-status-text">—</span>
|
|
||||||
<div class="panel-actions">
|
|
||||||
<button type="button" class="btn-secondary" id="btn-llm-run">开始解读</button>
|
|
||||||
<button type="button" class="btn-secondary" id="btn-llm-refresh">刷新解读</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="stats-desc">每日 08:05(北京时间)自动对「三日 Top30 交集」逐币解读,每币约 3 分钟;启动时也会自动跑一轮(需配置 LLM_API_KEY)。</p>
|
|
||||||
<div id="llm-interpret-list" class="llm-list"></div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
|
|||||||
+72
-3
@@ -398,15 +398,84 @@ button:hover {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.llm-inline summary {
|
.llm-status-line {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-table .stats-total-vol {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-col-head,
|
||||||
|
.llm-col {
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 360px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-fold {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: #0f1520;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-fold .llm-text {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.llm-col {
|
.llm-summary::-webkit-details-marker {
|
||||||
max-width: 280px;
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-summary.done::before {
|
||||||
|
content: "▸ ";
|
||||||
|
}
|
||||||
|
|
||||||
|
details[open] > .llm-summary.done::before {
|
||||||
|
content: "▾ ";
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-summary.failed {
|
||||||
|
color: #f6465d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-time {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-placeholder {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-placeholder.running {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-placeholder.pending {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.llm-err {
|
||||||
|
color: #f6465d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
|
|||||||
Reference in New Issue
Block a user