Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 19 additions & 13 deletions cmd/zen-gate/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,31 +236,37 @@ func main() {
ln.OnChange = syncEndpoints
gw.SetModelsSync(syncEndpoints)
ln.StartLoops(ctx, time.Duration(cfg.ProbeIntervalMinutes)*time.Minute)
// WorkBuddy 每日活跃:本地日历日一次,用免费模型发一条极小对话领取活跃
// 积分。启动 90s 后先查一次(覆盖"当天已登录"的场景),之后每小时补查;
// 已完成/未登录的检查是纯日期比较,重复触发没有成本。
// WorkBuddy 每日任务:本地日历日一次,按版本各走各的路 —— 国际版用免费
// 模型发一条极小对话领活跃积分,国内版直接调 daily-checkin 签到(国际版
// 没有这个端点)。启动 90s 后先查一次(覆盖"当天已登录"的场景),之后每
// 小时补查;已完成/未登录的判断是纯日期比较,重复触发没有成本。
go func() {
time.Sleep(90 * time.Second)
runWBActivity := func() {
ran, err := ln.WorkBuddyDailyActivity(ctx)
if !ran {
return
runWBDaily := func() {
if ran, err := ln.WorkBuddyDailyActivity(ctx); ran {
if err != nil {
logger.Infof("WorkBuddy 每日活跃未完成(下个小时重试): %v", err)
} else {
logger.Infof("WorkBuddy 每日活跃已完成")
}
}
if err != nil {
logger.Infof("WorkBuddy 每日活跃未完成(下个小时重试): %v", err)
return
if ran, err := ln.WorkBuddyDailyCheckin(ctx); ran {
if err != nil {
logger.Infof("WorkBuddy 国内版签到未完成(下个小时重试): %v", err)
} else {
logger.Infof("WorkBuddy 国内版每日签到已完成")
}
}
logger.Infof("WorkBuddy 每日活跃已完成")
}
runWBActivity()
runWBDaily()
t := time.NewTicker(time.Hour)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
runWBActivity()
runWBDaily()
}
}
}()
Expand Down
4 changes: 3 additions & 1 deletion internal/gateway/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,11 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request, rest string
case rest == "workbuddy/status" && r.Method == http.MethodGet:
s.adminWorkBuddyStatus(w, r)
case rest == "workbuddy/login/start" && r.Method == http.MethodPost:
s.adminWorkBuddyLoginStart(w)
s.adminWorkBuddyLoginStart(w, r)
case rest == "workbuddy/login/poll" && r.Method == http.MethodGet:
s.adminWorkBuddyLoginPoll(w)
case rest == "workbuddy/checkin" && r.Method == http.MethodPost:
s.adminWorkBuddyCheckin(w)
case rest == "workbuddy/logout" && r.Method == http.MethodPost:
s.adminWorkBuddyLogout(w)
case rest == "providers" && r.Method == http.MethodPost:
Expand Down
34 changes: 29 additions & 5 deletions internal/gateway/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1192,10 +1192,15 @@ <h3>CodeArts 账号渠道(华为云) <span class="muted" style="font-size:11
</div>
</div>
<div class="card" id="wb-card">
<h3>WorkBuddy 账号渠道(国际版) <span class="muted" style="font-size:11px" id="wb-state"></span></h3>
<h3>WorkBuddy 账号渠道 <span class="muted" style="font-size:11px" id="wb-state"></span></h3>
<div class="muted" style="font-size:12px; line-height:1.7" id="wb-body">加载中…</div>
<div style="margin-top:8px; display:flex; gap:8px; flex-wrap:wrap; align-items:center">
<select id="wb-realm" style="font-size:12px; padding:4px 6px">
<option value="intl">国际版 workbuddy.ai</option>
<option value="cn">国内版 codebuddy.cn</option>
</select>
<button class="act" id="wb-login">授权登录</button>
<button class="ghost" id="wb-checkin" style="display:none">立即签到</button>
<button class="ghost" id="wb-logout">退出登录</button>
<button class="act off" id="tg-workbuddy">关闭</button>
</div>
Expand Down Expand Up @@ -1349,15 +1354,24 @@ <h3 id="pv-form-title">添加供应商</h3>
setToggle("#tg-workbuddy", !!st.enabled);
const lg = $("#wb-logout");
if(lg) lg.style.display = st.loggedIn ? "" : "none";
const realmSel = $("#wb-realm");
if(realmSel) realmSel.style.display = st.loggedIn ? "none" : "";
const ck = $("#wb-checkin");
if(ck) ck.style.display = st.canCheckin ? "" : "none";
if(!st.loggedIn){
state.textContent = "未登录";
body.innerHTML = '用 WorkBuddy(www.workbuddy.ai)账号授权后,三个免费模型(hy4-preview-f / hy3 / deepseek-v4.1-flash)直接进入「模型」页和所有 Agent 选择器,每日自动活跃领取积分。免费模型走 24h 窗口限量;提示词可能被上游记录,别交机密内容。';
body.innerHTML = '用 WorkBuddy / CodeBuddy 账号授权后,免费模型(hy4-preview-f / hy3 / deepseek-v4.1-flash)直接进入「模型」页和所有 Agent 选择器。国际版每日自动发一条极小对话领活跃积分,国内版每日自动签到。免费模型走 24h 窗口限量;提示词可能被上游记录,别交机密内容。';
return;
}
state.textContent = st.enabled ? "已启用" : "已停用";
const exp = st.expiresAtMs ? new Date(st.expiresAtMs).toLocaleString() : "未知";
body.innerHTML = '账号 <b>' + (st.nickname || st.uid || "-") + '</b> · 登录有效期至 ' + exp +
' · 积分 <b>' + (st.credits === "" ? "—" : st.credits) + '</b> · 上次活跃 ' + (st.lastActivityDate || "—") +
const realmLabel = st.realm === "cn" ? "国内版 codebuddy.cn" : "国际版 workbuddy.ai";
const dailyLabel = st.realm === "cn"
? ' · 上次签到 ' + (st.lastCheckinDate || "—")
: ' · 上次活跃 ' + (st.lastActivityDate || "—");
body.innerHTML = '账号 <b>' + (st.nickname || st.uid || "-") + '</b> · ' + realmLabel +
' · 登录有效期至 ' + exp +
' · 积分 <b>' + (st.credits === "" ? "—" : st.credits) + '</b>' + dailyLabel +
(st.detail ? '<br><span style="color:#c88">' + st.detail + '</span>' : '');
}
function bindWorkBuddyCard(){
Expand All @@ -1367,7 +1381,8 @@ <h3 id="pv-form-title">添加供应商</h3>
$("#wb-login").onclick = async () => {
const b = $("#wb-login");
b.disabled = true;
const j = await api("/workbuddy/login/start", {});
const realm = ($("#wb-realm") && $("#wb-realm").value) || "intl";
const j = await api("/workbuddy/login/start", { realm });
b.disabled = false;
if (!j || !j.authUrl) return;
// 服务端已用系统默认浏览器打开授权页(复用你已有的 workbuddy.ai 登录态);
Expand All @@ -1393,6 +1408,15 @@ <h3 id="pv-form-title">添加供应商</h3>
}
}, 2000);
};
$("#wb-checkin").onclick = async () => {
const b = $("#wb-checkin");
b.disabled = true;
const j = await api("/workbuddy/checkin", {});
b.disabled = false;
if (!j) { renderWorkBuddyCard(); return; }
toast(j.skipped ? "今日已签到(每日一次)" : "国内版每日签到已完成");
renderWorkBuddyCard();
};
$("#wb-logout").onclick = async () => {
const j = await api("/workbuddy/logout", {});
if (j) toast("已退出 WorkBuddy 账号");
Expand Down
62 changes: 56 additions & 6 deletions internal/gateway/workbuddy.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package gateway

import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -55,15 +58,58 @@ func (s *Server) adminWorkBuddyStatus(w http.ResponseWriter, r *http.Request) {
"nickname": acct.Nickname,
"expiresAtMs": acct.ExpiresAtMS,
"lastActivityDate": acct.LastActivityDate,
"credits": credits,
"detail": detail,
"lastCheckinDate": acct.LastCheckinDate,
"realm": acct.Realm,
// canCheckin is the card's cue to show the 签到 button: only the CN
// publishment publishes the endpoint, and only for non-enterprise
// accounts. False never hides a button that would have worked — the
// lane re-checks on the click itself.
"canCheckin": acct.Realm == lane.WBRealmCN && acct.AccessToken != "",
"credits": credits,
"detail": detail,
})
}

// adminWorkBuddyCheckin runs the CN daily checkin on demand. The scheduler
// claims it once a day on its own; this is the "did it actually land?" button.
func (s *Server) adminWorkBuddyCheckin(w http.ResponseWriter) {
ran, err := s.Lane.WorkBuddyDailyCheckin(context.Background())
if err != nil {
writeJSON(w, 502, map[string]any{"ok": false, "error": err.Error()})
return
}
if !ran {
// Either not logged in, not a CN account, or already claimed today.
writeJSON(w, 200, map[string]any{"ok": true, "skipped": true})
return
}
if s.logger != nil {
s.logger.Infof("WorkBuddy 国内版每日签到已完成")
}
writeJSON(w, 200, map[string]any{"ok": true})
}

// adminWorkBuddyLoginStart mints a login state and hands the auth URL back;
// the card opens it in a new tab and starts polling.
func (s *Server) adminWorkBuddyLoginStart(w http.ResponseWriter) {
authURL, err := s.Lane.WorkBuddyLoginStart()
// the card opens it in a new tab and starts polling. ?realm=cn opens the
// login against the CN publishment instead — the two do not share a login
// state, so the choice has to be made before the browser is sent anywhere.
// the card posts it (api() sends a JSON body); both accepted so the endpoint
// does not care which way the caller talks.
func (s *Server) adminWorkBuddyLoginStart(w http.ResponseWriter, r *http.Request) {
realm := strings.TrimSpace(r.URL.Query().Get("realm"))
if realm == "" {
var in struct {
Realm string `json:"realm"`
}
if data, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)); err == nil {
_ = json.Unmarshal(data, &in)
}
realm = strings.TrimSpace(in.Realm)
}
if realm != lane.WBRealmCN {
realm = lane.WBRealmIntl
}
authURL, err := s.Lane.WorkBuddyLoginStart(realm)
if err != nil {
writeJSON(w, 502, map[string]any{"ok": false, "error": err.Error()})
return
Expand All @@ -74,7 +120,11 @@ func (s *Server) adminWorkBuddyLoginStart(w http.ResponseWriter) {
// mechanism as the tray's 打开管理页.
tray.Open(authURL)
if s.logger != nil {
s.logger.Infof("WorkBuddy 登录已开始,授权页已在默认浏览器打开")
if realm == lane.WBRealmCN {
s.logger.Infof("WorkBuddy 国内版登录已开始,授权页已在默认浏览器打开")
} else {
s.logger.Infof("WorkBuddy 登录已开始,授权页已在默认浏览器打开")
}
}
writeJSON(w, 200, map[string]any{"ok": true, "authUrl": authURL})
}
Expand Down
4 changes: 4 additions & 0 deletions internal/lane/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ type Lane struct {
wbRefreshedAt time.Time
wbLoginState string
wbLoginAt time.Time
// wbLoginRealm is the region the in-flight login was opened against. The
// browser finishes the login on that publishment, so the state poll and
// the token it mints must both go back to the same one.
wbLoginRealm string

probing atomicFlag
lastProbeAt time.Time
Expand Down
Loading
Loading