Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,14 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.

### English

- Let WorkBuddy accounts choose their automatic daily check-in time during creation, with a 21:00 retry for failed attempts
- Enlarge the account-name input in the create-account wizard
- Open request-history details immediately with a loading skeleton while the full record is fetched

### 中文

- WorkBuddy 账号创建时可选择每日自动签到时间,失败会在 21:00 重试
- 放大创建账号向导中的账号名称输入框
- 点击请求历史后立即打开详情弹窗,并在完整记录获取期间显示骨架屏

## 0.4.7 - 2026-09-10
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ export function createAccount(
priority?: number
drop_system_prompt?: boolean
workbuddy_auto_checkin?: boolean
workbuddy_checkin_time?: string
},
) {
return api('/api/accounts', {
Expand All @@ -203,6 +204,7 @@ export function createAccount(
priority: options?.priority ?? 50,
drop_system_prompt: options?.drop_system_prompt,
workbuddy_auto_checkin: options?.workbuddy_auto_checkin,
workbuddy_checkin_time: options?.workbuddy_checkin_time,
}),
})
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export type Overview = {
priority?: number
drop_system_prompt?: boolean
workbuddy_auto_checkin?: boolean
workbuddy_checkin_time?: string
status?: string
cooldown_until?: string | null
url?: string
Expand Down
41 changes: 31 additions & 10 deletions frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
const [priority, setPriority] = useState('50')
const [dropSystemPrompt, setDropSystemPrompt] = useState(true)
const [autoCheckin, setAutoCheckin] = useState(false)
const [autoCheckinTime, setAutoCheckinTime] = useState('09:00')
const [pat, setPat] = useState('')
const [json, setJson] = useState('')
const [phase, setPhase] = useState<Phase>('idle')
Expand Down Expand Up @@ -208,6 +209,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
priority: parsedPriority(),
drop_system_prompt: showDropSystem ? dropSystemPrompt : true,
workbuddy_auto_checkin: showAutoCheckin ? autoCheckin : false,
workbuddy_checkin_time: showAutoCheckin ? autoCheckinTime : undefined,
}
}

Expand All @@ -223,6 +225,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
setPriority('50')
setDropSystemPrompt(true)
setAutoCheckin(false)
setAutoCheckinTime('09:00')
setPat('')
setJson('')
setAdvancedOpen(false)
Expand Down Expand Up @@ -371,6 +374,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
priority: options.priority,
drop_system_prompt: options.drop_system_prompt,
workbuddy_auto_checkin: options.workbuddy_auto_checkin,
workbuddy_checkin_time: options.workbuddy_checkin_time,
})
setPhase('done')
setMessage(t('accountImported'))
Expand Down Expand Up @@ -503,6 +507,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {

<section className="mt-5 space-y-2.5">
<Input
className="h-12 text-base"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t('wizardNamePh')}
Expand Down Expand Up @@ -563,17 +568,33 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
</div>
) : null}
{showAutoCheckin ? (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium text-muted">{t('autoCheckin')}</div>
<p className="mt-0.5 text-[11px] leading-4 text-muted">{t('autoCheckinCreateHint')}</p>
<div className="space-y-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-medium text-muted">{t('autoCheckin')}</div>
<p className="mt-0.5 text-[11px] leading-4 text-muted">{t('autoCheckinCreateHint')}</p>
</div>
<CompactSwitch
isSelected={autoCheckin}
isDisabled={settingsLocked}
ariaLabel={t('autoCheckin')}
onChange={setAutoCheckin}
/>
</div>
<CompactSwitch
isSelected={autoCheckin}
isDisabled={settingsLocked}
ariaLabel={t('autoCheckin')}
onChange={setAutoCheckin}
/>
{autoCheckin ? (
<label className="block space-y-1.5 rounded-lg bg-surface-secondary/55 p-3">
<span className="text-xs font-medium text-muted">{t('autoCheckinTime')}</span>
<Input
className="h-11 w-full text-base sm:max-w-48"
type="time"
value={autoCheckinTime}
onChange={(event) => setAutoCheckinTime(event.target.value || '09:00')}
aria-label={t('autoCheckinTime')}
disabled={settingsLocked}
/>
<p className="text-[11px] leading-4 text-muted">{t('autoCheckinTimeHint')}</p>
</label>
) : null}
</div>
) : null}
</div>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/account/AccountCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ export function AccountCard({
<div className="flex items-center justify-between gap-3 text-[11px]">
<Tooltip>
<Tooltip.Trigger>
<span className="font-medium">{t('autoCheckin')}</span>
<span className="font-medium">{t('autoCheckin')} · {account.workbuddy_checkin_time || '09:00'}</span>
</Tooltip.Trigger>
<Tooltip.Content>{t('autoCheckinHint')}</Tooltip.Content>
</Tooltip>
Expand Down
12 changes: 8 additions & 4 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -477,8 +477,10 @@ export const messages: Record<Lang, Dict> = {
dropSystemPrompt: 'Drop system prompt',
dropSystemPromptHint: 'Strip caller system prompts before sending; WorkBuddy still gets an empty leading system slot so Global does not reject the request',
autoCheckin: 'Daily check-in',
autoCheckinHint: 'Opt in to scheduled WorkBuddy credit check-ins around 09:00 and 21:00 local time. Off by default.',
autoCheckinCreateHint: 'WorkBuddy only. Schedule daily credit check-ins. You can change this later on the account card.',
autoCheckinHint: 'Run the daily WorkBuddy credit check-in at the configured local time. Failed attempts retry around 21:00. Off by default.',
autoCheckinCreateHint: 'WorkBuddy only. Enable automatic daily credit check-ins and choose the first attempt time.',
autoCheckinTime: 'Check-in time',
autoCheckinTimeHint: 'Uses server local time and runs within about 14 minutes after the selected time. Failed attempts retry around 21:00.',
checkinNow: 'Check in now',
lastCheckin: 'Last check-in',
lastCheckinNone: 'No check-in yet',
Expand Down Expand Up @@ -1083,8 +1085,10 @@ export const messages: Record<Lang, Dict> = {
dropSystemPrompt: '丢弃系统提示词',
dropSystemPromptHint: '发送前剥离调用方的系统提示词;仍会补一条空的 system,避免国际版要求第一条必须是系统提示词',
autoCheckin: '每日签到',
autoCheckinHint: '按账号开启后,大约在本地 09:00 / 21:00 自动领取 WorkBuddy 积分。默认关闭。',
autoCheckinCreateHint: '仅 WorkBuddy。开启后按日自动签到,之后仍可在账号卡片上改。',
autoCheckinHint: '按账号配置的本地时间自动领取 WorkBuddy 积分;失败会在约 21:00 重试。默认关闭。',
autoCheckinCreateHint: '仅 WorkBuddy。开启后可选择每天首次自动签到的时间。',
autoCheckinTime: '签到时间',
autoCheckinTimeHint: '使用服务器本地时间,并在所选时间后的约 14 分钟内执行;首次失败会在约 21:00 重试。',
checkinNow: '立即签到',
lastCheckin: '最近签到',
lastCheckinNone: '还没有签到记录',
Expand Down
110 changes: 90 additions & 20 deletions internal/accounts/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,7 @@ type ImportAccount struct {
Priority int
DropSystemPrompt *bool
WorkBuddyAutoCheckin *bool
WorkBuddyCheckinTime string
Credential NativeCredential
}

Expand All @@ -936,6 +937,7 @@ func (m *Manager) Import(ctx context.Context, input ImportAccount) (Account, err
Name: input.Name, Provider: input.Provider, Region: input.Region, Enabled: false,
MaxInFlight: input.MaxInFlight, Priority: input.Priority, DropSystemPrompt: input.DropSystemPrompt,
WorkBuddyAutoCheckin: input.WorkBuddyAutoCheckin,
WorkBuddyCheckinTime: input.WorkBuddyCheckinTime,
})
if err != nil {
return Account{}, err
Expand Down Expand Up @@ -1277,6 +1279,7 @@ func (m *Manager) fetchAccountModels(ctx context.Context, item Item) {
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(item.URL, "/")+"/admin/models", nil)
if err != nil {
log.Printf("catalog refresh failed account=%s provider=%s stage=request: %v", item.ID, item.Provider, err)
return
}
if m.config.ProxyAPIKey != "" {
Expand All @@ -1285,16 +1288,20 @@ func (m *Manager) fetchAccountModels(ctx context.Context, item Item) {
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("catalog refresh failed account=%s provider=%s stage=http: %v", item.ID, item.Provider, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
log.Printf("catalog refresh failed account=%s provider=%s stage=status status=%d body=%q", item.ID, item.Provider, resp.StatusCode, strings.TrimSpace(string(body)))
return
}
var parsed struct {
Data []map[string]any `json:"data"`
}
if json.NewDecoder(resp.Body).Decode(&parsed) != nil {
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
log.Printf("catalog refresh failed account=%s provider=%s stage=decode: %v", item.ID, item.Provider, err)
return
}
m.pool.MergeModels(item.ID, catalogIDs(parsed.Data, nil))
Expand Down Expand Up @@ -1528,6 +1535,10 @@ func (m *Manager) CheckinAccount(ctx context.Context, accountID string) (Account
// CheckinOptedIn runs check-in for every enabled WorkBuddy account with
// workbuddy_auto_checkin on. Cooldown accounts are included; disabled skip.
func (m *Manager) CheckinOptedIn(ctx context.Context) {
m.checkinOptedIn(ctx, time.Now(), "", false)
}

func (m *Manager) checkinOptedIn(ctx context.Context, now time.Time, scheduledTime string, retryDue bool) {
if m == nil || m.workbuddy == nil {
return
}
Expand All @@ -1540,7 +1551,16 @@ func (m *Manager) CheckinOptedIn(ctx context.Context) {
if account.Provider != "workbuddy" || !account.Enabled || !account.WorkBuddyAutoCheckin {
continue
}
if checkedInLocalDay(account.LastCheckinAt, account.LastCheckinStatus, time.Now()) {
if scheduledTime != "" {
if retryDue {
if account.WorkBuddyCheckinTime == scheduledTime || !workBuddyCheckinDue(account.WorkBuddyCheckinTime, now) {
continue
}
} else if account.WorkBuddyCheckinTime != scheduledTime {
continue
}
}
if checkedInLocalDay(account.LastCheckinAt, account.LastCheckinStatus, now) {
continue
}
if _, err := m.CheckinAccount(ctx, account.ID); err != nil {
Expand All @@ -1549,6 +1569,15 @@ func (m *Manager) CheckinOptedIn(ctx context.Context) {
}
}

func workBuddyCheckinDue(value string, now time.Time) bool {
parsed, err := time.Parse("15:04", value)
if err != nil {
parsed, _ = time.Parse("15:04", defaultWorkBuddyCheckinTime)
}
due := time.Date(now.Year(), now.Month(), now.Day(), parsed.Hour(), parsed.Minute(), 0, 0, now.Location())
return !due.After(now)
}

// checkedInLocalDay is true when the last recorded check-in is success or
// already on the process-local calendar day. Error rows do not skip, so the
// evening slot can retry a morning miss.
Expand Down Expand Up @@ -1600,14 +1629,23 @@ func (m *Manager) KeepaliveWorkBuddy(ctx context.Context, onlyOptIn bool) {
}
}

// RunWorkBuddyMaintenanceLoop fires check-in near 09:00/21:00 and keepalive
// near 22:00 in the process local zone, with minute jitter. Stop by closing stop.
// RunWorkBuddyMaintenanceLoop fires each opted-in account at its configured
// local time, retries due failures near 21:00, and keeps tokens alive near
// 22:00. Stop by closing stop.
func (m *Manager) RunWorkBuddyMaintenanceLoop(stop <-chan struct{}) {
if m == nil {
return
}
for {
delay, kind := nextWorkBuddyFire(time.Now())
accounts, err := m.store.List(context.Background())
if err != nil {
log.Printf("workbuddy schedule list: %v", err)
}
delay, fire := nextWorkBuddyFire(time.Now(), accounts)
if delay > time.Minute {
delay = time.Minute
fire = workBuddyFire{}
}
timer := time.NewTimer(delay)
select {
case <-stop:
Expand All @@ -1618,38 +1656,70 @@ func (m *Manager) RunWorkBuddyMaintenanceLoop(stop <-chan struct{}) {
return
case <-timer.C:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
switch kind {
case "checkin":
m.CheckinOptedIn(ctx)
case "keepalive":
now := time.Now()
for _, scheduledTime := range fire.checkinTimes {
m.checkinOptedIn(ctx, now, scheduledTime, false)
}
if fire.retry {
m.checkinOptedIn(ctx, now, "21:00", true)
}
if fire.keepalive {
m.KeepaliveWorkBuddy(ctx, true)
}
cancel()
}
}
}

func nextWorkBuddyFire(now time.Time) (time.Duration, string) {
type workBuddyFire struct {
checkinTimes []string
retry bool
keepalive bool
}

func nextWorkBuddyFire(now time.Time, accounts []Account) (time.Duration, workBuddyFire) {
type slot struct {
hour int
time string
kind string
}
slots := []slot{{9, "checkin"}, {21, "checkin"}, {22, "keepalive"}}
slots := []slot{{"21:00", "retry"}, {"22:00", "keepalive"}}
seen := map[string]bool{}
for _, account := range accounts {
if account.Provider != "workbuddy" || !account.Enabled || !account.WorkBuddyAutoCheckin {
continue
}
checkinTime, err := normalizeWorkBuddyCheckinTime(account.WorkBuddyCheckinTime)
if err != nil || seen[checkinTime] {
continue
}
seen[checkinTime] = true
slots = append(slots, slot{checkinTime, "checkin"})
}
loc := now.Location()
var best time.Time
var bestKind string
var fire workBuddyFire
for _, slot := range slots {
candidate := time.Date(now.Year(), now.Month(), now.Day(), slot.hour, 0, 0, 0, loc)
parsed, _ := time.Parse("15:04", slot.time)
candidate := time.Date(now.Year(), now.Month(), now.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc)
candidate = candidate.Add(time.Duration(candidate.Unix()%15) * time.Minute)
if !candidate.After(now) {
candidate = candidate.Add(24 * time.Hour)
}
// Minute jitter 0–14 keeps multi-account fleets off the exact hour.
jitter := time.Duration(candidate.UnixNano()%15) * time.Minute
candidate = candidate.Add(jitter)
if bestKind == "" || candidate.Before(best) {
if best.IsZero() || candidate.Before(best) {
best = candidate
bestKind = slot.kind
fire = workBuddyFire{}
}
if !candidate.Equal(best) {
continue
}
switch slot.kind {
case "checkin":
fire.checkinTimes = append(fire.checkinTimes, slot.time)
case "retry":
fire.retry = true
case "keepalive":
fire.keepalive = true
}
}
return best.Sub(now), bestKind
return best.Sub(now), fire
}
Loading
Loading