Add scheduled task automation skill with worker execution pipeline.
Memind CI / Test, build, and release guards (push) Failing after 18s
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:
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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}[点::])/;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 任务需要 weekday(0=周日 … 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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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, /页面已生成/);
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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('一次性任务需要 runAtLocal(YYYY-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 任务需要有效 hour(0-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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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', `任务 completed,updated_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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(北京时间)自动搜索并生成「今日新闻页」,完成后推送链接。
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
'【微信服务号新消息】请只回答下面这条用户消息,不要主动延续无关的历史话题。',
|
||||
|
||||
Reference in New Issue
Block a user