import { addLocalDays, getLocalParts, normalizeTimezone, zonedTimeToEpochMs, } from './schedule-time.mjs'; import { hasReminderSetupCue, isWeeklyScheduleCue, normalizeScheduleUtterance, } from './schedule-utterance-normalize.mjs'; function normalizeText(text) { return String(text ?? '') .replace(/^(?:嗯|那个|就是|然后|呃|能不能|请|帮我|麻烦|嗨|好的|好)+/u, '') .replace(/\s+/g, '') .trim(); } /** ASR/wechat often inserts spaces around colons, e.g. "17 :08". */ function normalizeLooseTimeText(text) { return normalizeScheduleUtterance(text); } function pad2(value) { return String(value).padStart(2, '0'); } function chineseHourToNumber(value) { const raw = String(value ?? '').trim(); if (/^\d{1,2}$/.test(raw)) return Number(raw); const map = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10, }; if (raw === '十') return 10; if (raw.startsWith('十')) return 10 + (map[raw.slice(1)] ?? 0); if (raw.endsWith('十')) return (map[raw[0]] ?? 0) * 10; if (raw.includes('十')) { const [tens, ones] = raw.split('十'); return (map[tens] ?? 1) * 10 + (map[ones] ?? 0); } return map[raw] ?? null; } export function parseHourMinute(text) { const normalized = normalizeLooseTimeText(text); let match = normalized.match( /(?:今天|明天|后天|今晚|明晚|明早|今早)?(?:早上|上午|清晨|每天(?:早上|上午)?)?(早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)?([0-9]{1,2}|[零一二两三四五六七八九十]{1,3})(?:点|:|:)(半|[0-9]{1,2}分?)?/, ); if (!match) { match = normalized.match( /(?:今天|明天|后天|今晚|明晚|明早|今早)?(?:早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)?(早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)?([0-9]{1,2}|[零一二两三四五六七八九十]{1,3})点(半|[0-9]{1,2}分?)?/, ); } if (!match) return null; const period = String(match[1] ?? ''); let hour = chineseHourToNumber(match[2]); if (hour === null || hour < 0 || hour > 23) return null; let minute = 0; if (match[3] === '半') minute = 30; else if (match[3]) minute = Number(String(match[3]).replace('分', '')); if (!Number.isFinite(minute) || minute < 0 || minute > 59) return null; if (period === '中午') { hour = 12; } else if (/下午|傍晚|晚上/u.test(period) && hour < 12) { hour += 12; } else if (/上午|早上|清晨|凌晨/u.test(period) && hour === 12) { hour = 0; } else if (/今晚/u.test(normalized) && hour < 12 && !/上午|早上|清晨|凌晨/u.test(period)) { hour += 12; } return { hour, minute }; } function countTimeExpressions(text) { const normalized = normalizeLooseTimeText(text); const matches = normalized.match( /[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)(?:半|[0-9]{1,2}(?:分(?!开)|(?![0-9]))?)?/gu, ); return matches?.length ?? 0; } function wantsSimpleTimedReminder(compact, text) { if (isWeeklyScheduleCue(compact)) return false; if (!hasReminderSetupCue(compact)) return false; if (countTimeExpressions(text) !== 1) return false; if (/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:).{1,24}[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)/u.test(compact)) { return false; } return parseHourMinute(text) != null; } function extractTimedReminderTitle(text) { const original = String(text ?? '').trim(); const commaParts = original.split(/[,,]/).map((part) => part.trim()).filter(Boolean); if (commaParts.length > 1) { const tail = commaParts[commaParts.length - 1]; const tailTitle = cleanupTimedReminderTitleFragment(tail); if (tailTitle && tailTitle.length >= 2) return tailTitle; } return cleanupTimedReminderTitleFragment(original); } function cleanupTimedReminderTitleFragment(text) { let title = normalizeLooseTimeText(String(text ?? '').trim()); title = title .replace(/^(?:帮我|请|麻烦)?(?:设置|设|创建|添加)(?:一个|个)?提醒[,,、::\s]*/u, '') .replace(/^(?:帮我|请)?(?:设置|设)(?:一个|个)?(?:待办|代办|带办|代拜)[,,、::\s]*/u, ''); title = title .replace(/^(?:帮我|请|麻烦)?(?:设置|设|加|添加|创建|安排|定)(?:一个|个|一下)?(?:每天|每日|天天)?/u, '') .replace(/^(?:帮我|请|麻烦)?(?:加个|添加|创建|安排)(?:一个|个)?提醒/u, '') .replace(/^(?:帮我|请|麻烦)?(?:定|设)个?(?:闹钟|闹铃)/u, '') .replace(/(?:今天|今日|今晚|明天|后天|明早|今早)/gu, ' ') .replace(/(?:每天|每日|天天)/gu, ' ') .replace(/(?:早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)/gu, ' ') .replace( /[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)(?:半|[0-9]{1,2}(?:\s*分(?!开)|(?![0-9]))?)?/gu, ' ', ) .replace(/(?:提醒我|提醒|闹钟|闹铃|叫我|设置提醒|设提醒|备忘提醒|定时提醒)/gu, ' ') .replace(/[,,、::]/g, ' ') .replace(/\s+/g, ' ') .trim(); title = title .replace(/^(?:嗯|那个|就是|然后|呃|能不能|请|帮我|麻烦|嗨)+/u, '') .replace(/^(?:去|来)?(?:参加|进行)?(?:一个|个)?/u, '') .trim(); title = title.replace(/^分开/u, '开会').replace(/开会会/u, '开会'); if (!title || title.length < 2 || /^(?:请|麻烦|能不能|嗨|嗯|那个|帮我)$/u.test(title)) return null; return title; } export function buildTimedReminderLocal({ text, hour, minute = 0, timezone = 'Asia/Shanghai', now = Date.now(), } = {}) { const compact = normalizeText(text); let dayOffset = 0; if (/后天/u.test(compact)) dayOffset = 2; else if (/明天|明早|明晚/u.test(compact)) dayOffset = 1; else if (/今晚/u.test(compact)) dayOffset = 0; else if (/今早/u.test(compact)) dayOffset = 0; const tz = normalizeTimezone(timezone); let dayStart = addLocalDays(now, dayOffset, tz); let parts = getLocalParts(dayStart, tz); let remindAt = zonedTimeToEpochMs( { year: parts.year, month: parts.month, day: parts.day, hour, minute, second: 0, }, tz, ); if (remindAt <= now) { dayStart = addLocalDays(dayStart, 1, tz); parts = getLocalParts(dayStart, tz); remindAt = zonedTimeToEpochMs( { year: parts.year, month: parts.month, day: parts.day, hour, minute, second: 0, }, tz, ); } const localParts = getLocalParts(remindAt, tz); return `${localParts.year}-${pad2(localParts.month)}-${pad2(localParts.day)} ${pad2(localParts.hour)}:${pad2(localParts.minute)}`; } function parseSimpleTimedReminder(text, { timezone = 'Asia/Shanghai', now = Date.now() } = {}) { const compact = normalizeText(text); if (!wantsSimpleTimedReminder(compact, text)) return null; const time = parseHourMinute(text); if (!time) return null; const title = extractTimedReminderTitle(text); if (!title || title.length < 2) { const needsClarification = time ? ['reminder_title'] : ['reminder_time', 'reminder_title']; const partial = { action: 'create_timed_reminder', needsClarification, }; if (time) { partial.hour = time.hour; partial.minute = time.minute; partial.remindLocal = buildTimedReminderLocal({ text, hour: time.hour, minute: time.minute, timezone, now, }); } return partial; } return { action: 'create_timed_reminder', title, remindLocal: buildTimedReminderLocal({ text, hour: time.hour, minute: time.minute, timezone, now, }), hour: time.hour, minute: time.minute, recurrence: /(?:每天|每日|天天)/u.test(compact) ? 'daily' : 'once', }; } function extractTodoTitle(text) { const original = String(text ?? '').trim(); if (!original) return null; const quoted = original.match(/[「『【((“"']([^」』】))”"']{1,80})[」』】))”"']/); if (quoted?.[1]) { const title = quoted[1].trim(); if (title) return title; } const trailingCommand = original.match( /^(.{1,80}?)(?:帮我)?(?:设|设置|设下|记)(?:一个|下一个)?(?:待办|代办|带办|代拜)(?:吧|一下)?$/u, ); if (trailingCommand?.[1]) { const title = trailingCommand[1].trim(); if (title) return title; } const cleaned = original .replace( /^.*?(?:不用提醒|先记一下|帮我记一下|帮我记|记一下|添加待办|添加任务|记个待办|记个任务|设置一个待办|设置一个代办|设置一个带办|设置一个代拜|设置下一个代拜|设下一个代拜|帮我设置一个待办|帮我设置一个代办|帮我设置一个带办|帮我设置一个代拜|待办|代办|带办|代拜|任务)(?:[::,,、\s]+)?/u, '', ) .trim(); return cleaned || null; } function wantsDailyTodoDigest(compact) { const daily = /每天|每日|天天/.test(compact); const send = /发|发送|推送|提醒|通知|给我/.test(compact); const digest = /(待办|todo|任务).*(记录|列表|清单|安排|摘要|汇总)/.test(compact) || /(今天|今日|当天).*(待办|todo|任务)/.test(compact) || /(待办|todo|任务).*(今天|今日|当天)/.test(compact) || /一天的待办/.test(compact); if (digest && /(?:页面|网页|html|h5)/iu.test(compact)) return false; if (daily && send && digest) return true; // 「早上7点把当天待办发给我」省略了「每天」但语义仍是 digest if (!daily && send && /(当天|今天|今日).*(待办|todo|任务)/.test(compact) && parseHourMinute(compact)) { return true; } return false; } export function parseScheduleIntent(text, { timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', now = Date.now(), } = {}) { const compact = normalizeText(text); if (!compact) return { action: 'none' }; if (wantsDailyTodoDigest(compact)) { const time = parseHourMinute(compact); if (!time) { return { action: 'create_daily_todo_digest', needsClarification: ['digest_time'], }; } return { action: 'create_daily_todo_digest', hour: time.hour, minute: time.minute, }; } const wantsBalanceAlert = /(余额|钱包|账户).*(不足|低于|提醒|预警|通知)|余额不足|低余额|余额预警/.test(compact); if (wantsBalanceAlert) { const thresholdMatch = compact.match(/(?:低于|少于|不足|小于)(\d{1,6})(?:元|块|rmb|人民币|cny)?/i); const thresholdYuan = thresholdMatch ? Number(thresholdMatch[1]) : null; const thresholdCents = thresholdYuan == null ? null : Math.max(0, Math.round(thresholdYuan * 100)); return thresholdCents == null ? { action: 'create_balance_alert', needsClarification: ['threshold'] } : { action: 'create_balance_alert', thresholdCents }; } if (/看看|查看|看下|列出/.test(compact) && /(待办|日程|行程|计划|任务)/.test(compact)) { return { action: 'query_schedule' }; } const timedReminder = parseSimpleTimedReminder(text, { timezone, now }); if (timedReminder) return timedReminder; if (/^(?:帮我|请|麻烦)?(?:设置|设)(?:一个|个|一下)?提醒$/u.test(compact) || /^(?:帮我|请|麻烦)?(?:加个|添加|创建|安排)(?:一个|个)?提醒$/u.test(compact) || /^(?:帮我|请|麻烦)?(?:定|设)个?(?:闹钟|闹铃)$/u.test(compact)) { return { action: 'create_timed_reminder', needsClarification: ['reminder_time', 'reminder_title'], }; } const wantsTodoRecord = /(不用提醒|先记一下|帮我记一下|帮我记|记一下|添加待办|添加任务|记个待办|记个任务|设置一个待办|设置一个代办|设置一个带办|设置一个代拜|设置下一个代拜|设下一个代拜|帮我设置一个待办|帮我设置一个代办|帮我设置一个带办|帮我设置一个代拜|待办|代办|带办|代拜|任务)/.test( compact, ); if (wantsTodoRecord) { const explicitNoReminder = /(不用提醒|不提醒|先记一下)/.test(compact); const hasScheduleTime = /(提醒|闹钟|叫我|今天|今晚|明天|后天|早上|上午|中午|下午|晚上|[0-9零一二两三四五六七八九十]{1,3}(点|:|:))/.test( compact, ); if (!explicitNoReminder && hasScheduleTime) { return { action: 'schedule_agent' }; } const title = extractTodoTitle(text); if (!title) { return { action: 'create_todo', needsClarification: ['todo_title'] }; } return { action: 'create_todo', title }; } return { action: 'none' }; } export function isScheduleIntent(intent) { return intent?.action && intent.action !== 'none'; } export function shouldUseScheduleAssistant(text) { const compact = normalizeText(text); if (!compact) return false; // Scheduled automation (execute + deliver) uses scheduled-task-automation skill. if (/(?:定时(?:自动)?任务|scheduled\s*task|scheduled\s*automation)/iu.test(compact)) return false; if (/(?:每天|每日|每周|定时|到点|届时|自动)/u.test(compact) && /(?:做|生成|制作|创建|写|执行|跑|更新|整理|汇总|推送)/u.test(compact) && !/(?:提醒我|待办记录|待办列表|待办清单|当天待办|一天的待办)/u.test(compact)) { return false; } if (isScheduleIntent(parseScheduleIntent(text))) return true; if (hasReminderSetupCue(compact) && !isWeeklyScheduleCue(compact)) return true; const scheduleKeywords = /(提醒|待办|代办|带办|代拜|日程|行程|安排|计划|闹钟|闹铃)/; const timeKeywords = /(今天|今晚|明天|后天|早上|上午|中午|下午|晚上|\d{1,2}[点::]|[零一二两三四五六七八九十]{1,3}点)/; return scheduleKeywords.test(compact) && timeKeywords.test(compact); } /** Last-resort timed reminder when colloquial phrasing missed parseScheduleIntent. */ export function tryResolveTimedReminderIntent(text, { timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', now = Date.now(), } = {}) { const parsed = parseScheduleIntent(text, { timezone, now }); if (parsed.action === 'create_timed_reminder') return parsed; const compact = normalizeText(text); if (!hasReminderSetupCue(compact) || isWeeklyScheduleCue(compact)) return null; if (countTimeExpressions(text) !== 1 || !parseHourMinute(text)) return null; const time = parseHourMinute(text); const title = extractTimedReminderTitle(text); if (title && title.length >= 2) { return { action: 'create_timed_reminder', title, remindLocal: buildTimedReminderLocal({ text, hour: time.hour, minute: time.minute, timezone, now }), hour: time.hour, minute: time.minute, recurrence: /(?:每天|每日|天天)/u.test(compact) ? 'daily' : 'once', }; } return { action: 'create_timed_reminder', needsClarification: ['reminder_title'], hour: time.hour, minute: time.minute, remindLocal: buildTimedReminderLocal({ text, hour: time.hour, minute: time.minute, timezone, now }), recurrence: /(?:每天|每日|天天)/u.test(compact) ? 'daily' : 'once', }; }