Add scheduled task automation skill with worker execution pipeline.
Memind CI / Test, build, and release guards (push) Failing after 18s

Introduce scheduled-task-automation for H5 and WeChat, persist tasks in h5_scheduled_tasks, and run due jobs via a dedicated worker that executes agent tasks and delivers results.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-31 08:02:49 +08:00
parent 4186522c72
commit 81e63c16d3
27 changed files with 1736 additions and 2 deletions
+3
View File
@@ -459,6 +459,9 @@ export function sandboxMcpTools(capabilities) {
'schedule_create_item',
'schedule_create_reminder',
'schedule_list_items',
'scheduled_task_create',
'scheduled_task_list',
'scheduled_task_cancel',
);
}
return tools;
+9
View File
@@ -374,6 +374,9 @@ test('sandboxMcpTools returns correct tool list based on capabilities', () => {
'schedule_create_item',
'schedule_create_reminder',
'schedule_list_items',
'scheduled_task_create',
'scheduled_task_list',
'scheduled_task_cancel',
]);
const withBrowse = { ...base, code_browse: true };
@@ -397,6 +400,9 @@ test('private_data_space alone exposes private data tools through sandbox MCP',
'schedule_create_item',
'schedule_create_reminder',
'schedule_list_items',
'scheduled_task_create',
'scheduled_task_list',
'scheduled_task_cancel',
]);
const policy = buildAgentExtensionPolicy(caps, {
@@ -428,6 +434,9 @@ test('private_data_space alone exposes private data tools through sandbox MCP',
'schedule_create_item',
'schedule_create_reminder',
'schedule_list_items',
'scheduled_task_create',
'scheduled_task_list',
'scheduled_task_cancel',
]);
});
+15
View File
@@ -1,7 +1,10 @@
// Keep browser-safe: do not import user-publish.mjs (uses node:fs/path/url).
import { shouldUseScheduledTaskAutomation } from './scheduled-task-intent.mjs';
const PUBLISH_SKILL_NAME = 'static-page-publish';
export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
export const EXCEL_ANALYST_SKILL_NAME = 'excel-analyst';
export const SCHEDULED_TASK_AUTOMATION_SKILL_NAME = 'scheduled-task-automation';
export const AIDER_DEVELOPMENT_SKILL_NAME = 'aider-development';
export const SKILL_ROUTER_V2_ENV = 'TKMIND_SKILL_ROUTER_V2';
@@ -297,6 +300,12 @@ export function buildChatSkillPrompt(promptKey, skillName) {
'只有用户明确要求 Word / docx 下载时,才先 `load_skill` → `docx-generate`,生成 `public/*.docx` 并确认文件已存在,再写 HTML 并用同目录相对路径链接该文档。' +
'只有用户明确要求长图下载时,才先 `load_skill` → `long-image-download`,调用 `generate_long_image` 生成 `public/*.long.png` 并确认文件已存在,再给长图预览链接和 `?download=long-image` 下载链接。不要在没有明确需求时强制生成伴生文件。'
);
case 'scheduled-task-automation':
return (
`请使用 ${skillName ?? SCHEDULED_TASK_AUTOMATION_SKILL_NAME} 技能:这是定时自动任务(Scheduled Automation),不是待办提醒。` +
'先澄清缺失的执行时间或 taskSpec,再调用 scheduled_task_create / scheduled_task_list / scheduled_task_cancel。' +
'禁止在当前对话立即执行长任务;工具成功返回后才能确认已设置。一次性任务用 runAtLocal,循环任务用 hour/minute/weekday。我的需求是:'
);
default:
return '';
}
@@ -407,6 +416,12 @@ function buildManifestSkillPrompt(route) {
}
function buildLegacyAutoChatSkillPrefix(trimmed, grantedSkills) {
if (
grantedSkills.includes(SCHEDULED_TASK_AUTOMATION_SKILL_NAME) &&
shouldUseScheduledTaskAutomation(trimmed)
) {
return buildChatSkillPrompt('scheduled-task-automation', SCHEDULED_TASK_AUTOMATION_SKILL_NAME);
}
if (
grantedSkills.includes(EXCEL_ANALYST_SKILL_NAME) &&
isExcelAnalysisIntent(trimmed)
+8
View File
@@ -142,6 +142,14 @@ test('generate-page selection preserves an existing news-page request for the ne
assert.equal(mergeChatSkillPromptWithInput(prompt, task), `${prompt}${task}`);
});
test('buildAutoChatSkillPrefix routes scheduled automation when skill is granted', () => {
assert.match(
buildAutoChatSkillPrefix('每天6点帮我做今日新闻页面', ['scheduled-task-automation']),
/scheduled-task-automation/,
);
assert.equal(buildAutoChatSkillPrefix('每天6点帮我做今日新闻页面', []), '');
});
test('buildAutoChatSkillPrefix enables web for news-like queries', () => {
assert.match(
buildAutoChatSkillPrefix('帮我搜索今天热点新闻', ['web', 'search']),
+31
View File
@@ -772,6 +772,37 @@ export async function migrateSchema(pool) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_scheduled_tasks (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
title VARCHAR(200) NOT NULL,
task_spec TEXT NOT NULL,
recurrence ENUM('once', 'daily', 'weekly') NOT NULL DEFAULT 'daily',
hour TINYINT UNSIGNED NULL,
minute TINYINT UNSIGNED NOT NULL DEFAULT 0,
weekday TINYINT UNSIGNED NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai',
next_run_at BIGINT NOT NULL,
last_run_at BIGINT NULL,
notify_channel ENUM('wechat', 'web', 'both') NOT NULL DEFAULT 'both',
status ENUM('active', 'locked', 'running', 'completed', 'failed', 'cancelled') NOT NULL DEFAULT 'active',
attempts INT NOT NULL DEFAULT 0,
locked_until BIGINT NULL,
last_error VARCHAR(500) NULL,
last_result_json JSON NULL,
source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent',
source_session_id VARCHAR(128) NULL,
source_message_id VARCHAR(128) NULL,
source_text TEXT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_scheduled_task_due (status, next_run_at),
KEY idx_scheduled_task_user (user_id, status, next_run_at),
CONSTRAINT fk_scheduled_task_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_schedule_delivery_logs (
id CHAR(36) PRIMARY KEY,
+95
View File
@@ -16,6 +16,7 @@ import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import mysql from 'mysql2/promise';
import { createScheduleService } from './schedule-service.mjs';
import { createScheduledTaskService } from './scheduled-task-service.mjs';
import { resolveScheduleTimestamp } from './schedule-time.mjs';
import { shouldAutoCreateReminderAtStart } from './schedule-service.mjs';
import { renderLongImage } from './mindspace-long-image.mjs';
@@ -484,6 +485,7 @@ const ALL_TOOLS = [
let quotaPool = null;
let scheduleService = null;
let scheduledTaskService = null;
let userDataSpaceService = null;
function isQuotaSyncConfigured() {
@@ -529,6 +531,20 @@ function getScheduleService() {
return scheduleService;
}
function getScheduledTaskService() {
if (!PRIVATE_DATA_USER_ID) throw new Error('当前会话没有用户上下文,不能操作定时任务');
const pool = getQuotaPool();
if (!pool || !isScheduleConfigured()) {
throw new Error('当前环境未配置待办数据库连接');
}
if (!scheduledTaskService) {
scheduledTaskService = createScheduledTaskService(pool, {
defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
});
}
return scheduledTaskService;
}
if (isScheduleConfigured()) {
ALL_TOOLS.push(
{
@@ -591,6 +607,50 @@ if (isScheduleConfigured()) {
},
},
},
{
name: 'scheduled_task_create',
description:
'为当前用户创建定时自动任务(Scheduled Automation)。到点由系统自动执行 taskSpec 并推送结果;不用于纯待办/提醒。',
inputSchema: {
type: 'object',
properties: {
title: { type: 'string', description: '任务摘要标题,可选' },
taskSpec: { type: 'string', description: '到时具体执行什么(必填)' },
recurrence: { type: 'string', description: 'once / daily / weekly,默认 daily' },
runAtLocal: { type: 'string', description: '一次性任务执行时间 YYYY-MM-DD HH:mm' },
hour: { type: 'number', description: 'daily/weekly 的小时 0-23' },
minute: { type: 'number', description: 'daily/weekly 的分钟 0-59,默认 0' },
weekday: { type: 'number', description: 'weekly 的星期 0=周日 … 6=周六' },
timezone: { type: 'string', description: '时区,可选' },
notifyChannel: { type: 'string', description: 'wechat / web / both,默认 both' },
sourceMessageId: { type: 'string', description: '服务号消息 ID;有值时必须原样传入' },
sourceText: { type: 'string', description: '原始用户文本,可选' },
},
required: ['taskSpec'],
},
},
{
name: 'scheduled_task_list',
description: '查询当前用户的定时自动任务列表。仅返回当前用户数据。',
inputSchema: {
type: 'object',
properties: {
status: { type: 'string', description: '任务状态,默认 active,可选 all' },
limit: { type: 'number', description: '返回数量,默认 20,可选' },
},
},
},
{
name: 'scheduled_task_cancel',
description: '取消当前用户的一个定时自动任务。需提供 taskId 或 titleMatch。',
inputSchema: {
type: 'object',
properties: {
taskId: { type: 'string', description: '任务 ID,优先使用' },
titleMatch: { type: 'string', description: '按标题模糊匹配最近一条 active 任务' },
},
},
},
);
}
@@ -1155,6 +1215,41 @@ async function callTool(name, args) {
});
return [{ type: 'text', text: JSON.stringify(items, null, 2) }];
}
case 'scheduled_task_create': {
const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai';
const task = await getScheduledTaskService().createTask({
userId: PRIVATE_DATA_USER_ID,
title: args.title ?? null,
taskSpec: args.taskSpec,
recurrence: args.recurrence ?? 'daily',
runAtLocal: args.runAtLocal ?? null,
hour: args.hour ?? null,
minute: args.minute ?? 0,
weekday: args.weekday ?? null,
timezone,
notifyChannel: args.notifyChannel ?? 'both',
sourceChannel: 'agent',
sourceMessageId: args.sourceMessageId ?? null,
sourceText: args.sourceText ?? null,
});
return [{ type: 'text', text: JSON.stringify(task, null, 2) }];
}
case 'scheduled_task_list': {
const tasks = await getScheduledTaskService().listTasks({
userId: PRIVATE_DATA_USER_ID,
status: args.status ?? 'active',
limit: args.limit ?? 20,
});
return [{ type: 'text', text: JSON.stringify(tasks, null, 2) }];
}
case 'scheduled_task_cancel': {
const task = await getScheduledTaskService().cancelTask({
userId: PRIVATE_DATA_USER_ID,
taskId: args.taskId ?? null,
titleMatch: args.titleMatch ?? null,
});
return [{ type: 'text', text: JSON.stringify(task, null, 2) }];
}
default:
throw new Error(`未知工具:${name}`);
}
+2 -1
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -140,6 +140,13 @@ export function isScheduleIntent(intent) {
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;
const scheduleKeywords = /(提醒|待办|代办|带办|代拜|日程|行程|安排|计划|闹钟)/;
const timeKeywords = /(今天|今晚|明天|后天|早上|上午|中午|下午|晚上|\d{1,2}[点:])/;
+4
View File
@@ -3,6 +3,10 @@ import test from 'node:test';
import { parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
import { nextDailyRunAt } from './schedule-time.mjs';
test('daily news automation does not route to schedule assistant', () => {
assert.equal(shouldUseScheduleAssistant('每天6点帮我做今日新闻页面'), false);
});
test('parses daily 7am todo digest request', () => {
const intent = parseScheduleIntent('每天早上 7 点给我发一天的待办记录');
assert.equal(intent.action, 'create_daily_todo_digest');
+50
View File
@@ -113,6 +113,56 @@ export function zonedTimeToEpochMs(parts, timezone = DEFAULT_TIMEZONE) {
return guess;
}
export function nextWeeklyRunAt({
weekday,
hour,
minute = 0,
timezone = DEFAULT_TIMEZONE,
now = Date.now(),
} = {}) {
const tz = normalizeTimezone(timezone);
const safeWeekday = Number(weekday);
if (!Number.isInteger(safeWeekday) || safeWeekday < 0 || safeWeekday > 6) {
throw new Error('weekly 任务需要 weekday0=周日 … 6=周六)');
}
const todayStart = startOfLocalDay(now, tz);
const todayParts = getLocalParts(todayStart, tz);
const todayWeekday = new Date(
Date.UTC(todayParts.year, todayParts.month - 1, todayParts.day),
).getUTCDay();
const dayOffset = (safeWeekday - todayWeekday + 7) % 7;
let candidateDayStart = addLocalDays(todayStart, dayOffset, tz);
let parts = getLocalParts(candidateDayStart, tz);
let runAt = zonedTimeToEpochMs(
{
year: parts.year,
month: parts.month,
day: parts.day,
hour: Number(hour),
minute: Number(minute),
second: 0,
},
tz,
);
if (runAt <= now) {
candidateDayStart = addLocalDays(candidateDayStart, 7, tz);
parts = getLocalParts(candidateDayStart, tz);
runAt = zonedTimeToEpochMs(
{
year: parts.year,
month: parts.month,
day: parts.day,
hour: Number(hour),
minute: Number(minute),
second: 0,
},
tz,
);
}
return runAt;
}
export function nextDailyRunAt({ hour, minute = 0, timezone = DEFAULT_TIMEZONE, now = Date.now() }) {
const tz = normalizeTimezone(timezone);
const todayStart = startOfLocalDay(now, tz);
+131
View File
@@ -0,0 +1,131 @@
import crypto from 'node:crypto';
import { buildChatSkillPrompt, SCHEDULED_TASK_AUTOMATION_SKILL_NAME } from './chat-skills.mjs';
import { localDateLabel } from './schedule-time.mjs';
function messageText(message) {
if (typeof message?.content === 'string') return message.content.trim();
if (!Array.isArray(message?.content)) return '';
return message.content
.filter((item) => item?.type === 'text')
.map((item) => String(item?.text ?? '').trim())
.filter(Boolean)
.join('\n');
}
export function buildScheduledTaskExecutionPrompt(task, {
now = Date.now(),
timezone = task?.timezone ?? 'Asia/Shanghai',
} = {}) {
const dateLabel = localDateLabel(now, timezone);
const body = [
'【定时任务自动执行 Scheduled Automation】',
`任务标题:${task.title}`,
`执行日期:${dateLabel}`,
`任务要求:${task.taskSpec}`,
'执行约束:',
'- 这是系统自动触发的定时任务,请直接完成可交付结果,不要反问用户。',
'- 若需要生成页面,必须先 load_skill → static-page-publish,再 write_file 到 public/*.html,并给出正式可访问 URL。',
'- 若只需摘要/文本,给出完整中文结果摘要。',
'- 完成后在回复中明确写出交付结果(链接或摘要)。',
].join('\n');
const prefix = buildChatSkillPrompt(
'scheduled-task-automation',
SCHEDULED_TASK_AUTOMATION_SKILL_NAME,
);
return {
role: 'user',
content: [{ type: 'text', text: `${prefix}${body}` }],
metadata: {
displayText: `定时任务:${task.title}`,
userVisible: false,
agentVisible: true,
memindScheduledTask: {
taskId: task.id,
recurrence: task.recurrence,
automated: true,
},
},
};
}
export function extractScheduledTaskDeliveryText(messages, task) {
const lastAssistant = [...(Array.isArray(messages) ? messages : [])]
.reverse()
.find((message) => message?.role === 'assistant');
const text = messageText(lastAssistant);
if (text) return text.trim();
return `定时任务「${task.title}」已执行完成。`;
}
export function formatScheduledTaskDeliveryMessage(task, deliveryText) {
const header = `定时任务完成:${task.title}`;
const body = String(deliveryText ?? '').trim() || '任务已执行,请前往 MindSpace 查看结果。';
return `${header}\n\n${body}`.trim();
}
export async function executeScheduledTask(task, {
userAuth,
tkmindProxy,
sessionSnapshotService = null,
timeoutMs = 15 * 60 * 1000,
logger = console,
} = {}) {
if (!task?.userId) throw new Error('缺少 task.userId');
if (!userAuth || typeof userAuth.canUseChat !== 'function') {
throw new Error('缺少 userAuth.canUseChat');
}
if (
!tkmindProxy
|| typeof tkmindProxy.startSessionForUser !== 'function'
|| typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser !== 'function'
) {
throw new Error('缺少 tkmindProxy 会话执行能力');
}
const gate = await userAuth.canUseChat(task.userId);
if (!gate?.ok) {
const err = new Error(gate?.message ?? '当前账户无法执行定时任务');
err.code = gate?.code ?? 'CHAT_NOT_ALLOWED';
throw err;
}
const requestId = crypto.randomUUID();
const started = await tkmindProxy.startSessionForUser(task.userId, {
origin: 'h5',
});
const sessionId = started?.id ?? started?.sessionId;
if (!sessionId) throw new Error('创建定时任务会话失败');
const userMessage = buildScheduledTaskExecutionPrompt(task);
logger.info?.('[ScheduledTask] executing', {
taskId: task.id,
userId: task.userId,
sessionId,
requestId,
});
await tkmindProxy.submitSessionReplyAndAwaitFinishForUser(
task.userId,
sessionId,
requestId,
userMessage,
{ timeoutMs },
);
let messages = [];
if (typeof sessionSnapshotService?.refresh === 'function') {
const refreshed = await sessionSnapshotService.refresh(sessionId).catch(() => null);
messages = refreshed?.messages ?? refreshed?.conversation?.messages ?? [];
} else if (typeof sessionSnapshotService?.get === 'function') {
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
messages = snapshot?.messages ?? snapshot?.conversation?.messages ?? [];
}
const deliveryText = extractScheduledTaskDeliveryText(messages, task);
return {
sessionId,
requestId,
deliveryText,
messages,
};
}
+39
View File
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildScheduledTaskExecutionPrompt,
extractScheduledTaskDeliveryText,
formatScheduledTaskDeliveryMessage,
} from './scheduled-task-executor.mjs';
test('buildScheduledTaskExecutionPrompt includes task spec and automation marker', () => {
const message = buildScheduledTaskExecutionPrompt({
id: 'task-1',
title: '每日新闻页',
taskSpec: '搜索今日新闻并生成 HTML 页面',
recurrence: 'daily',
timezone: 'Asia/Shanghai',
});
const text = message.content[0].text;
assert.match(text, /scheduled-task-automation/);
assert.match(text, /每日新闻页/);
assert.match(text, /搜索今日新闻并生成 HTML 页面/);
assert.match(text, /Scheduled Automation/);
});
test('extractScheduledTaskDeliveryText reads last assistant message', () => {
const text = extractScheduledTaskDeliveryText([
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
{ role: 'assistant', content: [{ type: 'text', text: '今日新闻页:https://example.com/news.html' }] },
], { title: '每日新闻页' });
assert.match(text, /https:\/\/example.com\/news.html/);
});
test('formatScheduledTaskDeliveryMessage wraps delivery body', () => {
const text = formatScheduledTaskDeliveryMessage(
{ title: '每日新闻页' },
'页面已生成:https://example.com/news.html',
);
assert.match(text, /定时任务完成:每日新闻页/);
assert.match(text, /页面已生成/);
});
+80
View File
@@ -0,0 +1,80 @@
function normalizeText(text) {
return String(text ?? '').replace(/\s+/g, '').trim();
}
const EXECUTE_VERBS = /(?:做|生成|制作|创建|写|执行|跑|更新|整理|汇总|推送)/u;
const SCHEDULE_MARKERS = /(?:定时(?:自动)?任务|scheduled\s*task|scheduled\s*automation|cron\s*job|recurring\s*task)/iu;
const RECURRENCE_MARKERS = /(?:每天|每日|每周|定时|到点|届时|自动)/u;
function wantsScheduledTaskAutomation(compact) {
if (SCHEDULE_MARKERS.test(compact)) return true;
if (!RECURRENCE_MARKERS.test(compact)) return false;
if (!EXECUTE_VERBS.test(compact)) return false;
// Pure reminders/digests stay on schedule-assistant or wechat schedule handler.
if (/(?:提醒我|闹钟|待办记录|待办列表|待办清单|当天待办|一天的待办)/u.test(compact)) return false;
if (/(?:待办|代办|带办|代拜).{0,8}(?:记录|列表|清单|摘要|汇总)/u.test(compact)) return false;
return true;
}
export function shouldUseScheduledTaskAutomation(text) {
const compact = normalizeText(text);
if (!compact) return false;
return wantsScheduledTaskAutomation(compact);
}
export function parseScheduledTaskIntent(text) {
const compact = normalizeText(text);
if (!compact) return { action: 'none' };
if (!wantsScheduledTaskAutomation(compact)) {
return { action: 'none' };
}
const wantsCancel = /(?:取消|停止|关闭|删除).{0,12}(?:定时|自动)/u.test(compact)
|| /(?:cancel|stop|disable).{0,12}(?:scheduled|automation|task)/iu.test(compact);
if (wantsCancel) {
return { action: 'cancel_scheduled_task' };
}
const wantsList = /(?:查看|看看|列出|我的).{0,12}(?:定时|自动).{0,12}(?:任务|自动化)/u.test(compact)
|| /(?:list|show).{0,12}(?:scheduled|automation).{0,12}tasks/iu.test(compact);
if (wantsList) {
return { action: 'list_scheduled_tasks' };
}
const recurrence = /(?:一次|单次|仅一次|once)/iu.test(compact)
? 'once'
: /(?:每周|weekly)/iu.test(compact)
? 'weekly'
: /(?:每天|每日|daily)/u.test(compact)
? 'daily'
: null;
const hasTaskSpec = EXECUTE_VERBS.test(compact)
&& !/^(?:帮我)?(?:设|设置|创建|添加)(?:一个|个)?定时(?:自动)?任务/u.test(String(text ?? '').trim());
const needsClarification = [];
if (!recurrence && !/(?:明天|后天|今天|今晚|\d{1,2}[点::]|[零一二两三四五六七八九十]{1,3}点)/u.test(compact)) {
needsClarification.push('schedule');
}
if (!hasTaskSpec) {
needsClarification.push('task_spec');
}
if (needsClarification.length > 0) {
return {
action: 'create_scheduled_task',
needsClarification,
recurrence: recurrence ?? 'daily',
};
}
return {
action: 'create_scheduled_task',
recurrence: recurrence ?? 'daily',
};
}
export function isScheduledTaskIntent(intent) {
return intent?.action && intent.action !== 'none';
}
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isScheduledTaskIntent,
parseScheduledTaskIntent,
shouldUseScheduledTaskAutomation,
} from './scheduled-task-intent.mjs';
import { shouldUseScheduleAssistant } from './schedule-intent.mjs';
test('detects daily news page automation intent', () => {
assert.equal(shouldUseScheduledTaskAutomation('每天6点帮我做今日新闻页面'), true);
const intent = parseScheduledTaskIntent('每天6点帮我做今日新闻页面');
assert.equal(intent.action, 'create_scheduled_task');
assert.equal(intent.recurrence, 'daily');
});
test('detects explicit scheduled task phrase', () => {
assert.equal(shouldUseScheduledTaskAutomation('帮我设一个定时自动任务'), true);
const intent = parseScheduledTaskIntent('帮我设一个定时自动任务');
assert.equal(intent.action, 'create_scheduled_task');
assert.ok(intent.needsClarification?.includes('schedule'));
assert.ok(intent.needsClarification?.includes('task_spec'));
});
test('does not capture daily todo digest', () => {
assert.equal(shouldUseScheduledTaskAutomation('每天早上7点把当天待办发给我'), false);
assert.equal(parseScheduledTaskIntent('每天早上7点把当天待办发给我').action, 'none');
});
test('does not capture plain reminders', () => {
assert.equal(shouldUseScheduledTaskAutomation('明天下午三点提醒我开会'), false);
assert.equal(shouldUseScheduleAssistant('明天下午三点提醒我开会'), true);
});
test('scheduled automation takes precedence over schedule assistant keyword overlap', () => {
assert.equal(shouldUseScheduledTaskAutomation('每天6点定时任务做新闻页'), true);
assert.equal(shouldUseScheduleAssistant('每天6点定时任务做新闻页'), false);
});
test('parses cancel and list intents', () => {
assert.equal(parseScheduledTaskIntent('取消我的定时任务').action, 'cancel_scheduled_task');
assert.equal(parseScheduledTaskIntent('看看我的定时自动任务').action, 'list_scheduled_tasks');
});
test('isScheduledTaskIntent excludes none', () => {
assert.equal(isScheduledTaskIntent({ action: 'create_scheduled_task' }), true);
assert.equal(isScheduledTaskIntent({ action: 'none' }), false);
});
+375
View File
@@ -0,0 +1,375 @@
import crypto from 'node:crypto';
import {
nextDailyRunAt,
nextWeeklyRunAt,
normalizeTimezone,
parseLocalDateTimeString,
} from './schedule-time.mjs';
const DEFAULT_TIMEZONE = 'Asia/Shanghai';
const VALID_RECURRENCES = new Set(['once', 'daily', 'weekly']);
const VALID_NOTIFY_CHANNELS = new Set(['wechat', 'web', 'both']);
function nowMs() {
return Date.now();
}
function rowToTask(row) {
if (!row) return null;
return {
id: row.id,
userId: row.user_id,
title: row.title,
taskSpec: row.task_spec,
recurrence: row.recurrence,
hour: row.hour == null ? null : Number(row.hour),
minute: Number(row.minute ?? 0),
weekday: row.weekday == null ? null : Number(row.weekday),
timezone: row.timezone || DEFAULT_TIMEZONE,
nextRunAt: Number(row.next_run_at),
lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at),
notifyChannel: row.notify_channel || 'both',
status: row.status,
attempts: Number(row.attempts ?? 0),
lastError: row.last_error ?? null,
lastResult: parseJsonColumn(row.last_result_json),
sourceChannel: row.source_channel ?? 'agent',
sourceSessionId: row.source_session_id ?? null,
sourceMessageId: row.source_message_id ?? null,
sourceText: row.source_text ?? null,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
function parseJsonColumn(value) {
if (value == null || value === '') return null;
if (typeof value === 'string') {
try { return JSON.parse(value); } catch { return null; }
}
if (typeof value === 'object') return value;
return null;
}
export function computeScheduledTaskNextRunAt({
recurrence,
runAtLocal = null,
hour = null,
minute = 0,
weekday = null,
timezone = DEFAULT_TIMEZONE,
now = Date.now(),
} = {}) {
const safeRecurrence = String(recurrence ?? '').trim();
if (!VALID_RECURRENCES.has(safeRecurrence)) {
throw new Error('recurrence 无效,仅支持 once / daily / weekly');
}
const tz = normalizeTimezone(timezone);
if (safeRecurrence === 'once') {
const runAt = parseLocalDateTimeString(runAtLocal, tz);
if (runAt == null) throw new Error('一次性任务需要 runAtLocalYYYY-MM-DD HH:mm');
if (runAt <= now) throw new Error('一次性任务的执行时间必须在未来');
return runAt;
}
const safeHour = Number(hour);
const safeMinute = Number(minute ?? 0);
if (!Number.isInteger(safeHour) || safeHour < 0 || safeHour > 23) {
throw new Error('daily/weekly 任务需要有效 hour0-23');
}
if (!Number.isInteger(safeMinute) || safeMinute < 0 || safeMinute > 59) {
throw new Error('minute 无效(0-59');
}
if (safeRecurrence === 'weekly') {
return nextWeeklyRunAt({
weekday,
hour: safeHour,
minute: safeMinute,
timezone: tz,
now,
});
}
return nextDailyRunAt({
hour: safeHour,
minute: safeMinute,
timezone: tz,
now,
});
}
export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIMEZONE, clock = { now: nowMs } } = {}) {
if (!pool) throw new Error('缺少数据库连接');
const createTask = async ({
userId,
title,
taskSpec,
recurrence = 'daily',
runAtLocal = null,
hour = null,
minute = 0,
weekday = null,
timezone = defaultTimezone,
notifyChannel = 'both',
sourceChannel = 'agent',
sourceSessionId = null,
sourceMessageId = null,
sourceText = null,
}) => {
if (!userId) throw new Error('缺少用户');
const safeTitle = String(title ?? '').trim();
const safeTaskSpec = String(taskSpec ?? '').trim();
if (!safeTaskSpec) throw new Error('缺少 taskSpec(执行内容)');
const safeRecurrence = String(recurrence ?? 'daily').trim();
if (!VALID_RECURRENCES.has(safeRecurrence)) {
throw new Error('recurrence 无效,仅支持 once / daily / weekly');
}
const safeNotifyChannel = String(notifyChannel ?? 'both').trim();
if (!VALID_NOTIFY_CHANNELS.has(safeNotifyChannel)) {
throw new Error('notifyChannel 无效,仅支持 wechat / web / both');
}
const tz = normalizeTimezone(timezone);
const nextRunAt = computeScheduledTaskNextRunAt({
recurrence: safeRecurrence,
runAtLocal,
hour,
minute,
weekday,
timezone: tz,
now: clock.now(),
});
const id = crypto.randomUUID();
const ts = clock.now();
await pool.query(
`INSERT INTO h5_scheduled_tasks
(id, user_id, title, task_spec, recurrence, hour, minute, weekday, timezone,
next_run_at, notify_channel, status, source_channel, source_session_id,
source_message_id, source_text, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)`,
[
id,
userId,
safeTitle || safeTaskSpec.slice(0, 80),
safeTaskSpec,
safeRecurrence,
hour == null ? null : Number(hour),
Number(minute ?? 0),
weekday == null ? null : Number(weekday),
tz,
nextRunAt,
safeNotifyChannel,
sourceChannel,
sourceSessionId,
sourceMessageId,
sourceText,
ts,
ts,
],
);
const [rows] = await pool.query(
`SELECT * FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[id],
);
return rowToTask(rows[0]);
};
const listTasks = async ({
userId,
status = 'active',
limit = 20,
} = {}) => {
if (!userId) throw new Error('缺少用户');
const clauses = ['user_id = ?'];
const params = [userId];
if (status && status !== 'all') {
const statuses = Array.isArray(status) ? status : [status];
clauses.push(`status IN (${statuses.map(() => '?').join(', ')})`);
params.push(...statuses);
}
params.push(Math.max(1, Math.min(200, Number(limit) || 20)));
const [rows] = await pool.query(
`SELECT *
FROM h5_scheduled_tasks
WHERE ${clauses.join(' AND ')}
ORDER BY next_run_at ASC, created_at DESC
LIMIT ?`,
params,
);
return rows.map(rowToTask);
};
const cancelTask = async ({
userId,
taskId = null,
titleMatch = null,
} = {}) => {
if (!userId) throw new Error('缺少用户');
const ts = clock.now();
if (taskId) {
const [result] = await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'cancelled', updated_at = ?
WHERE id = ? AND user_id = ? AND status IN ('active', 'locked', 'failed')`,
[ts, taskId, userId],
);
if (Number(result?.affectedRows ?? 0) !== 1) {
throw new Error('未找到可取消的定时任务');
}
const [rows] = await pool.query(
`SELECT * FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[taskId],
);
return rowToTask(rows[0]);
}
const safeTitleMatch = String(titleMatch ?? '').trim();
if (!safeTitleMatch) throw new Error('取消任务需要 taskId 或 titleMatch');
const [rows] = await pool.query(
`SELECT *
FROM h5_scheduled_tasks
WHERE user_id = ?
AND status IN ('active', 'locked', 'failed')
AND title LIKE ?
ORDER BY created_at DESC
LIMIT 1`,
[userId, `%${safeTitleMatch}%`],
);
const target = rows[0];
if (!target) throw new Error('未找到可取消的定时任务');
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'cancelled', updated_at = ?
WHERE id = ?`,
[ts, target.id],
);
return rowToTask({ ...target, status: 'cancelled', updated_at: ts });
};
const listDueTasks = async ({ now = clock.now(), limit = 50 } = {}) => {
const [rows] = await pool.query(
`SELECT *
FROM h5_scheduled_tasks
WHERE next_run_at <= ?
AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))
ORDER BY next_run_at ASC
LIMIT ?`,
[now, now, Math.max(1, Math.min(200, Number(limit) || 50))],
);
return rows.map(rowToTask);
};
const lockTask = async (id, { now = clock.now(), lockMs = 300_000 } = {}) => {
const lockedUntil = now + lockMs;
const [result] = await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ?
WHERE id = ? AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))`,
[lockedUntil, now, id, now],
);
if (Number(result?.affectedRows ?? 0) !== 1) return null;
const [rows] = await pool.query(
`SELECT * FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[id],
);
return rowToTask(rows[0]);
};
const markTaskRunning = async (task, { now = clock.now() } = {}) => {
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'running', updated_at = ?
WHERE id = ?`,
[now, task.id],
);
return { ...task, status: 'running' };
};
const markTaskSucceeded = async (task, {
now = clock.now(),
result = null,
deliveryText = null,
sessionId = null,
requestId = null,
} = {}) => {
const lastResult = {
...(result && typeof result === 'object' ? result : {}),
deliveryText: deliveryText ?? null,
sessionId: sessionId ?? null,
requestId: requestId ?? null,
finishedAt: now,
};
if (task.recurrence === 'once') {
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'completed', last_run_at = ?, locked_until = NULL,
last_error = NULL, last_result_json = ?, updated_at = ?
WHERE id = ?`,
[now, JSON.stringify(lastResult), now, task.id],
);
return {
...task,
status: 'completed',
lastRunAt: now,
lastResult,
};
}
const nextRunAt = computeScheduledTaskNextRunAt({
recurrence: task.recurrence,
hour: task.hour,
minute: task.minute,
weekday: task.weekday,
timezone: task.timezone,
now: now + 1000,
});
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'active', next_run_at = ?, last_run_at = ?, locked_until = NULL,
last_error = NULL, last_result_json = ?, updated_at = ?
WHERE id = ?`,
[nextRunAt, now, JSON.stringify(lastResult), now, task.id],
);
return {
...task,
status: 'active',
nextRunAt,
lastRunAt: now,
lastResult,
};
};
const markTaskFailed = async (task, error, {
now = clock.now(),
retryMs = 10 * 60 * 1000,
maxAttempts = 5,
} = {}) => {
const attempts = Number(task.attempts ?? 0);
const message = String(error?.message ?? error ?? '执行失败').slice(0, 500);
const terminal = attempts >= maxAttempts;
const nextStatus = terminal ? 'failed' : 'active';
const nextRunAt = terminal
? task.nextRunAt
: now + retryMs;
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ?
WHERE id = ?`,
[nextStatus, nextRunAt, message, now, task.id],
);
return {
...task,
status: nextStatus,
nextRunAt,
lastError: message,
};
};
return {
createTask,
listTasks,
cancelTask,
listDueTasks,
lockTask,
markTaskRunning,
markTaskSucceeded,
markTaskFailed,
computeScheduledTaskNextRunAt,
};
}
+168
View File
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createScheduledTaskService, computeScheduledTaskNextRunAt } from './scheduled-task-service.mjs';
import { nextDailyRunAt } from './schedule-time.mjs';
test('computeScheduledTaskNextRunAt supports daily schedule', () => {
const now = Date.parse('2026-07-31T02:00:00+08:00');
const nextRunAt = computeScheduledTaskNextRunAt({
recurrence: 'daily',
hour: 6,
minute: 0,
timezone: 'Asia/Shanghai',
now,
});
assert.equal(nextRunAt, nextDailyRunAt({ hour: 6, minute: 0, timezone: 'Asia/Shanghai', now }));
});
test('createTask inserts scheduled automation row', async () => {
const inserts = [];
const service = createScheduledTaskService({
async query(sql, params) {
if (sql.includes('INSERT INTO h5_scheduled_tasks')) {
inserts.push(params);
return [{ affectedRows: 1 }];
}
if (sql.includes('SELECT * FROM h5_scheduled_tasks WHERE id = ?')) {
return [[{
id: params[0],
user_id: 'user-1',
title: '每日新闻页',
task_spec: '搜索今日新闻并生成 HTML 页面',
recurrence: 'daily',
hour: 6,
minute: 0,
weekday: null,
timezone: 'Asia/Shanghai',
next_run_at: 1780000000000,
last_run_at: null,
notify_channel: 'both',
status: 'active',
attempts: 0,
last_error: null,
last_result_json: null,
source_channel: 'agent',
source_session_id: null,
source_message_id: null,
source_text: null,
created_at: 1780000000000,
updated_at: 1780000000000,
}]];
}
return [[]];
},
}, {
clock: { now: () => 1780000000000 },
});
const task = await service.createTask({
userId: 'user-1',
title: '每日新闻页',
taskSpec: '搜索今日新闻并生成 HTML 页面',
recurrence: 'daily',
hour: 6,
minute: 0,
});
assert.equal(task.title, '每日新闻页');
assert.equal(task.recurrence, 'daily');
assert.equal(inserts.length, 1);
});
test('cancelTask updates status to cancelled', async () => {
const service = createScheduledTaskService({
async query(sql, params) {
if (sql.includes('UPDATE h5_scheduled_tasks') && sql.includes('cancelled')) {
return [{ affectedRows: 1 }];
}
if (sql.includes('SELECT * FROM h5_scheduled_tasks WHERE id = ?')) {
return [[{
id: params[0],
user_id: 'user-1',
title: '每日新闻页',
task_spec: '搜索今日新闻并生成 HTML 页面',
recurrence: 'daily',
hour: 6,
minute: 0,
weekday: null,
timezone: 'Asia/Shanghai',
next_run_at: 1780000000000,
last_run_at: null,
notify_channel: 'both',
status: 'cancelled',
attempts: 0,
last_error: null,
last_result_json: null,
source_channel: 'agent',
source_session_id: null,
source_message_id: null,
source_text: null,
created_at: 1780000000000,
updated_at: 1780000001000,
}]];
}
return [[]];
},
});
const task = await service.cancelTask({ userId: 'user-1', taskId: 'task-1' });
assert.equal(task.status, 'cancelled');
});
test('markTaskSucceeded completes one-shot task', async () => {
const updates = [];
const service = createScheduledTaskService({
async query(sql, params) {
if (sql.includes('UPDATE h5_scheduled_tasks') && sql.includes("'completed'")) {
updates.push(['completed', params]);
}
return [{ affectedRows: 1 }];
},
}, {
clock: { now: () => 1780000001000 },
});
const result = await service.markTaskSucceeded({
id: 'task-1',
recurrence: 'once',
title: '一次性任务',
nextRunAt: 1780000000000,
}, {
deliveryText: 'done',
sessionId: 'session-1',
requestId: 'req-1',
});
assert.equal(result.status, 'completed');
assert.equal(updates.length, 1);
});
test('markTaskSucceeded schedules next run for daily task', async () => {
const updates = [];
const service = createScheduledTaskService({
async query(sql, params) {
if (sql.includes('UPDATE h5_scheduled_tasks') && sql.includes("'active'")) {
updates.push(params);
}
return [{ affectedRows: 1 }];
},
}, {
clock: { now: () => Date.parse('2026-07-31T02:00:00+08:00') },
});
const result = await service.markTaskSucceeded({
id: 'task-2',
recurrence: 'daily',
hour: 6,
minute: 0,
timezone: 'Asia/Shanghai',
title: '每日任务',
nextRunAt: Date.parse('2026-07-31T06:00:00+08:00'),
}, {
deliveryText: 'done',
});
assert.equal(result.status, 'active');
assert.ok(result.nextRunAt > Date.parse('2026-07-31T02:00:00+08:00'));
assert.equal(updates.length, 1);
});
+130
View File
@@ -0,0 +1,130 @@
import {
executeScheduledTask,
formatScheduledTaskDeliveryMessage,
} from './scheduled-task-executor.mjs';
export function startScheduledTaskWorker({
scheduledTaskService,
scheduleService = null,
executeTask = executeScheduledTask,
userAuth = null,
tkmindProxy = null,
sessionSnapshotService = null,
notificationDispatcher = null,
logger = console,
intervalMs = Number(process.env.H5_SCHEDULED_TASK_SCAN_INTERVAL_MS ?? 30_000),
maxAttempts = Number(process.env.H5_SCHEDULED_TASK_MAX_ATTEMPTS ?? 3),
executionTimeoutMs = Number(process.env.H5_SCHEDULED_TASK_EXECUTION_TIMEOUT_MS ?? 15 * 60 * 1000),
runOnStart = true,
} = {}) {
const sendScheduleNotification =
notificationDispatcher?.sendScheduleNotification?.bind(notificationDispatcher) ??
null;
const canExecute =
scheduledTaskService
&& typeof executeTask === 'function'
&& userAuth
&& tkmindProxy;
if (!canExecute) {
return { stop() {} };
}
let stopped = false;
let running = false;
const deliverTaskResult = async (task, deliveryText) => {
const text = formatScheduledTaskDeliveryMessage(task, deliveryText);
const notifyChannel = task.notifyChannel ?? 'both';
if (scheduleService?.createUserNotification && (notifyChannel === 'web' || notifyChannel === 'both')) {
await scheduleService.createUserNotification({
userId: task.userId,
channel: 'web',
notificationType: 'scheduled_task_result',
title: `定时任务完成:${task.title}`,
body: text,
data: {
taskId: task.id,
recurrence: task.recurrence,
},
});
}
if (
(notifyChannel === 'wechat' || notifyChannel === 'both')
&& typeof sendScheduleNotification === 'function'
) {
await sendScheduleNotification({ userId: task.userId, text });
}
};
const runOnce = async () => {
if (running || stopped) return;
running = true;
try {
const dueTasks = await scheduledTaskService.listDueTasks({ limit: 10 });
for (const candidate of dueTasks) {
const task = await scheduledTaskService.lockTask(candidate.id);
if (!task) continue;
try {
await scheduledTaskService.markTaskRunning(task);
const result = await executeTask(task, {
userAuth,
tkmindProxy,
sessionSnapshotService,
timeoutMs: executionTimeoutMs,
logger,
});
await deliverTaskResult(task, result.deliveryText);
await scheduledTaskService.markTaskSucceeded(task, {
result: {
deliveryText: result.deliveryText,
},
deliveryText: result.deliveryText,
sessionId: result.sessionId,
requestId: result.requestId,
});
} catch (err) {
logger.warn?.('Scheduled task execution failed:', err);
const failedTask = await scheduledTaskService.markTaskFailed(task, err, {
maxAttempts,
});
if (failedTask.status === 'failed' && scheduleService?.createUserNotification) {
await scheduleService.createUserNotification({
userId: task.userId,
channel: 'web',
notificationType: 'scheduled_task_failed',
title: `定时任务失败:${task.title}`,
body: String(failedTask.lastError ?? '执行失败'),
data: { taskId: task.id },
}).catch(() => {});
}
if (
failedTask.status === 'failed'
&& (task.notifyChannel === 'wechat' || task.notifyChannel === 'both')
&& typeof sendScheduleNotification === 'function'
) {
await sendScheduleNotification({
userId: task.userId,
text: `定时任务失败:${task.title}\n${failedTask.lastError ?? '执行失败'}`,
}).catch(() => {});
}
}
}
} catch (err) {
logger.warn?.('Scheduled task worker failed:', err);
} finally {
running = false;
}
};
const timer = setInterval(runOnce, Math.max(1000, Number(intervalMs) || 30_000));
timer.unref?.();
if (runOnStart) void runOnce();
return {
runOnce,
stop() {
stopped = true;
clearInterval(timer);
},
};
}
+125
View File
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { startScheduledTaskWorker } from './scheduled-task-worker.mjs';
test('scheduled task worker executes due task and completes one-shot task', async () => {
const calls = [];
const notifications = [];
const wechat = [];
const task = {
id: 'task-1',
userId: 'user-1',
title: '每日新闻页',
taskSpec: '做新闻页',
recurrence: 'once',
notifyChannel: 'both',
attempts: 1,
};
const worker = startScheduledTaskWorker({
intervalMs: 60_000,
userAuth: { id: 'user-auth' },
tkmindProxy: { id: 'proxy' },
scheduledTaskService: {
async listDueTasks() {
calls.push('list');
return [task];
},
async lockTask(id) {
calls.push(`lock:${id}`);
return task;
},
async markTaskRunning(input) {
calls.push(`running:${input.id}`);
return { ...input, status: 'running' };
},
async markTaskSucceeded(input, payload) {
calls.push(`success:${input.id}:${payload.deliveryText}`);
return { ...input, status: 'completed' };
},
async markTaskFailed() {
calls.push('failed');
},
},
scheduleService: {
async createUserNotification(input) {
notifications.push(input.notificationType);
},
},
notificationDispatcher: {
async sendScheduleNotification({ userId, text }) {
wechat.push({ userId, text });
},
},
executeTask: async () => ({
sessionId: 'session-1',
requestId: 'req-1',
deliveryText: '页面:https://example.com/news.html',
}),
logger: { warn() {}, info() {} },
runOnStart: false,
});
await worker.runOnce();
worker.stop();
assert.deepEqual(calls, [
'list',
'lock:task-1',
'running:task-1',
'success:task-1:页面:https://example.com/news.html',
]);
assert.deepEqual(notifications, ['scheduled_task_result']);
assert.equal(wechat.length, 1);
assert.match(wechat[0].text, /定时任务完成:每日新闻页/);
});
test('scheduled task worker marks failure after execution error', async () => {
const calls = [];
const task = {
id: 'task-2',
userId: 'user-2',
title: '失败任务',
recurrence: 'daily',
notifyChannel: 'web',
attempts: 3,
};
const worker = startScheduledTaskWorker({
intervalMs: 60_000,
userAuth: { id: 'user-auth' },
tkmindProxy: { id: 'proxy' },
scheduledTaskService: {
async listDueTasks() {
return [task];
},
async lockTask() {
return task;
},
async markTaskRunning(input) {
return input;
},
async markTaskSucceeded() {
calls.push('success');
},
async markTaskFailed(input, err, { maxAttempts }) {
calls.push(`failed:${maxAttempts}:${err.message}`);
return { ...input, status: 'failed', lastError: err.message };
},
},
scheduleService: {
async createUserNotification(input) {
calls.push(`notify:${input.notificationType}`);
},
},
executeTask: async () => {
throw new Error('agent timeout');
},
maxAttempts: 3,
logger: { warn() {} },
runOnStart: false,
});
await worker.runOnce();
worker.stop();
assert.deepEqual(calls, ['failed:3:agent timeout', 'notify:scheduled_task_failed']);
});
@@ -0,0 +1,277 @@
#!/usr/bin/env node
/**
* 定时自动任务Scheduled Task Automation本地验证
*
* 默认DB + mock worker 全链路无需 Portal 进程
* 可选
* VERIFY_SCHEDULED_TASK_LIVE=1 等待已开启 worker Portal 到点执行 H5_SCHEDULED_TASK_WORKER_ENABLED=1
* --due-seconds=90 创建 N 秒后执行的一次性任务live 模式用
*/
import assert from 'node:assert/strict';
import crypto from 'node:crypto';
import fs from 'node:fs';
import mysql from 'mysql2/promise';
import { migrateSchema } from '../db.mjs';
import { parseScheduledTaskIntent, shouldUseScheduledTaskAutomation } from '../scheduled-task-intent.mjs';
import { createScheduledTaskService } from '../scheduled-task-service.mjs';
import { startScheduledTaskWorker } from '../scheduled-task-worker.mjs';
import { getLocalParts, normalizeTimezone } from '../schedule-time.mjs';
const tz = 'Asia/Shanghai';
const TEST_TITLE_PREFIX = 'VERIFY_SCHEDULED_TASK_';
let passed = 0;
let failed = 0;
function pass(label, detail = '') {
passed += 1;
console.log(`${label}${detail ? `: ${detail}` : ''}`);
}
function fail(label, detail = '') {
failed += 1;
console.error(`${label}${detail ? `: ${detail}` : ''}`);
}
function loadDatabaseUrl() {
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
const envPath = new URL('../.env', import.meta.url);
if (!fs.existsSync(envPath)) return null;
const envText = fs.readFileSync(envPath, 'utf8');
const dbLine = envText.match(/^DATABASE_URL=(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '');
return dbLine || null;
}
function parseArgs(argv) {
const dueSecondsArg = argv.find((arg) => arg.startsWith('--due-seconds='));
return {
dueSeconds: dueSecondsArg ? Number(dueSecondsArg.split('=')[1]) : 120,
};
}
function formatRunAtLocal(epochMs, timezone = tz) {
const parts = getLocalParts(epochMs, timezone);
return `${parts.year}-${String(parts.month).padStart(2, '0')}-${String(parts.day).padStart(2, '0')} ${String(parts.hour).padStart(2, '0')}:${String(parts.minute).padStart(2, '0')}`;
}
function testIntentLayer() {
assert.equal(shouldUseScheduledTaskAutomation('每天6点帮我做今日新闻页面'), true);
assert.equal(parseScheduledTaskIntent('每天6点帮我做今日新闻页面').action, 'create_scheduled_task');
assert.equal(shouldUseScheduledTaskAutomation('每天早上7点把当天待办发给我'), false);
pass('意图识别', '自动化 vs 待办 digest 边界正确');
}
async function cleanupTestRows(pool, userId) {
await pool.query(
`DELETE FROM h5_scheduled_tasks
WHERE user_id = ? AND title LIKE ?`,
[userId, `${TEST_TITLE_PREFIX}%`],
);
}
async function testDatabaseAndMockWorker(pool, userId, { dueSeconds }) {
await migrateSchema(pool);
pass('数据库 schema', 'h5_scheduled_tasks 已就绪');
const scheduledTaskService = createScheduledTaskService(pool, {
defaultTimezone: normalizeTimezone(tz),
});
await cleanupTestRows(pool, userId);
const runAtMs = Date.now() + Math.max(30, dueSeconds) * 1000;
const runAtLocal = formatRunAtLocal(runAtMs, tz);
const created = await scheduledTaskService.createTask({
userId,
title: `${TEST_TITLE_PREFIX}mock_pipeline`,
taskSpec: '生成一段今日新闻摘要(验证脚本,无需写页面)',
recurrence: 'once',
runAtLocal,
notifyChannel: 'web',
sourceChannel: 'api',
sourceText: 'verify-scheduled-task-automation',
});
if (created.recurrence === 'once' && created.nextRunAt > Date.now()) {
pass('创建一次性任务', `next_run_at=${new Date(created.nextRunAt).toLocaleString('zh-CN', { timeZone: tz })}`);
} else {
fail('创建一次性任务', `status=${created.status}, nextRunAt=${created.nextRunAt}`);
return;
}
const notDue = await scheduledTaskService.listDueTasks({ now: Date.now() - 1000 });
if (!notDue.some((row) => row.id === created.id)) {
pass('到期扫描(未到点)', '未来任务不会进入 due 列表');
} else {
fail('到期扫描(未到点)', '未来任务不应被扫描为 due');
}
await pool.query(
`UPDATE h5_scheduled_tasks SET next_run_at = ?, status = 'active', locked_until = NULL WHERE id = ?`,
[Date.now() - 1000, created.id],
);
const notifications = [];
const worker = startScheduledTaskWorker({
scheduledTaskService,
scheduleService: {
async createUserNotification(input) {
notifications.push(input);
},
},
userAuth: { id: 'mock-user-auth' },
tkmindProxy: { id: 'mock-proxy' },
executeTask: async (task) => ({
sessionId: 'verify-session',
requestId: crypto.randomUUID(),
deliveryText: `验证交付:${task.title} 已在 ${new Date().toLocaleString('zh-CN', { timeZone: tz })} 完成。`,
}),
logger: { warn() {}, info() {} },
runOnStart: false,
});
await worker.runOnce();
worker.stop();
const [rows] = await pool.query(
`SELECT status, last_result_json, last_error FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[created.id],
);
const row = rows[0];
if (row?.status === 'completed') {
pass('mock worker 执行', '一次性任务 status=completed');
} else {
fail('mock worker 执行', `status=${row?.status ?? 'missing'}, error=${row?.last_error ?? ''}`);
}
if (notifications.some((item) => item.notificationType === 'scheduled_task_result')) {
pass('结果推送(mock', 'scheduled_task_result 通知已写入');
} else {
fail('结果推送(mock', `notifications=${notifications.length}`);
}
let lastResult = row?.last_result_json;
if (typeof lastResult === 'string') {
try { lastResult = JSON.parse(lastResult); } catch { lastResult = null; }
}
if (lastResult?.deliveryText) {
pass('执行结果落盘', String(lastResult.deliveryText).slice(0, 80));
} else {
fail('执行结果落盘', 'last_result_json 缺少 deliveryText');
}
await cleanupTestRows(pool, userId);
}
async function testLivePortalWorker(pool, userId, { dueSeconds }) {
const enabled = ['1', 'true', 'yes', 'on'].includes(
String(process.env.VERIFY_SCHEDULED_TASK_LIVE ?? '').toLowerCase(),
);
if (!enabled) {
pass('Live Portal worker(跳过)', '设置 VERIFY_SCHEDULED_TASK_LIVE=1 可启用');
return;
}
const baseUrl = process.env.H5_PORT
? `http://127.0.0.1:${process.env.H5_PORT}`
: 'http://127.0.0.1:8081';
let portalOk = false;
try {
const res = await fetch(`${baseUrl}/api/status`, { signal: AbortSignal.timeout(3000) });
portalOk = res.ok;
} catch {
portalOk = false;
}
if (!portalOk) {
pass('Live Portal worker(跳过)', `Portal 未运行:${baseUrl}/api/status 不可达`);
return;
}
pass('Live Portal 探活', baseUrl);
const scheduledTaskService = createScheduledTaskService(pool, {
defaultTimezone: normalizeTimezone(tz),
});
await cleanupTestRows(pool, userId);
const waitSeconds = Math.max(30, Math.min(180, dueSeconds));
const runAtMs = Date.now() + waitSeconds * 1000;
const created = await scheduledTaskService.createTask({
userId,
title: `${TEST_TITLE_PREFIX}live_portal`,
taskSpec: '用三句话总结今天的热点新闻(验证脚本,回复文本即可,不要生成页面)',
recurrence: 'once',
runAtLocal: formatRunAtLocal(runAtMs, tz),
notifyChannel: 'web',
sourceChannel: 'api',
});
pass('Live 任务已创建', `${waitSeconds}s 后执行,id=${created.id}`);
console.log(`\n等待 Portal worker 执行(最多 ${waitSeconds + 120}s)…`);
console.log('请确认 Portal 进程已设置 H5_SCHEDULED_TASK_WORKER_ENABLED=1\n');
const deadline = Date.now() + (waitSeconds + 120) * 1000;
let terminal = null;
while (Date.now() < deadline) {
const [rows] = await pool.query(
`SELECT status, last_error, last_result_json, updated_at FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`,
[created.id],
);
terminal = rows[0];
if (terminal?.status === 'completed' || terminal?.status === 'failed') break;
await new Promise((resolve) => setTimeout(resolve, 5000));
}
if (terminal?.status === 'completed') {
pass('Live Portal worker', `任务 completedupdated_at=${terminal.updated_at}`);
} else if (terminal?.status === 'failed') {
fail('Live Portal worker', terminal.last_error ?? 'failed');
} else {
fail('Live Portal worker', `超时,最后 status=${terminal?.status ?? 'unknown'}`);
}
await cleanupTestRows(pool, userId);
}
async function main() {
const { dueSeconds } = parseArgs(process.argv.slice(2));
console.log('=== 定时自动任务本地验证 ===\n');
testIntentLayer();
const dbUrl = loadDatabaseUrl();
if (!dbUrl) {
fail('数据库场景', '未配置 DATABASE_URL');
} else {
const pool = mysql.createPool(dbUrl);
try {
const username = process.env.VERIFY_SCHEDULED_TASK_USER ?? 'john8';
const [users] = await pool.query(
'SELECT id, username FROM h5_users WHERE username = ? LIMIT 1',
[username],
);
if (!users[0]) {
const [fallback] = await pool.query('SELECT id, username FROM h5_users ORDER BY created_at ASC LIMIT 1');
if (!fallback[0]) {
fail('数据库场景', '无可用测试用户');
} else {
pass('测试用户', `${fallback[0].username} (${fallback[0].id})`);
await testDatabaseAndMockWorker(pool, fallback[0].id, { dueSeconds });
await testLivePortalWorker(pool, fallback[0].id, { dueSeconds });
}
} else {
pass('测试用户', `${users[0].username} (${users[0].id})`);
await testDatabaseAndMockWorker(pool, users[0].id, { dueSeconds });
await testLivePortalWorker(pool, users[0].id, { dueSeconds });
}
} finally {
await pool.end();
}
}
console.log('\n=== 汇总 ===');
console.log(`通过: ${passed}`);
console.log(`失败: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+7
View File
@@ -312,8 +312,10 @@ let wechatOAuthService = null;
let wechatMpService = null;
let notificationDispatcher = null;
let scheduleService = null;
let scheduledTaskService = null;
let feedbackService = null;
let scheduleReminderWorker = null;
let scheduledTaskWorker = null;
let llmProviderService = null;
let wordFilterService = null;
let authPool = null;
@@ -358,6 +360,7 @@ async function bootstrapUserAuth() {
mindSpaceRybbitConfig,
);
scheduleService = domainServices.scheduleService;
scheduledTaskService = domainServices.scheduledTaskService;
pageDataService = domainServices.pageDataService;
pageDataPublicService =
domainServices.pageDataPublicService;
@@ -521,6 +524,8 @@ async function bootstrapUserAuth() {
sessionAccess,
tkmindProxy,
scheduleService,
scheduledTaskService,
sessionSnapshotService,
wechatScheduleLlmConfigService,
llmProviderService,
chatIntentRouter,
@@ -541,6 +546,8 @@ async function bootstrapUserAuth() {
integrationServices.notificationDispatcher;
scheduleReminderWorker =
integrationServices.scheduleReminderWorker;
scheduledTaskWorker =
integrationServices.scheduledTaskWorker;
mindSpacePageEditSession =
integrationServices.mindSpacePageEditSession;
return true;
@@ -26,6 +26,7 @@ import {
writebackPublications,
} from '../plaza-tasks.mjs';
import { createScheduleService } from '../schedule-service.mjs';
import { createScheduledTaskService } from '../scheduled-task-service.mjs';
import { createFeedbackService } from '../user-feedback.mjs';
import { PUBLISH_KEY_UUID } from '../user-publish.mjs';
import { initSchema } from '../db.mjs';
@@ -47,6 +48,7 @@ export async function bootstrapPortalDomainServices({
ensureMindSpaceConfigFn = ensureMindSpaceConfig,
loadMindSpaceConfigFn = loadMindSpaceConfig,
createScheduleServiceFn = createScheduleService,
createScheduledTaskServiceFn = createScheduledTaskService,
createPageDataServiceFn = createPageDataService,
createPageDataPublicServiceFn =
createPageDataPublicService,
@@ -114,6 +116,10 @@ export async function bootstrapPortalDomainServices({
defaultTimezone:
env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
});
const scheduledTaskService = createScheduledTaskServiceFn(pool, {
defaultTimezone:
env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
});
const pageDataService = createPageDataServiceFn({
getUserAuth,
getPool: () => pool,
@@ -278,6 +284,7 @@ export async function bootstrapPortalDomainServices({
mindSpaceAnalyticsConfig: resolvedAnalyticsConfig,
mindSpaceRybbitConfig: resolvedRybbitConfig,
scheduleService,
scheduledTaskService,
pageDataService,
pageDataPublicService,
feedbackService,
@@ -10,6 +10,7 @@ import {
import { createPageEditSessionService } from '../mindspace-page-edit-session.mjs';
import { createNotificationDispatcher } from '../notification-dispatcher.mjs';
import { startScheduleReminderWorker } from '../schedule-reminder-worker.mjs';
import { startScheduledTaskWorker } from '../scheduled-task-worker.mjs';
import { isPassiveCanaryRuntime } from './portal-runtime-role.mjs';
import { loadWechatMpModule } from '../wechat-mp-loader.mjs';
import { createToolGateway } from '../tool-gateway.mjs';
@@ -29,11 +30,12 @@ export async function bootstrapPortalIntegrationServices({
sessionAccess,
tkmindProxy,
scheduleService,
scheduledTaskService = null,
sessionSnapshotService = null,
wechatScheduleLlmConfigService,
llmProviderService,
chatIntentRouter,
systemDisclosurePolicyService,
sessionSnapshotService,
mindSpaceAnalyticsConfig,
mindSpaceRybbitConfig,
subscriptionService,
@@ -56,6 +58,8 @@ export async function bootstrapPortalIntegrationServices({
createNotificationDispatcher,
startScheduleReminderWorkerFn =
startScheduleReminderWorker,
startScheduledTaskWorkerFn =
startScheduledTaskWorker,
createPageEditSessionServiceFn =
createPageEditSessionService,
createToolGatewayFn = createToolGateway,
@@ -266,6 +270,27 @@ export async function bootstrapPortalIntegrationServices({
logger.log('Schedule reminder worker enabled');
}
let scheduledTaskWorker = null;
if (
!isPassiveCanaryRuntime(env) &&
env.H5_SCHEDULED_TASK_WORKER_ENABLED === '1' &&
scheduledTaskService &&
userAuth &&
tkmindProxy
) {
scheduledTaskWorker =
startScheduledTaskWorkerFn({
scheduledTaskService,
scheduleService,
userAuth,
tkmindProxy,
sessionSnapshotService,
notificationDispatcher,
logger,
});
logger.log('Scheduled task worker enabled');
}
let subscriptionExpiryTimer = null;
if (subscriptionService && !isPassiveCanaryRuntime(env)) {
subscriptionExpiryTimer = setIntervalFn(
@@ -323,6 +348,7 @@ export async function bootstrapPortalIntegrationServices({
wechatMpService,
notificationDispatcher,
scheduleReminderWorker,
scheduledTaskWorker,
subscriptionExpiryTimer,
mindSpacePageEditSession,
};
+1
View File
@@ -21,6 +21,7 @@ export const DEFAULT_USER_SKILLS = {
'search-enhanced': true,
[EXCEL_ANALYST_SKILL_NAME]: false,
'schedule-assistant': true,
'scheduled-task-automation': true,
'service-integration-smoke': true,
'form-builder': true,
'table-viewer': true,
+2
View File
@@ -18,6 +18,7 @@ test('lists static-page-publish in platform catalog', () => {
assert.ok(catalog.some((item) => item.name === 'static-page-publish'));
assert.ok(catalog.some((item) => item.name === 'page-data-collect'));
assert.ok(catalog.some((item) => item.name === 'schedule-assistant'));
assert.ok(catalog.some((item) => item.name === 'scheduled-task-automation'));
assert.ok(catalog.some((item) => item.name === 'service-integration-smoke'));
assert.ok(catalog.some((item) => item.name === 'product-campaign-page'));
assert.ok(catalog.some((item) => item.name === 'long-image-download'));
@@ -68,6 +69,7 @@ test('DEFAULT_USER_SKILLS enables common platform skills', () => {
assert.equal(DEFAULT_USER_SKILLS.web, true);
assert.equal(DEFAULT_USER_SKILLS.search, true);
assert.equal(DEFAULT_USER_SKILLS['schedule-assistant'], true);
assert.equal(DEFAULT_USER_SKILLS['scheduled-task-automation'], true);
assert.equal(DEFAULT_USER_SKILLS['service-integration-smoke'], true);
assert.equal(DEFAULT_USER_SKILLS['form-builder'], true);
assert.equal(DEFAULT_USER_SKILLS['table-viewer'], true);
+67
View File
@@ -0,0 +1,67 @@
---
name: scheduled-task-automation
description: 处理定时自动任务(Scheduled Automation):澄清执行时间与任务内容,写入订阅;到点由系统自动执行并推送结果。不处理纯待办/提醒。
---
# 定时自动任务(Scheduled Task Automation
这个 skill 处理**到点自动执行并交付结果**的定时任务,与 `schedule-assistant`(待办/提醒)严格分离。
## 适用范围
- 创建一次性或循环的定时自动任务(Scheduled Task / Recurring Automation
- 查询、取消当前用户的定时任务
- 澄清缺失的执行时间或任务内容
## 边界
1. **不**处理纯提醒、待办记录、日程查询 —— 那些交给 `schedule-assistant`
2. **不**在当前对话里立即执行长任务;本 skill 只负责**写入定时订阅**
3. 只能操作当前用户的数据;工具成功返回前,不能说「已经设置好了」。
4. 信息不完整时先追问,禁止臆造时间或任务内容。
## 可用工具
- `scheduled_task_create`
- `scheduled_task_list`
- `scheduled_task_cancel`
## 必填信息(创建前必须齐全)
| 字段 | 说明 |
|------|------|
| `taskSpec` | 到时具体执行什么(给后续 Agent 的说明) |
| `recurrence` | `once` 一次性 / `daily` 每天 / `weekly` 每周 |
| 执行时间 | `once``runAtLocal``daily`/`weekly``hour`+`minute`weekly 还需 `weekday` |
## 时间写入(必守)
1. **禁止**自行估算 Unix 毫秒时间戳。
2. 一次性任务用 `runAtLocal``YYYY-MM-DD HH:mm`,用户时区)。
3. 循环任务用 `hour`0-23+ `minute`0-59);每周任务额外传 `weekday`0=周日 … 6=周六)。
4. 写入后可用 `scheduled_task_list` 核对。
## 工作规则
1. 先判断用户是要**创建**、**查询**还是**取消**定时自动任务。
2. 缺时间或缺任务内容时,一次只追问最小必要信息。
3. 创建成功后,用用户能理解的话说明:
- 何时执行
- 执行什么
- 是一次性还是循环
- 结果会通过哪个通道推送(默认微信 + 站内)
4. 取消时优先用 `taskId`;用户只说任务名时可传 `titleMatch`
5. 若提示里给出了 `sourceMessageId`,调用 `scheduled_task_create` 时必须原样传入。
## 回复要求
- 简短、直接
- 成功时说明任务摘要、时间与 recurrence
- 失败时明确没写入成功,并给出下一步
## 示例
用户:每天 6 点帮我做今日新闻页面
助手:(信息齐全后调用 `scheduled_task_create`
已设置循环任务:每天 06:00(北京时间)自动搜索并生成「今日新闻页」,完成后推送链接。
+11
View File
@@ -265,6 +265,17 @@ export function buildSessionMemoryEntries({
].join('\n'),
});
}
if ((scheduleTools?.available_tools ?? []).includes('scheduled_task_create')) {
entries.push({
title: 'TKMind 定时自动任务规则',
content: [
'创建 Scheduled Automation 时:',
'- 使用 scheduled_task_create;一次性任务传 runAtLocal,循环任务传 hour/minute/weekday。',
'- 禁止自行估算 Unix 毫秒;本 skill 只写入订阅,不在当前对话立即执行长任务。',
'- 写入后调用 scheduled_task_list 核对。',
].join('\n'),
});
}
if (!hasMemoryStore(sessionPolicy)) {
return entries;
+17
View File
@@ -4,6 +4,7 @@ import {
isPageDataIntent,
} from '../../chat-skills.mjs';
import { shouldUseScheduleAssistant } from '../../schedule-intent.mjs';
import { shouldUseScheduledTaskAutomation } from '../../scheduled-task-intent.mjs';
import { buildCurrentTimeAgentPrefix } from '../../user-memory-profile.mjs';
import { isPageGenerateText, wantsDocxDownload } from '../intent/patterns.mjs';
import { buildWechatStandaloneImageInstruction } from '../image-generation-policy.mjs';
@@ -81,6 +82,17 @@ export function buildWechatAgentPrompt(intent, {
'',
].filter(Boolean).join('\n')
: '';
const scheduledTaskAutomationHint = shouldUseScheduledTaskAutomation(agentText)
? [
'【定时自动任务要求】这条消息是 Scheduled Automation(到点自动执行并推送结果),不是待办提醒。',
'开始前先加载 `scheduled-task-automation` skill,并严格按 skill 边界执行。',
'先澄清缺失的执行时间或 taskSpec,再调用 scheduled_task_create / scheduled_task_list / scheduled_task_cancel。',
'禁止在当前对话立即执行长任务;工具成功返回后才能确认已设置。',
'一次性任务用 runAtLocal;循环任务用 hour/minute/weekday,禁止自行估算 Unix 毫秒。',
intent?.msgId ? `调用 scheduled_task_create 时必须传入 sourceMessageId: ${intent.msgId}` : '',
'',
].filter(Boolean).join('\n')
: '';
const imageGenerationHint = buildWechatStandaloneImageInstruction(imagePolicy, {
sourceMessageId: intent?.msgId,
});
@@ -92,6 +104,7 @@ export function buildWechatAgentPrompt(intent, {
pageDataRepairHint,
currentTimeHint,
scheduleAssistantHint,
scheduledTaskAutomationHint,
imageGenerationHint,
'【微信服务号语音消息】用户通过语音输入,以下是微信识别结果。',
'',
@@ -105,6 +118,7 @@ export function buildWechatAgentPrompt(intent, {
docxDownloadHint,
currentTimeHint,
scheduleAssistantHint,
scheduledTaskAutomationHint,
'【微信服务号图片消息】用户发送了图片。',
'如用户没有明确要求,请先根据图片内容给出简短理解,并询问下一步。',
'',
@@ -132,6 +146,7 @@ export function buildWechatAgentPrompt(intent, {
return [
currentTimeHint,
scheduleAssistantHint,
scheduledTaskAutomationHint,
'【微信服务号位置消息】用户发送了当前位置。',
location.label ? `地址:${location.label}` : '',
location.latitude !== null && location.latitude !== undefined
@@ -150,6 +165,7 @@ export function buildWechatAgentPrompt(intent, {
return [
currentTimeHint,
scheduleAssistantHint,
scheduledTaskAutomationHint,
'【微信服务号链接消息】用户分享了链接。',
link.title ? `标题:${link.title}` : '',
link.description ? `描述:${link.description}` : '',
@@ -171,6 +187,7 @@ export function buildWechatAgentPrompt(intent, {
if (sessionPageContinuationHint) lines.push(sessionPageContinuationHint);
if (pagePublishHint) lines.push(pagePublishHint);
if (scheduleAssistantHint) lines.push(scheduleAssistantHint);
if (scheduledTaskAutomationHint) lines.push(scheduledTaskAutomationHint);
if (imageGenerationHint) lines.push(imageGenerationHint);
lines.push(
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',