Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- 实现 `schedule_reminder` 的 `daily`、`weekly` 和 `weekdays` 重复触发;每次触发
会生成下一周期的独立任务,并按客户端时区保留本地时间。
- 后台 Session 模型覆盖统一使用 ACP `configOptions` 与
`session/set_config_option`,不再调用私有模型接口或生成后台配置文件;未声明标准
模型选项的 Agent 将沿用自身配置。OpenCode/OpenClaw 一键托管初始化保持不变。
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ AGENT_PROTOCOL=openclaw
QWEN_AUDIO_AGENT_BACKEND_MODEL=qwen3.7-max
```

Before starting, create a key from the [Bailian API Key page](https://bailian.console.aliyun.com/?tab=model#/api-key).
Eligible new users can review the [new-user free quota](https://help.aliyun.com/zh/model-studio/new-free-quota)
and check remaining usage on the [model usage page](https://help.aliyun.com/zh/model-studio/model-usage-statistics).
Quota and billing rules are subject to the current official Bailian documentation.

> Uses DashScope realtime voice frontend by default; alternatively, switch to a local [speech-to-speech frontend](docs/voice-frontends/speech-to-speech.md), no cloud API Key needed.

2. Start the Gateway, then open another terminal to start the TUI (or use `qwenaudio webui` for the browser UI):
Expand Down
5 changes: 5 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ AGENT_PROTOCOL=openclaw
QWEN_AUDIO_AGENT_BACKEND_MODEL=qwen3.7-max
```

开始前请先在[百炼 API Key 页面](https://bailian.console.aliyun.com/?tab=model#/api-key)
创建 Key;符合条件的新用户可在[新人免费额度说明](https://help.aliyun.com/zh/model-studio/new-free-quota)
中查看额度规则,并在[模型用量页面](https://help.aliyun.com/zh/model-studio/model-usage-statistics)
查看剩余额度。额度和计费规则以百炼官方页面为准。

> 默认使用 DashScope 实时语音前台;也可切换为本地 [speech-to-speech 前台](docs/voice-frontends/speech-to-speech.zh.md),无需云端 API Key。


Expand Down
14 changes: 10 additions & 4 deletions docs/getting-started/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,18 @@ simultaneously. Set `QWAUDIO_CONFIG_DIR` or

## Obtain a DashScope API Key

Alibaba Cloud Model Studio (Bailian) provides a
[free trial quota](https://help.aliyun.com/zh/model-studio/new-free-quota) for Qwen Audio 3.0 Realtime. After creating an API Key,
you can start using qwen-audio-agent for free.
Alibaba Cloud Model Studio (Bailian) automatically provides eligible new users with a
[new-user free quota](https://help.aliyun.com/zh/model-studio/new-free-quota), which normally does not require a separate claim.
See the official [free-quota guide](https://help.aliyun.com/zh/model-studio/new-free-quota)
for current eligibility, region, validity, and stop-when-exhausted rules. You can also
open the [model usage page](https://help.aliyun.com/zh/model-studio/model-usage-statistics)
to check remaining quota. Quota and billing rules can vary by region, model, and account
status; follow the official Bailian pages for the current rules.

1. Open the [API Key page](https://bailian.console.aliyun.com/?tab=model#/api-key) in the Bailian console,
log in to your account, and click **Create API Key**.
2. Copy the generated Key and fill it into `config.env` later. Do not publicly share or commit your API Key.

For detailed instructions, see the [official Bailian documentation](https://help.aliyun.com/zh/model-studio/get-api-key).
For detailed instructions, see [Get and configure an API Key](https://help.aliyun.com/zh/model-studio/get-api-key).
After quota is exhausted, verified accounts may continue with pay-as-you-go billing, so
enable the official stop-when-exhausted option when appropriate.
11 changes: 7 additions & 4 deletions docs/getting-started/install.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,15 @@ CLI 与桌面版共享 `~/.config/qwaudio/config.env`(设置、身份、记忆

## 获取 DashScope API Key

阿里云百炼为 Qwen Audio 3.0 Realtime 提供
[新人免费额度](https://help.aliyun.com/zh/model-studio/new-free-quota),创建 API Key 后
即可免费开始使用 qwen-audio-agent。
阿里云百炼为符合条件的新用户自动发放
[新人免费额度](https://help.aliyun.com/zh/model-studio/new-free-quota),通常不需要另行领取。
可以在官方的[免费额度说明](https://help.aliyun.com/zh/model-studio/new-free-quota)中查看
领取、地域、有效期和用完即停规则,也可以打开[模型用量页面](https://help.aliyun.com/zh/model-studio/model-usage-statistics)
查看剩余额度。免费额度和计费规则可能随地域、模型及账号状态变化,请以百炼官方页面为准。

1. 打开百炼控制台的 [API Key 页面](https://bailian.console.aliyun.com/?tab=model#/api-key),
登录账号,单击**创建 API Key**。
2. 复制生成的 Key,稍后填入 `config.env`。请勿公开或提交 API Key。

详细说明见[百炼官方文档](https://help.aliyun.com/zh/model-studio/get-api-key)。
详细说明见[获取与配置 API Key](https://help.aliyun.com/zh/model-studio/get-api-key);
如果账号已完成实名认证,免费额度用完后可能继续按量付费,建议按需开启免费额度用完即停。
176 changes: 176 additions & 0 deletions server/src/task/recurrence.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
const RECURRENCE_VALUES = new Set([
'once',
'daily',
'weekly',
'weekdays',
])

const FORMATTERS = new Map()

export function normalizeRecurrence(value) {
const recurrence = String(value || '').trim().toLowerCase()
return RECURRENCE_VALUES.has(recurrence) ? recurrence : 'once'
}

export function normalizeTimeZone(value) {
const timeZone = String(value || '').trim()
if (!timeZone) return 'UTC'
try {
formatterFor(timeZone)
return timeZone
} catch {
return 'UTC'
}
}

function formatterFor(timeZone) {
let formatter = FORMATTERS.get(timeZone)
if (!formatter) {
formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
})
FORMATTERS.set(timeZone, formatter)
}
return formatter
}

function localParts(at, timeZone) {
const parts = Object.create(null)
for (const part of formatterFor(timeZone).formatToParts(new Date(at))) {
if (part.type !== 'literal') parts[part.type] = Number(part.value)
}
return parts
}

function wallClockEpoch(parts) {
return Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
)
}

function addCalendarDays(parts, days) {
const date = new Date(Date.UTC(
parts.year,
parts.month - 1,
parts.day + days,
))
return {
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate(),
hour: parts.hour,
minute: parts.minute,
second: parts.second,
millisecond: parts.millisecond,
}
}

function weekday(parts) {
return new Date(Date.UTC(
parts.year,
parts.month - 1,
parts.day,
)).getUTCDay()
}

function offsetsAround(at, timeZone) {
const offsets = new Set()
const dayMs = 86_400_000
const hourMs = 3_600_000
for (let delta = -3 * dayMs; delta <= 3 * dayMs; delta += hourMs) {
const probe = at + delta
offsets.add(wallClockEpoch(localParts(probe, timeZone)) - probe)
}
return [...offsets]
}

/**
* Convert a wall-clock date in an IANA timezone back to an instant.
*
* A local time can be ambiguous or nonexistent around a timezone transition.
* Check all offsets observed around the target date so repeated hours choose
* the earliest valid instant and skipped hours move forward past the gap.
*/
function zonedPartsToEpoch(parts, timeZone) {
const target = wallClockEpoch(parts)
const exact = []
const after = []
const before = []
for (const offset of offsetsAround(target, timeZone)) {
const candidate = target - offset
const actual = localParts(candidate, timeZone)
const actualWallClock = wallClockEpoch(actual)
if (actualWallClock === target) exact.push(candidate)
else if (actualWallClock > target) after.push({ candidate, actualWallClock })
else before.push({ candidate, actualWallClock })
}
if (exact.length) return Math.min(...exact) + parts.millisecond
if (after.length) {
after.sort((left, right) => (
left.actualWallClock - right.actualWallClock
|| left.candidate - right.candidate
))
return after[0].candidate + parts.millisecond
}
before.sort((left, right) => (
right.actualWallClock - left.actualWallClock
|| right.candidate - left.candidate
))
return (before[0]?.candidate ?? target) + parts.millisecond
}

/**
* Return the first occurrence strictly after `now` for a recurring schedule.
* Missed occurrences are coalesced into one catch-up event, then the next
* future calendar occurrence is scheduled.
*/
export function nextOccurrenceAt(
at,
recurrence,
{ now = Date.now(), timeZone = 'UTC' } = {},
) {
const start = Number(at)
const current = Number(now)
const normalized = normalizeRecurrence(recurrence)
if (
normalized === 'once'
|| !Number.isFinite(start)
|| !Number.isFinite(current)
) return null

const zone = normalizeTimeZone(timeZone)
const initial = new Date(start)
const startParts = {
...localParts(start, zone),
millisecond: initial.getUTCMilliseconds(),
}
const step = normalized === 'weekly' ? 7 : 1
let days = step

// A guard protects the scheduler if a future recurrence mode is added but
// its calendar rules are not wired here yet.
for (let attempt = 0; attempt < 3700; attempt += 1) {
const candidateParts = addCalendarDays(startParts, days)
if (
normalized !== 'weekdays'
|| ![0, 6].includes(weekday(candidateParts))
) {
const candidate = zonedPartsToEpoch(candidateParts, zone)
if (candidate > current) return candidate
}
days += step
}
return null
}
58 changes: 50 additions & 8 deletions server/src/task/reminder-scheduler.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { TaskDomainEvent } from './task-events.mjs'
import { TaskStatus, transitionTask } from './task-state.mjs'
import { nextOccurrenceAt, normalizeRecurrence } from './recurrence.mjs'

/**
* ReminderScheduler — setTimeout-driven scheduler for scheduled tasks.
Expand Down Expand Up @@ -68,16 +69,60 @@ export class ReminderScheduler {
overdue.forEach((task, index) => {
const delay = index * this.staggerMs
const timer = setTimeout(() => {
if (task.status !== 'scheduled') return
transitionTask(task, TaskStatus.QUEUED)
this.taskManager.emit(TaskDomainEvent.SCHEDULED_FIRED, task)
if (!this.fireTask(task)) return
this.taskManager.persistDeferred()
this.taskManager.drain()
}, delay)
timer.unref?.()
})
}

fireTask(task, now = Date.now()) {
if (task.status !== 'scheduled') return false
transitionTask(task, TaskStatus.QUEUED)
const recurrence = normalizeRecurrence(task.schedule?.recurrence)
const nextAt = nextOccurrenceAt(
task.schedule?.at,
recurrence,
{
now,
timeZone: task.schedule?.timeZone,
},
)
if (nextAt) this.scheduleNext(task, nextAt, recurrence)
this.taskManager.emit(TaskDomainEvent.SCHEDULED_FIRED, task)
return true
}

scheduleNext(task, at, recurrence) {
const runner = typeof task.runner === 'function'
? task.runner
: task.kind === 'scheduled_task'
? this.taskManager.scheduledTaskRunner
: null
const next = this.taskManager.createScheduled({
objective: task.objective,
ownerId: task.ownerId,
sessionId: task.sessionId,
turnId: task.turnId,
schedule: {
at,
recurrence,
timeZone: task.schedule?.timeZone,
},
type: task.kind === 'scheduled_task' ? 'task' : 'reminder',
timeoutMs: task.timeoutMs,
runner,
})
this.logger?.debug?.('reminder.rescheduled', {
taskId: task.id,
nextTaskId: next.id,
recurrence,
executeAt: new Date(at).toISOString(),
})
return next
}

/**
* Register a single setTimeout for the next due scheduled task.
* Called after every create, fire, or cancel.
Expand All @@ -101,14 +146,11 @@ export class ReminderScheduler {
/**
* Fire all due scheduled tasks: status scheduled → queued, then drain.
*/
fire() {
const now = Date.now()
fire(now = Date.now()) {
let fired = 0
for (const task of this.taskManager.tasks.values()) {
if (task.status === 'scheduled' && task.schedule?.at <= now) {
transitionTask(task, TaskStatus.QUEUED)
// Phase 3: recurrence handling (create next cycle's new scheduled task)
fired += 1
fired += this.fireTask(task, now) ? 1 : 0
}
}
if (fired) {
Expand Down
13 changes: 11 additions & 2 deletions server/src/task/task-manager.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
import { logger } from '../core/logger.mjs'
import { BackendEventType } from '../core/backend-events.mjs'
import { SessionJournalRegistry } from '../session/session-journal-registry.mjs'
import { normalizeRecurrence, normalizeTimeZone } from './recurrence.mjs'

export function taskExecutionContext(task, { onEvent, signal }) {
return Object.freeze({
Expand Down Expand Up @@ -489,12 +490,13 @@ export class TaskManager {
ownerId,
sessionId,
turnId,
schedule: { at, recurrence = 'once' } = {},
schedule: { at, recurrence = 'once', timeZone = null } = {},
type = 'reminder',
timeoutMs = null,
runner = null,
}) {
const kind = type === 'task' ? 'scheduled_task' : 'reminder'
const normalizedRecurrence = normalizeRecurrence(recurrence)
const task = {
id: this.allocateTaskId(),
status: 'scheduled',
Expand All @@ -506,7 +508,14 @@ export class TaskManager {
turnId: turnId || null,
priority: 0,
parentTaskId: null,
schedule: { type: 'at', at: Number(at), recurrence },
schedule: {
type: 'at',
at: Number(at),
recurrence: normalizedRecurrence,
...(normalizedRecurrence === 'once'
? {}
: { timeZone: normalizeTimeZone(timeZone) }),
},
timeoutMs: type === 'task'
? Number(timeoutMs) || config.scheduledTaskTimeoutMs
: null,
Expand Down
2 changes: 1 addition & 1 deletion server/src/voice/frontend-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ const scheduleReminderTool = {
recurrence: {
type: 'string',
enum: ['once', 'daily', 'weekly', 'weekdays'],
description: '重复模式,默认 once。',
description: '重复模式,默认 once;daily=每天,weekly=每周,weekdays=每周一至周五。重复提醒按客户端时区保留本地时间。',
},
},
required: ['execute_at', 'reminder'],
Expand Down
Loading
Loading