53b0d2c62f
Memind CI / Test, build, and release guards (push) Successful in 3m37s
Finish now releases static HTML delivery contracts in a finally block so re-edited pages are not stuck at HTTP 409, and M成果 groups pages by Asia/Shanghai calendar dates to avoid duplicate day headings. Co-authored-by: Cursor <cursoragent@cursor.com>
250 lines
7.9 KiB
JavaScript
250 lines
7.9 KiB
JavaScript
import crypto from 'node:crypto';
|
||
import path from 'node:path';
|
||
import { buildChatSkillPrompt, SCHEDULED_TASK_AUTOMATION_SKILL_NAME } from './chat-skills.mjs';
|
||
import { releaseMaterializedPageDeliveryContracts } from './mindspace-delivery-contract.mjs';
|
||
import {
|
||
collectOwnPublicHtmlRelativePaths,
|
||
materializeMissingPublicHtmlWrites,
|
||
} from './mindspace-public-finish-sync.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}`,
|
||
'执行约束:',
|
||
'- 这是系统自动触发的定时任务,请直接完成可交付结果,不要反问用户。',
|
||
'- 禁止向用户追问时间、频率或任务内容;taskSpec 已是完整执行说明。',
|
||
'- 禁止调用 scheduled_task_create / scheduled_task_list / scheduled_task_cancel;只执行 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();
|
||
}
|
||
|
||
const SCHEDULED_TASK_CLARIFICATION_PATTERNS = [
|
||
/需确认/u,
|
||
/请确认/u,
|
||
/请问/u,
|
||
/未指定/u,
|
||
/需要澄清/u,
|
||
/在创建前需要确认/u,
|
||
/信息不完整/u,
|
||
/具体几点/u,
|
||
/缺(?:少|失)/u,
|
||
];
|
||
|
||
export function looksLikeScheduledTaskNonDelivery(text, { readyPaths = [] } = {}) {
|
||
if (Array.isArray(readyPaths) && readyPaths.length > 0) return false;
|
||
const normalized = String(text ?? '').trim();
|
||
if (!normalized) return true;
|
||
if (SCHEDULED_TASK_CLARIFICATION_PATTERNS.some((pattern) => pattern.test(normalized))) {
|
||
return true;
|
||
}
|
||
if (/https?:\/\//i.test(normalized)) return false;
|
||
if (/public\/[^\s]+\.html/i.test(normalized)) return false;
|
||
if (/页面链接/u.test(normalized)) return false;
|
||
if (/(?:已生成|已完成|交付).{0,24}(?:页面|链接|结果)/u.test(normalized)) return false;
|
||
if (/^验证成功[。!!]?$/u.test(normalized)) return false;
|
||
if (/^(?:任务)?(?:已)?完成[。!!]?$/u.test(normalized)) return false;
|
||
if (/^【验证】/u.test(normalized) && normalized.length >= 6) return false;
|
||
return normalized.length < 12;
|
||
}
|
||
|
||
export async function finalizeScheduledTaskPageDelivery({
|
||
pool,
|
||
userId,
|
||
sessionId,
|
||
messages,
|
||
publishDir,
|
||
currentUser = null,
|
||
logger = console,
|
||
} = {}) {
|
||
if (!pool || !userId || !publishDir) return [];
|
||
|
||
const materialized = materializeMissingPublicHtmlWrites({ messages, publishDir });
|
||
const relativePaths = new Set(
|
||
collectOwnPublicHtmlRelativePaths({
|
||
messages,
|
||
currentUser: currentUser ?? { id: userId },
|
||
publishDir,
|
||
materialized: materialized.materialized,
|
||
skipped: materialized.skipped,
|
||
}),
|
||
);
|
||
|
||
if (sessionId) {
|
||
const [rows] = await pool.query(
|
||
`SELECT workspace_relative_path
|
||
FROM h5_page_delivery_contracts
|
||
WHERE user_id = ? AND status = 'preparing'`,
|
||
[userId],
|
||
);
|
||
for (const row of rows ?? []) {
|
||
if (row?.workspace_relative_path) relativePaths.add(row.workspace_relative_path);
|
||
}
|
||
}
|
||
|
||
const readyPaths = await releaseMaterializedPageDeliveryContracts({
|
||
pool,
|
||
userId,
|
||
relativePaths: [...relativePaths],
|
||
allowPgRequired: true,
|
||
}).catch((error) => {
|
||
logger.warn?.('[ScheduledTask] release delivery contracts failed:', error);
|
||
return [];
|
||
});
|
||
for (const relativePath of readyPaths) {
|
||
logger.info?.('[ScheduledTask] delivery contract ready', {
|
||
userId,
|
||
sessionId,
|
||
relativePath,
|
||
});
|
||
}
|
||
return readyPaths;
|
||
}
|
||
|
||
export async function executeScheduledTask(task, {
|
||
userAuth,
|
||
tkmindProxy,
|
||
sessionSnapshotService = null,
|
||
pool = null,
|
||
h5Root = 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 tkmindProxy.fetchSessionConversationForUser === 'function') {
|
||
messages = await tkmindProxy.fetchSessionConversationForUser(task.userId, sessionId).catch(() => []);
|
||
} else if (typeof sessionSnapshotService?.get === 'function') {
|
||
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
|
||
messages = snapshot?.messages ?? snapshot?.conversation?.messages ?? [];
|
||
}
|
||
|
||
const publishDir = h5Root && task.userId
|
||
? path.join(h5Root, 'MindSpace', task.userId)
|
||
: null;
|
||
const readyPaths = await finalizeScheduledTaskPageDelivery({
|
||
pool,
|
||
userId: task.userId,
|
||
sessionId,
|
||
messages,
|
||
publishDir,
|
||
logger,
|
||
}).catch((error) => {
|
||
logger.warn?.('[ScheduledTask] finalize page delivery failed:', error);
|
||
return [];
|
||
});
|
||
|
||
let deliveryText = extractScheduledTaskDeliveryText(messages, task);
|
||
if (
|
||
readyPaths.length > 0
|
||
&& !/https?:\/\//i.test(deliveryText)
|
||
&& task.userId
|
||
) {
|
||
const links = readyPaths.map(
|
||
(relativePath) => `https://m.tkmind.cn/MindSpace/${task.userId}/${relativePath}`,
|
||
);
|
||
deliveryText = `${deliveryText}\n\n页面链接:\n${links.join('\n')}`.trim();
|
||
}
|
||
|
||
return {
|
||
sessionId,
|
||
requestId,
|
||
deliveryText,
|
||
messages,
|
||
readyPaths,
|
||
};
|
||
}
|