feat: console actions to query and reset a site quota
This commit is contained in:
1 parent
08464a27d7
commit
753b495f8a
3 files changed
+102
-1
No files matched your search
@@ -33,6 +33,7 @@ Go 实现,编译为**单个静态二进制**,无运行时依赖;内置 **W
|
||||
- **多格式对话接口**:`POST /v1/chat/completions`(OpenAI,流式 / 非流式)、`POST /v1/responses`(OpenAI Responses)、`POST /v1/messages`(Anthropic Messages)、`GET /v1/models`、`GET /health`
|
||||
- **Tool Calling**:三个对话端点均支持 `tools` / `tool_choice`,模型以 JSON 输出 tool_calls,自动回填到响应(`tool_calls` / `function_call` / `tool_use`);回复被上游输出上限截断时自动多轮续写拼接(`continue_rounds`),仍不闭合则在截断处就近补全为部分工具调用返回,由客户端下一轮继续写
|
||||
- **三站点聚合**:单站点每日额度耗尽自动切换到其他站点的同款模型,额度耗尽的站点临时降级 10 分钟
|
||||
- **站点额度管理**:额度按访客身份按日计算,控制台站点卡片可一键查询当前额度、重置访客身份(等同新开隐私窗口、额度即刻回满);请求撞额度时也会自动轮换访客身份恢复,无需重新求解
|
||||
- **代理线路**:主线路 + 多条备用线路,支持 `http://` `https://` `socks5://`;slow-start 检测 + 失败降级 + 流中失败不重放
|
||||
- **Turnstile 自动突破**:三种 Token 获取方式 — 求解服务 API(`provider="api"`)、本地浏览器自动求解(`provider="browser"`,CDP 直连 Chrome/Edge,无需 Playwright/Docker)、手动导入 Cookie(`provider="manual"`);结果按「代理线路 × 站点」缓存,TTL 过期自动重解
|
||||
- **会话自愈**:nonce / data-config 缓存 + 配额耗尽 / 验证失败时自动刷新重试
|
||||
|
||||
@@ -382,6 +382,20 @@ func (s *Server) handleActions(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.Unmarshal(body, &p)
|
||||
writeJSON(w, http.StatusOK, s.probeSite(p.Site))
|
||||
|
||||
case "site_balance":
|
||||
var p struct {
|
||||
Site string `json:"site"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &p)
|
||||
writeJSON(w, http.StatusOK, s.siteBalance(p.Site))
|
||||
|
||||
case "rotate_guest":
|
||||
var p struct {
|
||||
Site string `json:"site"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &p)
|
||||
writeJSON(w, http.StatusOK, s.rotateGuest(p.Site))
|
||||
|
||||
default:
|
||||
fail(w, "unknown action: "+req.Action)
|
||||
}
|
||||
@@ -574,6 +588,71 @@ func firstNonEmpty(vals ...string) string {
|
||||
|
||||
// siteCodes resolves a requested site code to a list; an empty request means
|
||||
// every configured site.
|
||||
// siteBalance reports the free daily tier of the visitor id currently
|
||||
// cached for the site (same endpoint the site's own balance badge uses).
|
||||
func (s *Server) siteBalance(code string) map[string]any {
|
||||
site, ok, proxy, botID := s.balanceContext(code)
|
||||
if !ok {
|
||||
return map[string]any{"ok": false, "error": "site not found"}
|
||||
}
|
||||
gid, has := s.ts.GuestID(code, proxy)
|
||||
if !has {
|
||||
return map[string]any{"ok": false, "error": "no cached session for this site — send a chat request first"}
|
||||
}
|
||||
return s.balanceJSON(code, site, gid, botID, proxy, false)
|
||||
}
|
||||
|
||||
// rotateGuest swaps the site's visitor cookie for a fresh id — the site
|
||||
// hands every new visitor id its own daily free tier, the same effect as a
|
||||
// new private window — then reports the new identity's balance.
|
||||
func (s *Server) rotateGuest(code string) map[string]any {
|
||||
site, ok, proxy, botID := s.balanceContext(code)
|
||||
if !ok {
|
||||
return map[string]any{"ok": false, "error": "site not found"}
|
||||
}
|
||||
gid := s.ts.RotateGuest(code)
|
||||
if gid == "" {
|
||||
return map[string]any{"ok": false, "error": "no cached session for this site — refresh cookies first"}
|
||||
}
|
||||
return s.balanceJSON(code, site, gid, botID, proxy, true)
|
||||
}
|
||||
|
||||
func (s *Server) balanceContext(code string) (config.Site, bool, string, int) {
|
||||
s.cfg.RLock()
|
||||
defer s.cfg.RUnlock()
|
||||
st, ok := s.cfg.Sites[code]
|
||||
if !ok {
|
||||
return config.Site{}, false, "", 0
|
||||
}
|
||||
botID := 0
|
||||
for _, m := range s.cfg.Models {
|
||||
if m.Site == code && m.Enabled {
|
||||
botID = m.BotID
|
||||
break
|
||||
}
|
||||
}
|
||||
return *st, true, s.cfg.Proxy.URL, botID
|
||||
}
|
||||
|
||||
func (s *Server) balanceJSON(code string, site config.Site, gid string, botID int, proxy string, rotated bool) map[string]any {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
bal, err := s.up.FetchBalance(ctx, site, gid, botID, upstream.Route{Name: "balance", Proxy: proxy})
|
||||
if err != nil {
|
||||
return map[string]any{"ok": false, "error": err.Error()}
|
||||
}
|
||||
out := map[string]any{
|
||||
"ok": true, "site": code, "rotated": rotated,
|
||||
"balance": bal.Balance, "limit": bal.Free.Limit,
|
||||
"used": bal.Free.Used, "remaining": bal.Free.Remaining,
|
||||
"reset_period": bal.Free.ResetPeriod,
|
||||
}
|
||||
if rotated {
|
||||
s.log.Info("visitor identity rotated from console", "site", code, "remaining", bal.Free.Remaining)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) siteCodes(want string) []string {
|
||||
s.cfg.RLock()
|
||||
defer s.cfg.RUnlock()
|
||||
|
||||
@@ -178,7 +178,9 @@ PAGES.dashboard = async () => {
|
||||
<div class="toolbar">
|
||||
<div><b>${esc(s.code.toUpperCase())}</b> <span class="pill ${s.enabled ? "on" : "off"}">${s.enabled ? "启用" : "停用"}</span>
|
||||
<div class="dim">${esc(s.base_url)}</div></div>
|
||||
<div class="right"><button class="btn small" data-probe="${esc(s.code)}">探测</button></div>
|
||||
<div class="right"><button class="btn small" data-probe="${esc(s.code)}">探测</button>
|
||||
<button class="btn small" data-quota="${esc(s.code)}">额度</button>
|
||||
<button class="btn small" data-rotate="${esc(s.code)}">重置</button></div>
|
||||
</div>
|
||||
<div class="dim" style="margin-top:8px">请求 ${fmtInt(ms ? ms.requests : 0)} · 错误 ${fmtInt(ms ? ms.errors : 0)} · Cookie ${ts.valid ? "有效 " + Math.round(ts.remaining_s / 60) + " 分钟" : "未建立"}</div>
|
||||
<div class="bar"><i style="width:${pct}%"></i></div></div>`;
|
||||
@@ -193,6 +195,25 @@ PAGES.dashboard = async () => {
|
||||
b.disabled = false; b.textContent = "探测";
|
||||
});
|
||||
|
||||
$$("[data-quota]").forEach(b => b.onclick = async () => {
|
||||
b.disabled = true; b.textContent = "…";
|
||||
try {
|
||||
const r = await api("/api/actions", { body: { action: "site_balance", site: b.dataset.quota } });
|
||||
const period = r.reset_period === "daily" ? "每日" : (r.reset_period || "");
|
||||
toast(r.ok ? `${b.dataset.quota}: 剩余 ${fmtInt(r.remaining)} / ${fmtInt(r.limit)} · 已用 ${fmtInt(r.used)} · ${period}重置` : `${b.dataset.quota}: ${r.error || "失败"}`, r.ok ? "ok" : "err");
|
||||
} catch (e) { toast(e.message, "err"); }
|
||||
b.disabled = false; b.textContent = "额度";
|
||||
});
|
||||
|
||||
$$("[data-rotate]").forEach(b => b.onclick = async () => {
|
||||
b.disabled = true; b.textContent = "…";
|
||||
try {
|
||||
const r = await api("/api/actions", { body: { action: "rotate_guest", site: b.dataset.rotate } });
|
||||
toast(r.ok ? `${b.dataset.rotate}: 已换新访客身份,剩余 ${fmtInt(r.remaining)} / ${fmtInt(r.limit)}` : `${b.dataset.rotate}: ${r.error || "失败"}`, r.ok ? "ok" : "err");
|
||||
} catch (e) { toast(e.message, "err"); }
|
||||
b.disabled = false; b.textContent = "重置";
|
||||
});
|
||||
|
||||
const rec = (m.recent || []).slice(0, 30);
|
||||
$("#tb-recent").innerHTML = rec.length ? rec.map(r => `
|
||||
<tr><td class="dim">${timeStr(r.at)}</td>
|
||||
|
||||
Reference in new issue
Block a user