v1.8.0 智能截图多视觉大模型接口配置(优先级+失败降级) + 提取历史图示统计
1) 智能截图·视觉大模型配置: - 支持配置多个视觉大模型接口(均含视觉能力), 按列表顺序=优先级调用 - 失败/报错/超时自动降级到下一个接口, 全部失败才保守停止滚动 - 预置两个高优先级接口: 本地18008 Qwen3.8-27B-NVFP4 + SiliconFlow Kimi-K2.6 - 前端弹窗: 接口增删改/启用开关/⬆⬇调整优先级/逐接口🧪测试连接 - /api/smart/test 支持 endpoint_id 测试已保存接口; 旧单接口配置自动迁移为 endpoints[0] - 每步判断记录所用模型名+接口名(详情可见降级链路) 2) 提取历史图示统计: - /api/history/stats?dimension=day|action|backend|status|caller|method&days=N - 前端📊统计弹窗: 6个角度切换 + 7/30/90天范围 + 饼图/柱状图切换 + 汇总(总数/成功率) + 数据表 + 下载PNG - 按天维度缺0补0, caller Top8+其他
This commit is contained in:
@@ -163,10 +163,28 @@ init_db()
|
||||
# ===== 按需截图(AI智能判断滚动)配置 =====
|
||||
CONFIG_FILE = DATA_DIR / "config.json"
|
||||
|
||||
# 预置视觉大模型接口(按列表顺序 = 优先级,越靠前越优先;失败自动降级到下一个)
|
||||
DEFAULT_ENDPOINTS = [
|
||||
{
|
||||
"id": "ep_local_qwen",
|
||||
"name": "本地 Qwen3.8-27B (18008)",
|
||||
"base_url": "http://121.40.164.32:18008/v1",
|
||||
"api_key": "xxxx",
|
||||
"model": "unsloth/Qwen3.8-27B-NVFP4",
|
||||
"enabled": True
|
||||
},
|
||||
{
|
||||
"id": "ep_siliconflow_kimi",
|
||||
"name": "SiliconFlow Kimi-K2.6",
|
||||
"base_url": "https://api.siliconflow.cn/v1",
|
||||
"api_key": "sk-fhpoexpptvjghpnphtaxbkhjwulzovoqfffbckcfscjmwhcg",
|
||||
"model": "Pro/moonshotai/Kimi-K2.6",
|
||||
"enabled": True
|
||||
}
|
||||
]
|
||||
|
||||
DEFAULT_SMART_CONFIG = {
|
||||
"base_url": "https://www.autodl.art/api/v1",
|
||||
"api_key": "F9MBfolzuapqTsD4KmUf9qen720rXvUZ3Sp3IrWiCTukqonx",
|
||||
"model": "qwen3.6-plus",
|
||||
"endpoints": [json.loads(json.dumps(ep)) for ep in DEFAULT_ENDPOINTS],
|
||||
"prompt": (
|
||||
"你是网页内容完整性判断助手。下面是一张网页滚动截图的当前视口画面。"
|
||||
"请判断:这个网页的主题内容(正文/主要内容)是否已经完整截取完成,是否还需要继续向下滚动?\n"
|
||||
@@ -182,7 +200,7 @@ DEFAULT_SMART_CONFIG = {
|
||||
|
||||
|
||||
def load_smart_config():
|
||||
"""读取按需截图 LLM 配置(缺省回默认)"""
|
||||
"""读取按需截图 LLM 配置(缺省回默认;旧单接口配置自动迁移为 endpoints[0])"""
|
||||
cfg = json.loads(json.dumps(DEFAULT_SMART_CONFIG))
|
||||
if CONFIG_FILE.exists():
|
||||
try:
|
||||
@@ -193,16 +211,49 @@ def load_smart_config():
|
||||
cfg[k] = saved[k]
|
||||
except Exception:
|
||||
pass
|
||||
# 兼容旧版单接口配置:没有 endpoints 时,把 base_url/api_key/model 迁移为第一个接口
|
||||
eps = cfg.get("endpoints") or []
|
||||
if not eps and cfg.get("base_url"):
|
||||
eps = [{
|
||||
"id": "ep_legacy",
|
||||
"name": cfg.get("model") or cfg.get("base_url"),
|
||||
"base_url": cfg["base_url"],
|
||||
"api_key": cfg.get("api_key", ""),
|
||||
"model": cfg.get("model", ""),
|
||||
"enabled": True
|
||||
}]
|
||||
cfg["endpoints"] = eps
|
||||
return cfg
|
||||
|
||||
|
||||
def save_smart_config(cfg):
|
||||
"""保存按需截图 LLM 配置"""
|
||||
"""保存按需截图 LLM 配置(endpoints 数组顺序 = 优先级)"""
|
||||
merged = json.loads(json.dumps(DEFAULT_SMART_CONFIG))
|
||||
if isinstance(cfg, dict):
|
||||
for k in DEFAULT_SMART_CONFIG:
|
||||
# 滚动/提示词参数
|
||||
for k in ("prompt", "max_scrolls", "scroll_ratio", "timeout"):
|
||||
if k in cfg and cfg[k] not in (None, ""):
|
||||
merged[k] = cfg[k]
|
||||
# 接口列表:保留有效字段,补 id/name/enabled 默认值
|
||||
eps = cfg.get("endpoints")
|
||||
if isinstance(eps, list) and eps:
|
||||
clean = []
|
||||
for i, ep in enumerate(eps):
|
||||
if not isinstance(ep, dict) or not ep.get("base_url"):
|
||||
continue
|
||||
clean.append({
|
||||
"id": ep.get("id") or f"ep_{i+1}",
|
||||
"name": ep.get("name") or ep.get("model") or f"接口{i+1}",
|
||||
"base_url": ep["base_url"],
|
||||
"api_key": ep.get("api_key", ""),
|
||||
"model": ep.get("model", ""),
|
||||
"enabled": bool(ep.get("enabled", True))
|
||||
})
|
||||
if clean:
|
||||
merged["endpoints"] = clean
|
||||
elif isinstance(cfg.get("endpoints"), list) and not eps:
|
||||
# 显式传空列表 = 清空
|
||||
merged["endpoints"] = []
|
||||
CONFIG_FILE.parent.mkdir(exist_ok=True)
|
||||
CONFIG_FILE.write_text(json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return merged
|
||||
@@ -225,17 +276,18 @@ def extract_json_from_content(content):
|
||||
return None
|
||||
|
||||
|
||||
def llm_judge_screenshot(image_path, cfg):
|
||||
def llm_call_vision(image_path, cfg, endpoint):
|
||||
"""
|
||||
调用视觉大模型判断当前截图是否已覆盖主题内容、是否还需滚动
|
||||
用单个视觉大模型接口判断截图
|
||||
endpoint: {"base_url", "api_key", "model", "name"}
|
||||
返回: {"complete": bool, "reason": str}
|
||||
异常时抛 ValueError(调用方决定如何处置)
|
||||
任何失败(网络/HTTP/解析/格式)都抛 ValueError,由上层降级到下一个接口
|
||||
"""
|
||||
import requests
|
||||
img_b64 = base64.b64encode(Path(image_path).read_bytes()).decode()
|
||||
url = cfg["base_url"].rstrip("/") + "/chat/completions"
|
||||
url = endpoint["base_url"].rstrip("/") + "/chat/completions"
|
||||
payload = {
|
||||
"model": cfg["model"],
|
||||
"model": endpoint.get("model", ""),
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [
|
||||
@@ -245,20 +297,45 @@ def llm_judge_screenshot(image_path, cfg):
|
||||
}],
|
||||
"max_tokens": 300
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json"}
|
||||
headers = {"Authorization": f"Bearer {endpoint.get('api_key', '')}", "Content-Type": "application/json"}
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=int(cfg.get("timeout", 120)))
|
||||
if resp.status_code != 200:
|
||||
raise ValueError(f"LLM 接口返回 {resp.status_code}: {resp.text[:200]}")
|
||||
raise ValueError(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
try:
|
||||
content = resp.json()["choices"][0]["message"]["content"]
|
||||
except Exception:
|
||||
raise ValueError(f"LLM 响应格式异常: {resp.text[:200]}")
|
||||
raise ValueError(f"响应格式异常: {resp.text[:200]}")
|
||||
parsed = extract_json_from_content(content)
|
||||
if not parsed or "complete" not in parsed:
|
||||
raise ValueError(f"LLM 判断结果解析失败,原始输出: {content[:200]}")
|
||||
raise ValueError(f"判断结果解析失败,原始输出: {content[:200]}")
|
||||
return {"complete": bool(parsed["complete"]), "reason": str(parsed.get("reason", ""))}
|
||||
|
||||
|
||||
def llm_judge_screenshot(image_path, cfg):
|
||||
"""
|
||||
调用视觉大模型判断当前截图是否已覆盖主题内容、是否还需滚动。
|
||||
按 endpoints 列表顺序(高→低优先级)逐个尝试,失败/报错/超时自动降级到下一个;
|
||||
全部失败才抛 ValueError(调用方保守停止滚动)。
|
||||
返回: {"complete": bool, "reason": str, "model": str, "used_endpoint": str}
|
||||
"""
|
||||
import requests # noqa: F401 (确保模块可用,实际在 llm_call_vision 中导入)
|
||||
endpoints = cfg.get("endpoints") or []
|
||||
enabled = [ep for ep in endpoints if ep.get("enabled", True)]
|
||||
if not enabled:
|
||||
raise ValueError("未配置可用的视觉大模型接口(请到「⚙️ 智能配置」添加)")
|
||||
errors = []
|
||||
for ep in enabled:
|
||||
ep_name = ep.get("name") or ep.get("model") or ep.get("base_url", "")
|
||||
try:
|
||||
result = llm_call_vision(image_path, cfg, ep)
|
||||
result["model"] = ep.get("model", "")
|
||||
result["used_endpoint"] = ep_name
|
||||
return result
|
||||
except Exception as e:
|
||||
errors.append(f"{ep_name}: {e}")
|
||||
raise ValueError("全部视觉大模型接口调用失败 → " + " | ".join(errors))
|
||||
|
||||
|
||||
def stitch_images(image_paths, out_path):
|
||||
"""把多张视口截图纵向拼接成一张长图(所有图对齐到最宽宽度)"""
|
||||
from PIL import Image
|
||||
@@ -764,7 +841,8 @@ def smart_capture_agent_browser(url, cfg, wait_time, viewport):
|
||||
# 视觉大模型实时判断
|
||||
try:
|
||||
judge = llm_judge_screenshot(shot, cfg)
|
||||
steps.append({"step": step_no, "complete": judge["complete"], "reason": judge["reason"]})
|
||||
steps.append({"step": step_no, "complete": judge["complete"], "reason": judge["reason"],
|
||||
"model": judge.get("model", ""), "endpoint": judge.get("used_endpoint", "")})
|
||||
except ValueError as ve:
|
||||
# 判断失败:保守停止,保留已截内容
|
||||
steps.append({"step": step_no, "complete": True, "reason": f"⚠️ 大模型判断失败,停止滚动: {ve}"})
|
||||
@@ -902,7 +980,8 @@ async def smart_capture_playwright(url, cfg, wait_time, viewport):
|
||||
|
||||
try:
|
||||
judge = llm_judge_screenshot(shot, cfg)
|
||||
steps.append({"step": step_no, "complete": judge["complete"], "reason": judge["reason"]})
|
||||
steps.append({"step": step_no, "complete": judge["complete"], "reason": judge["reason"],
|
||||
"model": judge.get("model", ""), "endpoint": judge.get("used_endpoint", "")})
|
||||
except ValueError as ve:
|
||||
steps.append({"step": step_no, "complete": True, "reason": f"⚠️ 大模型判断失败,停止滚动: {ve}"})
|
||||
stop_reason = str(ve)
|
||||
@@ -1145,7 +1224,10 @@ def api_info():
|
||||
"endpoints": {
|
||||
"/api/capture": "POST - Capture webpage (screenshot/html/text/smart) + 自动入库历史(记录调用者/方式/原始HTML)",
|
||||
"/api/backends": "GET - 可用后端列表与默认顺序(供前端渲染后端选择器)",
|
||||
"/api/smart/config": "GET/POST - 智能截图视觉大模型多接口配置(列表顺序=优先级,失败自动降级)",
|
||||
"/api/smart/test": "POST - 测试指定视觉接口连通性({endpoint_id} / {endpoint} / 旧版{config})",
|
||||
"/api/history": "GET - 历史记录分页列表 (?page&page_size&action&search)",
|
||||
"/api/history/stats": "GET - 历史图示统计 (?dimension=day|action|backend|status|caller|method&days=30)",
|
||||
"/api/history/<id>": "GET - 历史记录详情 / DELETE - 删除记录",
|
||||
"/api/history/<id>/file": "GET - 读取历史截图文件",
|
||||
"/api/history/<id>/html": "GET - 读取保存的原始HTML文件",
|
||||
@@ -1197,6 +1279,78 @@ def history_list():
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/history/stats', methods=['GET'])
|
||||
def history_stats():
|
||||
"""
|
||||
提取历史图示统计
|
||||
参数:
|
||||
dimension: day(按天趋势) | action(操作类型) | backend(后端) | status(状态) | caller(调用者) | method(调用方式)
|
||||
days: 按天维度的天数范围(1-365, 默认30); 其他维度默认全量(也可传 days 只统计最近N天)
|
||||
返回: {success, dimension, days, total, success_count, success_rate, items:[{label, value}]}
|
||||
"""
|
||||
dimension = request.args.get('dimension', 'action')
|
||||
valid = ('day', 'action', 'backend', 'status', 'caller', 'method')
|
||||
if dimension not in valid:
|
||||
return jsonify({"success": False, "error": f"dimension 需为 {'/'.join(valid)}"}), 400
|
||||
days = min(365, max(1, int(request.args.get('days', 30))))
|
||||
|
||||
# 时间下限(可选):非 day 维度也可按 days 过滤
|
||||
from datetime import timedelta
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
conn = get_db()
|
||||
# 总量 / 成功率
|
||||
total = conn.execute("SELECT COUNT(*) c FROM captures").fetchone()["c"]
|
||||
sc = conn.execute("SELECT COUNT(*) c FROM captures WHERE status='success'").fetchone()["c"]
|
||||
|
||||
items = []
|
||||
if dimension == 'day':
|
||||
# 最近 days 天的每日提取量(缺0补0)
|
||||
rows = conn.execute(
|
||||
"SELECT substr(created_at,1,10) d, COUNT(*) c FROM captures WHERE created_at >= ? GROUP BY d ORDER BY d",
|
||||
(cutoff,)
|
||||
).fetchall()
|
||||
m = {r["d"]: r["c"] for r in rows}
|
||||
date_list = []
|
||||
for i in range(days - 1, -1, -1):
|
||||
date_list.append((datetime.now() - timedelta(days=i)).strftime("%Y-%m-%d"))
|
||||
items = [{"label": d, "value": m.get(d, 0)} for d in date_list]
|
||||
else:
|
||||
col = {"action": "action", "backend": "backend", "status": "status",
|
||||
"caller": "caller", "method": "call_method"}[dimension]
|
||||
rows = conn.execute(
|
||||
f"SELECT {col} k, COUNT(*) c FROM captures WHERE created_at >= ? GROUP BY {col} ORDER BY c DESC",
|
||||
(cutoff,)
|
||||
).fetchall()
|
||||
# caller 太多时只保留 Top 8 + 其他
|
||||
if dimension == 'caller':
|
||||
top = rows[:8]
|
||||
rest = sum(r["c"] for r in rows[8:])
|
||||
items = [{"label": r["k"] or "未知", "value": r["c"]} for r in top]
|
||||
if rest:
|
||||
items.append({"label": "其他", "value": rest})
|
||||
else:
|
||||
for r in rows:
|
||||
label = r["k"] or "(空)"
|
||||
if dimension == 'status':
|
||||
label = "成功" if r["k"] == "success" else ("失败" if r["k"] == "failed" else r["k"])
|
||||
elif dimension == 'action':
|
||||
label = {"screenshot": "📸 截图", "html": "📄 HTML", "text": "📝 文本", "smart": "🧠 按需截图"}.get(r["k"], r["k"])
|
||||
items.append({"label": label, "value": r["c"]})
|
||||
conn.close()
|
||||
|
||||
# 按天维度去 emoji(保持纯净日期标签)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"dimension": dimension,
|
||||
"days": days,
|
||||
"total": total,
|
||||
"success_count": sc,
|
||||
"success_rate": round(sc / total, 4) if total else 0,
|
||||
"items": items
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/history/<int:rid>', methods=['GET'])
|
||||
def history_detail(rid):
|
||||
"""历史记录详情(含完整内容)"""
|
||||
@@ -1277,25 +1431,46 @@ def smart_config_endpoint():
|
||||
|
||||
@app.route('/api/smart/test', methods=['POST'])
|
||||
def smart_test_endpoint():
|
||||
"""测试按需截图 LLM 接口连通性(发一条小文本消息验证 base_url/api_key/model)"""
|
||||
"""测试视觉大模型接口连通性(发一条小文本消息验证 base_url/api_key/model)。
|
||||
入参(三选一):
|
||||
{endpoint_id: "ep_xxx"} → 测试已保存配置中的某个接口
|
||||
{endpoint: {base_url, api_key, model}} → 直接测试传入的接口
|
||||
{config: {...}} 或 {base_url,...} → 兼容旧版单接口测试
|
||||
"""
|
||||
import requests
|
||||
data = request.get_json() or {}
|
||||
cfg = dict(load_smart_config())
|
||||
if data.get("config"):
|
||||
for k in DEFAULT_SMART_CONFIG:
|
||||
if k in data["config"] and data["config"][k] not in (None, ""):
|
||||
cfg[k] = data["config"][k]
|
||||
elif data.get("base_url") or data.get("api_key") or data.get("model"):
|
||||
for k in ("base_url", "api_key", "model"):
|
||||
if data.get(k):
|
||||
cfg[k] = data[k]
|
||||
ep = None
|
||||
|
||||
url = cfg["base_url"].rstrip("/") + "/chat/completions"
|
||||
payload = {"model": cfg["model"], "messages": [{"role": "user", "content": "ping"}], "max_tokens": 5}
|
||||
headers = {"Authorization": f"Bearer {cfg['api_key']}", "Content-Type": "application/json"}
|
||||
# 1) endpoint_id:从已保存配置里找
|
||||
if data.get("endpoint_id"):
|
||||
saved = load_smart_config()
|
||||
for e in (saved.get("endpoints") or []):
|
||||
if e.get("id") == data["endpoint_id"]:
|
||||
ep = e
|
||||
break
|
||||
if not ep:
|
||||
return jsonify({"success": False, "error": f"接口 {data['endpoint_id']} 不存在"}), 404
|
||||
# 2) endpoint 对象:直接测试
|
||||
elif isinstance(data.get("endpoint"), dict) and data["endpoint"].get("base_url"):
|
||||
ep = data["endpoint"]
|
||||
# 3) 兼容旧版:config 或平铺字段
|
||||
else:
|
||||
cfg = dict(load_smart_config())
|
||||
override = data.get("config") or data
|
||||
if isinstance(override, dict):
|
||||
for k in ("base_url", "api_key", "model", "timeout"):
|
||||
if k in override and override[k] not in (None, ""):
|
||||
cfg[k] = override[k]
|
||||
ep = {"base_url": cfg["base_url"], "api_key": cfg.get("api_key", ""),
|
||||
"model": cfg.get("model", ""), "name": cfg.get("model", "")}
|
||||
|
||||
url = ep["base_url"].rstrip("/") + "/chat/completions"
|
||||
payload = {"model": ep.get("model", ""), "messages": [{"role": "user", "content": "ping"}], "max_tokens": 5}
|
||||
headers = {"Authorization": f"Bearer {ep.get('api_key', '')}", "Content-Type": "application/json"}
|
||||
timeout = int(data.get("timeout") or 60)
|
||||
t0 = time.time()
|
||||
try:
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=int(cfg.get("timeout", 120)))
|
||||
resp = requests.post(url, headers=headers, json=payload, timeout=timeout)
|
||||
cost = round(time.time() - t0, 2)
|
||||
if resp.status_code != 200:
|
||||
return jsonify({"success": False, "error": f"HTTP {resp.status_code}: {resp.text[:200]}", "latency": cost}), 400
|
||||
@@ -1304,7 +1479,7 @@ def smart_test_endpoint():
|
||||
reply = d["choices"][0]["message"]["content"][:80]
|
||||
except Exception:
|
||||
reply = "(无内容)"
|
||||
return jsonify({"success": True, "latency": cost, "model": cfg["model"], "reply": reply})
|
||||
return jsonify({"success": True, "latency": cost, "model": ep.get("model", ""), "reply": reply})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 400
|
||||
|
||||
|
||||
Reference in New Issue
Block a user