From 2c91689692170101c9ff823a2c9f8b011871d955 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 30 Jun 2026 02:02:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(schedule):=20=E5=BE=85=E5=8A=9E=E6=8F=90?= =?UTF-8?q?=E9=86=92=E6=8E=A8=E9=80=81=E3=80=81=E8=A1=8C=E4=BA=8B=E5=8E=86?= =?UTF-8?q?=E4=BB=85=E5=B1=95=E7=A4=BA=E6=8F=90=E9=86=92=E4=B8=8E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单次提醒 worker 投递、local 时间写入校验、未来 7 天提醒列表与忽略/批量删除;行事历去掉事项重复展示。 Co-authored-by: Cursor --- .runtime/portal/mindspace-sandbox-mcp.mjs | 263 +++- .runtime/portal/server.mjs | 1362 ++++++++++++----- .../portal/skills/schedule-assistant/SKILL.md | 8 + .../103-release-0630002-rollback.md | 67 + mindspace-sandbox-mcp.mjs | 52 +- mindspace.mjs | 12 +- mindspace.test.mjs | 40 + schedule-reminder-worker.mjs | 44 + schedule-reminder-worker.test.mjs | 152 ++ schedule-service.mjs | 240 ++- schedule-service.test.mjs | 114 ++ schedule-time.mjs | 61 + server.mjs | 33 + skills/schedule-assistant/SKILL.md | 8 + src/api/client.ts | 20 + src/components/MindSpaceView.tsx | 298 +++- src/index.css | 97 ++ src/types.ts | 17 + user-memory-profile.mjs | 13 + wechat-mp.mjs | 4 + 20 files changed, 2453 insertions(+), 452 deletions(-) create mode 100644 docs/regression-guards/103-release-0630002-rollback.md diff --git a/.runtime/portal/mindspace-sandbox-mcp.mjs b/.runtime/portal/mindspace-sandbox-mcp.mjs index 4080aa9..3250642 100755 --- a/.runtime/portal/mindspace-sandbox-mcp.mjs +++ b/.runtime/portal/mindspace-sandbox-mcp.mjs @@ -149,6 +149,42 @@ function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE) { const parts = getLocalParts(epochMs, timezone); return `${pad2(parts.hour)}:${pad2(parts.minute)}`; } +function parseLocalDateTimeString(value, timezone = DEFAULT_TIMEZONE) { + const raw = String(value ?? "").trim(); + if (!raw) return null; + const match = raw.match( + /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ + ); + if (!match) { + throw new Error(`\u65E0\u6CD5\u89E3\u6790\u672C\u5730\u65F6\u95F4\u300C${raw}\u300D\uFF0C\u8BF7\u4F7F\u7528 YYYY-MM-DD HH:mm`); + } + return zonedTimeToEpochMs( + { + year: Number(match[1]), + month: Number(match[2]), + day: Number(match[3]), + hour: Number(match[4] ?? 0), + minute: Number(match[5] ?? 0), + second: Number(match[6] ?? 0) + }, + timezone + ); +} +function resolveScheduleTimestamp({ + epochMs = null, + localString = null, + timezone = DEFAULT_TIMEZONE, + fieldName = "\u65F6\u95F4" +} = {}) { + const local = String(localString ?? "").trim(); + if (local) return parseLocalDateTimeString(local, timezone); + if (epochMs == null || epochMs === "") return null; + const safe = Number(epochMs); + if (!Number.isFinite(safe) || safe <= 0) { + throw new Error(`${fieldName}\u65E0\u6548\uFF0C\u8BF7\u6539\u7528 YYYY-MM-DD HH:mm \u7684 local \u5B57\u6BB5`); + } + return safe; +} // schedule-service.mjs var DEFAULT_TIMEZONE2 = "Asia/Shanghai"; @@ -227,6 +263,18 @@ function rowToReminder(row) { updatedAt: Number(row.updated_at) }; } +function rowToReminderWithItem(row) { + const reminder = rowToReminder(row); + if (!reminder) return null; + return { + ...reminder, + itemTitle: String(row.item_title ?? "").trim(), + itemKind: row.item_kind === "event" ? "event" : "task", + itemTimezone: row.item_timezone || DEFAULT_TIMEZONE2, + itemStartAt: row.item_start_at == null ? null : Number(row.item_start_at), + itemEndAt: row.item_end_at == null ? null : Number(row.item_end_at) + }; +} function parseJsonColumn(value) { if (value == null || value === "") return null; if (typeof value === "string") { @@ -387,6 +435,65 @@ function createScheduleService(pool, options = {}) { const end = addLocalDays(start, 1, timezone); return listItems({ userId, from: start, to: end, status: "active", limit: 200 }); }; + const listUpcomingItems = async ({ + userId, + timezone = defaultTimezone, + days = 7, + now = clock.now() + } = {}) => { + const safeDays = Math.max(1, Math.min(30, Number(days) || 7)); + const start = startOfLocalDay(now, timezone); + const end = addLocalDays(start, safeDays, timezone); + return listItems({ userId, from: start, to: end, status: "active", limit: 200 }); + }; + const listUpcomingReminders = async ({ + userId, + timezone = defaultTimezone, + days = 7, + now = clock.now(), + limit = 100 + } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const safeDays = Math.max(1, Math.min(30, Number(days) || 7)); + const start = startOfLocalDay(now, timezone); + const end = addLocalDays(start, safeDays, timezone); + const [rows] = await pool.query( + `SELECT r.*, + i.title AS item_title, + i.kind AS item_kind, + i.timezone AS item_timezone, + i.start_at AS item_start_at, + i.end_at AS item_end_at + FROM h5_schedule_reminders r + INNER JOIN h5_schedule_items i ON i.id = r.item_id + WHERE r.user_id = ? + AND r.status IN ('pending', 'locked') + AND r.remind_at >= ? + AND r.remind_at < ? + AND i.deleted_at IS NULL + AND i.status = 'active' + ORDER BY r.remind_at ASC + LIMIT ?`, + [ + userId, + start, + end, + Math.max(1, Math.min(200, Number(limit) || 100)) + ] + ); + return rows.map(rowToReminderWithItem); + }; + const getItem = async ({ userId, itemId }) => { + if (!userId || !itemId) throw new Error("\u7F3A\u5C11\u4E8B\u9879\u53C2\u6570"); + const [rows] = await pool.query( + `SELECT * + FROM h5_schedule_items + WHERE id = ? AND user_id = ? AND deleted_at IS NULL + LIMIT 1`, + [itemId, userId] + ); + return rowToItem(rows[0]); + }; const createReminder = async ({ userId, itemId, @@ -610,6 +717,88 @@ function createScheduleService(pool, options = {}) { attempts: 0 }; }; + const listDueReminders = async ({ now = clock.now(), limit = 50 } = {}) => { + const [rows] = await pool.query( + `SELECT r.* + FROM h5_schedule_reminders r + INNER JOIN h5_schedule_items i ON i.id = r.item_id + WHERE r.status = 'pending' + AND r.remind_at <= ? + AND (r.locked_until IS NULL OR r.locked_until <= ?) + AND i.deleted_at IS NULL + AND i.status = 'active' + ORDER BY r.remind_at ASC + LIMIT ?`, + [now, now, Math.max(1, Math.min(200, Number(limit) || 50))] + ); + return rows.map(rowToReminder); + }; + const lockReminder = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => { + const lockedUntil = now + lockMs; + const [result] = await pool.query( + `UPDATE h5_schedule_reminders + SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ? + WHERE id = ? AND status = 'pending' AND remind_at <= ?`, + [lockedUntil, now, id, now] + ); + if (Number(result?.affectedRows ?? 0) !== 1) return null; + const [rows] = await pool.query( + `SELECT * FROM h5_schedule_reminders WHERE id = ? LIMIT 1`, + [id] + ); + return rowToReminder(rows[0]); + }; + const markReminderSent = async (reminder, { now = clock.now() } = {}) => { + await pool.query( + `UPDATE h5_schedule_reminders + SET status = 'sent', sent_at = ?, locked_until = NULL, last_error = NULL, updated_at = ? + WHERE id = ?`, + [now, now, reminder.id] + ); + return { ...reminder, status: "sent", sentAt: now, lockedUntil: null }; + }; + const markReminderCancelled = async (reminder, reason, { now = clock.now() } = {}) => { + const lastError = String(reason ?? "\u5DF2\u53D6\u6D88").slice(0, 500); + await pool.query( + `UPDATE h5_schedule_reminders + SET status = 'cancelled', locked_until = NULL, last_error = ?, updated_at = ? + WHERE id = ?`, + [lastError, now, reminder.id] + ); + return { ...reminder, status: "cancelled", lastError }; + }; + const markReminderFailed = async (reminder, error, { now = clock.now(), retryMs = 10 * 60 * 1e3, maxAttempts = 5 } = {}) => { + const attempts = Number(reminder.attempts ?? 0); + const status = attempts >= maxAttempts ? "failed" : "pending"; + const lockedUntil = status === "pending" ? now + retryMs : null; + await pool.query( + `UPDATE h5_schedule_reminders + SET status = ?, locked_until = ?, last_error = ?, updated_at = ? + WHERE id = ?`, + [ + status, + lockedUntil, + String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), + now, + reminder.id + ] + ); + }; + const buildReminderText = async (reminder) => { + const item = await getItem({ userId: reminder.userId, itemId: reminder.itemId }); + if (!item || item.status !== "active") return null; + const timezone = item.timezone || defaultTimezone; + const remindLabel = formatLocalTime(reminder.remindAt, timezone); + const eventAt = item.startAt ?? item.dueAt ?? null; + const eventLabel = eventAt ? formatLocalTime(eventAt, timezone) : null; + const lines = [`\u3010\u5F85\u529E\u63D0\u9192\u3011${item.title}`]; + if (eventLabel && eventLabel !== remindLabel) { + lines.push(`\u4E8B\u9879\u65F6\u95F4\uFF1A${eventLabel}`); + } + lines.push(`\u63D0\u9192\u65F6\u95F4\uFF1A${remindLabel}`); + if (item.location) lines.push(`\u5730\u70B9\uFF1A${item.location}`); + return lines.join("\n"); + }; const listDueBalanceAlerts = async ({ now = clock.now(), limit = 50 } = {}) => { const [rows] = await pool.query( `SELECT * @@ -712,14 +901,24 @@ function createScheduleService(pool, options = {}) { [status, nextRunAt, String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), now, subscription.id] ); }; - const logDelivery = async ({ subscriptionId, userId, channel = "wechat", status, providerMessageId = null, errorCode = null, errorMessage = null }) => { + const logDelivery = async ({ + reminderId = null, + subscriptionId = null, + userId, + channel = "wechat", + status, + providerMessageId = null, + errorCode = null, + errorMessage = null + }) => { await pool.query( `INSERT INTO h5_schedule_delivery_logs (id, reminder_id, subscription_id, user_id, channel, status, provider_message_id, error_code, error_message, created_at) - VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ crypto.randomUUID(), + reminderId, subscriptionId, userId, channel, @@ -835,13 +1034,22 @@ function createScheduleService(pool, options = {}) { }; return { createItem, + getItem, createReminder, listItems, listItemsBySourceMessage, listTodayTodoItems, + listUpcomingItems, + listUpcomingReminders, listDigestSubscriptions, createDailyTodoDigest, createBalanceLowAlert, + listDueReminders, + lockReminder, + markReminderSent, + markReminderCancelled, + markReminderFailed, + buildReminderText, listDueDigestSubscriptions, lockDigestSubscription, markDigestSent, @@ -1031,9 +1239,12 @@ if (isScheduleConfigured()) { kind: { type: "string", description: "task \u6216 event\uFF0C\u9ED8\u8BA4 task" }, title: { type: "string", description: "\u4E8B\u9879\u6807\u9898" }, description: { type: "string", description: "\u4E8B\u9879\u63CF\u8FF0\uFF0C\u53EF\u9009" }, - startAt: { type: "number", description: "\u5F00\u59CB\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" }, - endAt: { type: "number", description: "\u7ED3\u675F\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" }, - dueAt: { type: "number", description: "\u622A\u6B62\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" }, + startAt: { type: "number", description: "\u5F00\u59CB\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009\uFF08\u4F18\u5148\u4F7F\u7528 startLocal\uFF09" }, + startLocal: { type: "string", description: "\u672C\u5730\u5F00\u59CB\u65F6\u95F4 YYYY-MM-DD HH:mm\uFF0C\u63A8\u8350" }, + endAt: { type: "number", description: "\u7ED3\u675F\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009\uFF08\u4F18\u5148\u4F7F\u7528 endLocal\uFF09" }, + endLocal: { type: "string", description: "\u672C\u5730\u7ED3\u675F\u65F6\u95F4 YYYY-MM-DD HH:mm\uFF0C\u63A8\u8350" }, + dueAt: { type: "number", description: "\u622A\u6B62\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009\uFF08\u4F18\u5148\u4F7F\u7528 dueLocal\uFF09" }, + dueLocal: { type: "string", description: "\u672C\u5730\u622A\u6B62\u65F6\u95F4 YYYY-MM-DD HH:mm\uFF0C\u53EF\u9009" }, allDay: { type: "boolean", description: "\u662F\u5426\u5168\u5929\u4E8B\u9879\uFF0C\u53EF\u9009" }, timezone: { type: "string", description: "\u65F6\u533A\uFF0C\u53EF\u9009" }, location: { type: "string", description: "\u5730\u70B9\uFF0C\u53EF\u9009" }, @@ -1050,11 +1261,12 @@ if (isScheduleConfigured()) { type: "object", properties: { itemId: { type: "string", description: "\u4E8B\u9879 ID" }, - remindAt: { type: "number", description: "\u63D0\u9192\u89E6\u53D1\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233" }, + remindAt: { type: "number", description: "\u63D0\u9192\u89E6\u53D1\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF08\u4F18\u5148\u4F7F\u7528 remindLocal\uFF09" }, + remindLocal: { type: "string", description: "\u672C\u5730\u63D0\u9192\u65F6\u95F4 YYYY-MM-DD HH:mm\uFF0C\u63A8\u8350" }, offsetMinutes: { type: "number", description: "\u76F8\u5BF9\u4E8B\u9879\u65F6\u95F4\u7684\u63D0\u524D\u5206\u949F\u6570\uFF0C\u53EF\u9009" }, channel: { type: "string", description: "\u63D0\u9192\u901A\u9053\uFF0C\u9ED8\u8BA4 wechat" } }, - required: ["itemId", "remindAt"] + required: ["itemId"] } }, { @@ -1303,16 +1515,35 @@ async function callTool(name, args) { case "private_data_execute": return [{ type: "text", text: await executePrivateData(args.sql) }]; case "schedule_create_item": { + const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai"; + const startAt = resolveScheduleTimestamp({ + epochMs: args.startAt, + localString: args.startLocal, + timezone, + fieldName: "\u5F00\u59CB\u65F6\u95F4" + }); + const endAt = resolveScheduleTimestamp({ + epochMs: args.endAt, + localString: args.endLocal, + timezone, + fieldName: "\u7ED3\u675F\u65F6\u95F4" + }); + const dueAt = resolveScheduleTimestamp({ + epochMs: args.dueAt, + localString: args.dueLocal, + timezone, + fieldName: "\u622A\u6B62\u65F6\u95F4" + }); const item = await getScheduleService().createItem({ userId: PRIVATE_DATA_USER_ID, kind: args.kind, title: args.title, description: args.description ?? null, - startAt: args.startAt ?? null, - endAt: args.endAt ?? null, - dueAt: args.dueAt ?? null, + startAt, + endAt, + dueAt, allDay: Boolean(args.allDay), - timezone: args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai", + timezone, location: args.location ?? null, sourceChannel: "agent", sourceMessageId: args.sourceMessageId ?? null, @@ -1322,10 +1553,18 @@ async function callTool(name, args) { return [{ type: "text", text: JSON.stringify(item, null, 2) }]; } case "schedule_create_reminder": { + const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai"; + const remindAt = resolveScheduleTimestamp({ + epochMs: args.remindAt, + localString: args.remindLocal, + timezone, + fieldName: "\u63D0\u9192\u65F6\u95F4" + }); + if (remindAt == null) throw new Error("\u7F3A\u5C11\u63D0\u9192\u65F6\u95F4 remindLocal \u6216 remindAt"); const reminder = await getScheduleService().createReminder({ userId: PRIVATE_DATA_USER_ID, itemId: args.itemId, - remindAt: args.remindAt, + remindAt, offsetMinutes: args.offsetMinutes ?? null, channel: args.channel ?? "wechat" }); diff --git a/.runtime/portal/server.mjs b/.runtime/portal/server.mjs index fc8d5f7..5448bfc 100644 --- a/.runtime/portal/server.mjs +++ b/.runtime/portal/server.mjs @@ -1631,15 +1631,15 @@ var require_pg_connection_string = __commonJS({ if (config.sslnegotiation === "direct" && config.ssl === void 0) { config.ssl = true; } - const fs27 = config.sslcert || config.sslkey || config.sslrootcert ? __require("fs") : null; + const fs29 = config.sslcert || config.sslkey || config.sslrootcert ? __require("fs") : null; if (config.sslcert) { - config.ssl.cert = fs27.readFileSync(config.sslcert).toString(); + config.ssl.cert = fs29.readFileSync(config.sslcert).toString(); } if (config.sslkey) { - config.ssl.key = fs27.readFileSync(config.sslkey).toString(); + config.ssl.key = fs29.readFileSync(config.sslkey).toString(); } if (config.sslrootcert) { - config.ssl.ca = fs27.readFileSync(config.sslrootcert).toString(); + config.ssl.ca = fs29.readFileSync(config.sslrootcert).toString(); } if (options.useLibpqCompat && config.uselibpqcompat) { throw new Error("Both useLibpqCompat and uselibpqcompat are set. Please use only one of them."); @@ -3458,7 +3458,7 @@ var require_split2 = __commonJS({ var require_helper = __commonJS({ "node_modules/.pnpm/pgpass@1.0.5/node_modules/pgpass/lib/helper.js"(exports, module) { "use strict"; - var path29 = __require("path"); + var path31 = __require("path"); var Stream = __require("stream").Stream; var split = require_split2(); var util = __require("util"); @@ -3497,7 +3497,7 @@ var require_helper = __commonJS({ }; module.exports.getFileName = function(rawEnv) { var env = rawEnv || process.env; - var file = env.PGPASSFILE || (isWin ? path29.join(env.APPDATA || "./", "postgresql", "pgpass.conf") : path29.join(env.HOME || "./", ".pgpass")); + var file = env.PGPASSFILE || (isWin ? path31.join(env.APPDATA || "./", "postgresql", "pgpass.conf") : path31.join(env.HOME || "./", ".pgpass")); return file; }; module.exports.usePgPass = function(stats, fname) { @@ -3629,16 +3629,16 @@ var require_helper = __commonJS({ var require_lib = __commonJS({ "node_modules/.pnpm/pgpass@1.0.5/node_modules/pgpass/lib/index.js"(exports, module) { "use strict"; - var path29 = __require("path"); - var fs27 = __require("fs"); + var path31 = __require("path"); + var fs29 = __require("fs"); var helper = require_helper(); module.exports = function(connInfo, cb) { var file = helper.getFileName(); - fs27.stat(file, function(err, stat) { + fs29.stat(file, function(err, stat) { if (err || !helper.usePgPass(stat, file)) { return cb(void 0); } - var st = fs27.createReadStream(file); + var st = fs29.createReadStream(file); helper.getPassword(connInfo, st, cb); }); }; @@ -5415,10 +5415,10 @@ var init_experience_service_pg = __esm({ // server.mjs import express2 from "express"; import crypto35 from "node:crypto"; -import fs26 from "node:fs"; +import fs28 from "node:fs"; import fsPromises3 from "node:fs/promises"; import { createProxyMiddleware } from "http-proxy-middleware"; -import path28 from "node:path"; +import path30 from "node:path"; import { fileURLToPath as fileURLToPath6 } from "node:url"; // auth.mjs @@ -5786,9 +5786,11 @@ function createMindSpaceService(pool2, options = {}) { let schedule = null; if (scheduleService2) { try { - const [todayTodoItems, digestSubscriptions] = await withOptionalTimeout( + const [todayTodoItems, upcomingItems, upcomingReminders, digestSubscriptions] = await withOptionalTimeout( Promise.all([ typeof scheduleService2.listTodayTodoItems === "function" ? scheduleService2.listTodayTodoItems({ userId }) : Promise.resolve([]), + typeof scheduleService2.listUpcomingItems === "function" ? scheduleService2.listUpcomingItems({ userId }) : Promise.resolve([]), + typeof scheduleService2.listUpcomingReminders === "function" ? scheduleService2.listUpcomingReminders({ userId }) : Promise.resolve([]), typeof scheduleService2.listDigestSubscriptions === "function" ? scheduleService2.listDigestSubscriptions({ userId, digestType: "todo_day", @@ -5797,9 +5799,9 @@ function createMindSpaceService(pool2, options = {}) { }) : Promise.resolve([]) ]), 1e3, - [[], []] + [[], [], [], []] ); - schedule = { todayTodoItems, digestSubscriptions }; + schedule = { todayTodoItems, upcomingItems, upcomingReminders, digestSubscriptions }; } catch { schedule = null; } @@ -7077,8 +7079,8 @@ var USER_API_ALLOWLIST = [ { method: "POST", pattern: /^\/agent\/harness_remember$/ } ]; function isNativeH5ApiPath(pathname) { - const path29 = String(pathname ?? ""); - return path29.startsWith("/mindspace/") || path29.startsWith("/plaza/"); + const path31 = String(pathname ?? ""); + return path31.startsWith("/mindspace/") || path31.startsWith("/plaza/"); } function evaluateProxyRequest(method, pathname, policies, { unrestricted = false } = {}) { if (unrestricted) return { allowed: true }; @@ -8359,11 +8361,11 @@ import crypto4 from "node:crypto"; function createImgproxySigner(key, salt) { const keyBuf = Buffer.from(key, "hex"); const saltBuf = Buffer.from(salt, "hex"); - function signPath(path29) { - const signaturePayload = Buffer.concat([saltBuf, Buffer.from(path29)]); + function signPath(path31) { + const signaturePayload = Buffer.concat([saltBuf, Buffer.from(path31)]); const signature = crypto4.createHmac("sha256", keyBuf).update(signaturePayload).digest(); const signatureB64 = signature.toString("base64url"); - return `/${signatureB64}${path29}`; + return `/${signatureB64}${path31}`; } function buildUrl(baseUrl, storagePath, preset = "display") { if (!baseUrl || !storagePath) { @@ -13103,10 +13105,10 @@ var PUBLIC_SCHEME = (process.env.LOCAL_TEST_SCHEME ?? (String(process.env.LOCAL_ var PUBLIC_PORT = Number( process.env.LOCAL_TEST_PUBLIC_PORT ?? (USES_LOCALHOST ? 8443 : PUBLIC_SCHEME === "https" ? 443 : 80) ); -function publicUrl(host, { path: path29 = "" } = {}) { +function publicUrl(host, { path: path31 = "" } = {}) { const defaultPort = PUBLIC_SCHEME === "https" ? 443 : 80; const portSuffix = PUBLIC_PORT === defaultPort ? "" : `:${PUBLIC_PORT}`; - const normalizedPath = path29.startsWith("/") ? path29 : path29 ? `/${path29}` : ""; + const normalizedPath = path31.startsWith("/") ? path31 : path31 ? `/${path31}` : ""; return `${PUBLIC_SCHEME}://${host}${portSuffix}${normalizedPath}`; } var H5_PUBLIC_BASE = (process.env.H5_PUBLIC_BASE_URL ?? publicUrl(H5_HOST)).replace(/\/$/, ""); @@ -16111,8 +16113,8 @@ function assertMindSpaceRoute(flags, route) { // mindspace-pages.mjs import crypto12 from "node:crypto"; -import fs14 from "node:fs/promises"; -import path18 from "node:path"; +import fs16 from "node:fs/promises"; +import path20 from "node:path"; // mindspace-content-scan.mjs import crypto11 from "node:crypto"; @@ -16366,6 +16368,193 @@ function redactContent(content, options = {}) { }; } +// mindspace-html-download-links.mjs +import fs14 from "node:fs"; +import path18 from "node:path"; +var DOWNLOADABLE_FILE_PATTERN = /\.(?:docx?|pdf|xlsx?|pptx?|zip|csv|txt|md)$/i; +var URL_ATTR_PATTERN = /(\b(?:href|src)\s*=\s*["'])([^"']+)(["'])/gi; +function isRelativeDownloadReference(value) { + const trimmed = String(value ?? "").trim(); + if (!trimmed) return false; + const pathPart = trimmed.split("#")[0].split("?")[0]; + if (/^([a-z][a-z0-9+.-]*:|\/|#|data:|mailto:|javascript:)/i.test(pathPart)) return false; + return DOWNLOADABLE_FILE_PATTERN.test(pathPart); +} +function splitReferenceParts(relativeRef) { + const value = String(relativeRef ?? ""); + const match = value.match(/^([^#?]+)(.*)$/); + return { + pathPart: match?.[1] ?? value, + suffix: match?.[2] ?? "" + }; +} +function resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef) { + const { pathPart } = splitReferenceParts(relativeRef); + const ref = String(pathPart ?? "").replace(/^\/+/, ""); + if (!ref) return ref; + if (/^(?:public|oa|private)\//.test(ref)) return ref; + const htmlPath = String(htmlRelativePath ?? "public/index.html").replace(/^\/+/, ""); + const htmlDir = path18.posix.dirname(htmlPath); + if (!htmlDir || htmlDir === ".") return `public/${ref}`; + return path18.posix.join(htmlDir, ref).replace(/\\/g, "/"); +} +function buildWorkspaceDownloadLinkIndex(assets = []) { + const byBasename = /* @__PURE__ */ new Map(); + const byPath = /* @__PURE__ */ new Map(); + for (const asset of assets) { + const id = asset.id; + const filename = String(asset.original_filename ?? asset.originalFilename ?? "").replace(/\\/g, "/"); + if (!id || !filename) continue; + byPath.set(filename, id); + byBasename.set(path18.posix.basename(filename), id); + if (filename.startsWith("public/")) { + byPath.set(filename.slice("public/".length), id); + } + } + return { byBasename, byPath }; +} +function lookupAssetIdForWorkspacePath(index, workspaceRelativePath) { + const normalized = String(workspaceRelativePath ?? "").replace(/\\/g, "/"); + if (!normalized || !index) return null; + return index.byPath.get(normalized) ?? index.byPath.get(`public/${normalized}`) ?? index.byBasename.get(path18.posix.basename(normalized)) ?? null; +} +function buildAssetDownloadUrl(assetId) { + return `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download`; +} +function buildPublicWorkspaceFileUrl(publicBaseUrl, publishKey, workspaceRelativePath) { + const clean = String(workspaceRelativePath ?? "").replace(/^\/+/, ""); + return buildPublicUrl(publicBaseUrl, publishKey, clean); +} +function workspaceFileExists(publishDir, workspaceRelativePath) { + if (!publishDir || !workspaceRelativePath) return false; + const absolutePath = path18.join(publishDir, ...String(workspaceRelativePath).split("/")); + try { + return fs14.existsSync(absolutePath) && fs14.statSync(absolutePath).isFile(); + } catch { + return false; + } +} +function resolveDownloadTargetUrl({ + relativeRef, + htmlRelativePath = "public/index.html", + publishDir = null, + linkIndex = null, + publicBaseUrl = "", + publishKey = null, + preferAssetDownload = true +}) { + const { suffix } = splitReferenceParts(relativeRef); + const workspacePath = resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef); + const assetId = lookupAssetIdForWorkspacePath(linkIndex, workspacePath); + const publicCandidates = [ + workspacePath, + path18.posix.join("public", path18.posix.basename(workspacePath)) + ]; + if (preferAssetDownload && assetId) { + return `${buildAssetDownloadUrl(assetId)}${suffix}`; + } + if (publishDir && publishKey) { + for (const candidate of publicCandidates) { + if (workspaceFileExists(publishDir, candidate)) { + return `${buildPublicWorkspaceFileUrl(publicBaseUrl, publishKey, candidate)}${suffix}`; + } + } + } + if (assetId) { + return `${buildAssetDownloadUrl(assetId)}${suffix}`; + } + return null; +} +function rewriteRelativeDownloadLinks(html, options = {}) { + const source = String(html ?? ""); + if (!source) return { html: source, count: 0 }; + let count = 0; + const rewritten = source.replace(URL_ATTR_PATTERN, (full, prefix, url, suffix) => { + if (!isRelativeDownloadReference(url)) return full; + const resolved = resolveDownloadTargetUrl({ ...options, relativeRef: url }); + if (!resolved) return full; + count += 1; + return `${prefix}${resolved}${suffix}`; + }); + return { html: rewritten, count }; +} +async function loadWorkspaceDownloadLinkIndex(pool2, userId) { + const [rows] = await pool2.query( + `SELECT id, original_filename + FROM h5_assets + WHERE user_id = ? AND status <> 'deleted'`, + [userId] + ); + return buildWorkspaceDownloadLinkIndex(rows); +} +async function prepareHtmlDownloadLinks(pool2, userId, html, options = {}) { + const linkIndex = options.linkIndex ?? await loadWorkspaceDownloadLinkIndex(pool2, userId); + return rewriteRelativeDownloadLinks(html, { ...options, linkIndex }); +} + +// mindspace-page-purge.mjs +import fs15 from "node:fs/promises"; +import path19 from "node:path"; +var PRIVATE_ASSET_URL_PATTERN = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; +var URL_ATTR_PATTERN2 = /(\b(?:href|src)\s*=\s*["'])([^"']+)(["'])/gi; +function extractAssetIdsFromHtml(html) { + return [ + ...new Set( + [...String(html ?? "").matchAll(PRIVATE_ASSET_URL_PATTERN)].map((match) => match[1]).filter(Boolean) + ) + ]; +} +function extractRelativeDownloadRefs(html) { + const refs = /* @__PURE__ */ new Set(); + for (const match of String(html ?? "").matchAll(URL_ATTR_PATTERN2)) { + const url = match[2]; + if (isRelativeDownloadReference(url)) { + refs.add(splitReferenceParts(url).pathPart); + } + } + return [...refs]; +} +function collectWorkspacePurgeTargets(htmlRelativePath, html) { + const targets = /* @__PURE__ */ new Set(); + const normalizedHtmlPath = String(htmlRelativePath ?? "").replace(/^\/+/, ""); + if (normalizedHtmlPath) { + targets.add(normalizedHtmlPath); + if (normalizedHtmlPath.toLowerCase().endsWith(".html")) { + targets.add(workspaceThumbnailRelativePath(normalizedHtmlPath)); + const pngThumb = normalizedHtmlPath.replace(/\.html$/i, ".thumbnail.png"); + if (pngThumb !== normalizedHtmlPath) targets.add(pngThumb); + } + } + for (const ref of extractRelativeDownloadRefs(html)) { + targets.add(resolveWorkspaceRelativeFilePath(normalizedHtmlPath || "public/index.html", ref)); + } + return [...targets].filter(Boolean); +} +async function purgeWorkspacePageArtifacts({ + publishDir, + htmlRelativePath = null, + html = "" +} = {}) { + if (!publishDir) return { removed: [], skipped: [] }; + const resolvedRoot = path19.resolve(publishDir); + const removed = []; + const skipped = []; + for (const relativeTarget of collectWorkspacePurgeTargets(htmlRelativePath, html)) { + const absolutePath = path19.resolve(publishDir, ...relativeTarget.split("/")); + if (absolutePath !== resolvedRoot && !absolutePath.startsWith(`${resolvedRoot}${path19.sep}`)) { + skipped.push(relativeTarget); + continue; + } + try { + await fs15.unlink(absolutePath); + removed.push(relativeTarget); + } catch (error) { + if (error?.code !== "ENOENT") skipped.push(relativeTarget); + } + } + return { removed, skipped }; +} + // mindspace-cover-meta.mjs function escapeMetaAttribute(value) { return String(value ?? "").replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<"); @@ -16418,10 +16607,19 @@ var MAX_SUMMARY_LENGTH = 1e3; var MAX_CONTENT_BYTES = 1024 * 1024; var TEMPLATE_IDS = /* @__PURE__ */ new Set(["editorial", "report", "profile", "knowledge-card", "static-html"]); var SAVE_CATEGORY_CODES = /* @__PURE__ */ new Set(["draft", "oa", "private", "public"]); -var PRIVATE_ASSET_URL_PATTERN = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; +var PRIVATE_ASSET_URL_PATTERN2 = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; function asNumber4(value) { return Number(value ?? 0); } +function parseJsonColumn(value, fallback = {}) { + if (value == null || value === "") return { ...fallback }; + if (typeof value === "object" && !Buffer.isBuffer(value)) return value; + try { + return JSON.parse(String(value)); + } catch { + return { ...fallback }; + } +} function pageError(message, code, details) { return Object.assign(new Error(message), { code, details }); } @@ -16456,7 +16654,7 @@ function normalizePageInput(input) { }; } function extensionForMime2(mimeType, filename = "") { - const ext = path18.extname(String(filename ?? "")).replace(/^\./, "").toLowerCase(); + const ext = path20.extname(String(filename ?? "")).replace(/^\./, "").toLowerCase(); if (ext && /^[a-z0-9]{1,8}$/.test(ext)) return ext === "jpeg" ? "jpg" : ext; if (mimeType === "image/jpeg") return "jpg"; if (mimeType === "image/png") return "png"; @@ -16464,8 +16662,8 @@ function extensionForMime2(mimeType, filename = "") { if (mimeType === "image/gif") return "gif"; return "bin"; } -function pageResponse(row) { - return { +function pageResponse(row, { includeContent = true } = {}) { + const response = { id: row.id, categoryId: row.category_id, categoryCode: row.category_code, @@ -16484,10 +16682,19 @@ function pageResponse(row) { publicationUrl: row.pub_public_url ?? null, currentVersionId: row.current_version_id, versionNo: asNumber4(row.version_no), - content: row.content, createdAt: asNumber4(row.created_at), updatedAt: asNumber4(row.updated_at) }; + if (includeContent) { + response.content = row.content; + } + return response; +} +var LIST_PAGES_MAX_LIMIT = 100; +function normalizeListPageFilters(filters = {}) { + const limit = Math.min(Math.max(Number(filters.limit) || LIST_PAGES_MAX_LIMIT, 1), LIST_PAGES_MAX_LIMIT); + const offset = Math.max(Number(filters.offset) || 0, 0); + return { limit, offset }; } function escapeHtml2(value) { return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); @@ -16574,8 +16781,8 @@ function renderPublicationHtml(page) { ); } function createPageService(pool2, options = {}) { - const storageRoot = path18.resolve(options.storageRoot ?? path18.join(process.cwd(), "data", "mindspace")); - const h5Root = options.h5Root ? path18.resolve(options.h5Root) : null; + const storageRoot = path20.resolve(options.storageRoot ?? path20.join(process.cwd(), "data", "mindspace")); + const h5Root = options.h5Root ? path20.resolve(options.h5Root) : null; const idFactory = options.idFactory ?? (() => crypto12.randomUUID()); const resolveWorkspacePublishDir = async (userId) => { if (!h5Root) return null; @@ -16588,8 +16795,8 @@ function createPageService(pool2, options = {}) { return resolvePublishDir(h5Root, { id: userId }); }; const absoluteStoragePath2 = (storageKey) => { - const resolved = path18.resolve(storageRoot, storageKey); - if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path18.sep}`)) { + const resolved = path20.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path20.sep}`)) { throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); } return resolved; @@ -16611,7 +16818,7 @@ function createPageService(pool2, options = {}) { for (const candidate of resolveStoragePathCandidates(storageKey)) { const absolutePath = absoluteStoragePath2(candidate); try { - await fs14.stat(absolutePath); + await fs16.stat(absolutePath); return absolutePath; } catch (error) { if (error?.code === "ENOENT") { @@ -16624,7 +16831,7 @@ function createPageService(pool2, options = {}) { throw lastError ?? Object.assign(new Error("\u5B58\u50A8\u6587\u4EF6\u4E0D\u5B58\u5728"), { code: "storage_not_found" }); }; const writeVersionContent = async (userId, assetId, versionId, content) => { - const storageKey = path18.posix.join( + const storageKey = path20.posix.join( "users", userId, "pages", @@ -16633,8 +16840,8 @@ function createPageService(pool2, options = {}) { `${versionId}.md` ); const target = absoluteStoragePath2(storageKey); - await fs14.mkdir(path18.dirname(target), { recursive: true }); - await fs14.writeFile(target, content, { flag: "wx" }); + await fs16.mkdir(path20.dirname(target), { recursive: true }); + await fs16.writeFile(target, content, { flag: "wx" }); return { storageKey, target }; }; const createVersion = async (userId, input, source = {}) => { @@ -16861,7 +17068,7 @@ function createPageService(pool2, options = {}) { return getPage(userId, pageId); } catch (error) { await conn.rollback(); - if (writtenPath) await fs14.rm(writtenPath, { force: true }).catch(() => { + if (writtenPath) await fs16.rm(writtenPath, { force: true }).catch(() => { }); throw error; } finally { @@ -16905,17 +17112,35 @@ function createPageService(pool2, options = {}) { clauses.push(`p.status = ?`); params.push(filters.status); } - const [rows] = await pool2.query( - `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url - FROM h5_page_records p + if (filters.categoryCode) { + clauses.push(`c.category_code = ?`); + params.push(filters.categoryCode); + } + const where = clauses.join(" AND "); + const { limit, offset } = normalizeListPageFilters(filters); + const baseFrom = `FROM h5_page_records p JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id - LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online' - WHERE ${clauses.join(" AND ")} - ORDER BY p.created_at DESC, p.updated_at DESC LIMIT 100`, - params - ); - return rows.map(pageResponse); + LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'`; + const [[countRows], [rows]] = await Promise.all([ + pool2.query(`SELECT COUNT(*) AS total ${baseFrom} WHERE ${where}`, params), + pool2.query( + `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url + ${baseFrom} + WHERE ${where} + ORDER BY p.created_at DESC, p.updated_at DESC + LIMIT ? OFFSET ?`, + [...params, limit, offset] + ) + ]); + const total = asNumber4(countRows[0]?.total); + return { + items: rows.map((row) => pageResponse(row, { includeContent: false })), + total, + limit, + offset, + hasMore: offset + rows.length < total + }; }; async function getPage(userId, pageId) { const [rows] = await pool2.query( @@ -16931,7 +17156,7 @@ function createPageService(pool2, options = {}) { const row = rows[0]; if (!row) throw pageError("\u9875\u9762\u4E0D\u5B58\u5728", "page_not_found"); const storagePath = await resolveReadableStoragePath(row.storage_key); - const content = await fs14.readFile(storagePath, "utf8"); + const content = await fs16.readFile(storagePath, "utf8"); return pageResponse({ ...row, content }); } const listVersions = async (userId, pageId) => { @@ -17025,6 +17250,45 @@ ${updated.content ?? ""}`, changes }; }; + const loadPageWorkspaceContext = async (userId, pageId) => { + const [rows] = await pool2.query( + `SELECT pv.source_snapshot_json + FROM h5_page_records p + JOIN h5_page_versions pv ON pv.id = p.current_version_id + WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted' + LIMIT 1`, + [pageId, userId] + ); + let snapshot = {}; + try { + snapshot = parseJsonColumn(rows[0]?.source_snapshot_json); + } catch { + snapshot = {}; + } + const workspaceHtmlRelativePath = snapshot.relative_path ?? "public/index.html"; + const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null; + return { + workspaceHtmlRelativePath, + workspacePublishDir, + publishKey: userId + }; + }; + const finalizeHtmlPreview = async (userId, pageId, html) => { + const shell = renderHtmlPreview(html); + try { + const ctx = await loadPageWorkspaceContext(userId, pageId); + const { html: rewritten } = await prepareHtmlDownloadLinks(pool2, userId, shell, { + htmlRelativePath: ctx.workspaceHtmlRelativePath, + publishDir: ctx.workspacePublishDir, + publishKey: ctx.publishKey, + publicBaseUrl: "", + preferAssetDownload: true + }); + return rewritten; + } catch { + return shell; + } + }; const localizePrivateResources = async (userId, pageId, input = {}) => { const page = await getPage(userId, pageId); if (input.pageVersionId && input.pageVersionId !== page.currentVersionId) { @@ -17043,7 +17307,7 @@ ${updated.content ?? ""}`, const sourceTitle = input.title ?? page.title; const sourceSummary = input.summary ?? page.summary ?? ""; const sourceContent = input.content ?? page.content ?? ""; - const matches = [...String(sourceContent).matchAll(PRIVATE_ASSET_URL_PATTERN)]; + const matches = [...String(sourceContent).matchAll(PRIVATE_ASSET_URL_PATTERN2)]; if (matches.length === 0) { throw pageError("\u672A\u53D1\u73B0\u53EF\u5B89\u5168\u4FEE\u590D\u7684\u79C1\u6709\u56FE\u7247\u5F15\u7528", "publish_fix_not_needed"); } @@ -17061,7 +17325,7 @@ ${updated.content ?? ""}`, for (const assetId of assetIds) { const asset = byId.get(assetId); if (!asset) continue; - const buffer = await fs14.readFile(absoluteStoragePath2(asset.storage_key)); + const buffer = await fs16.readFile(absoluteStoragePath2(asset.storage_key)); const mimeType = String(asset.mime_type || "application/octet-stream"); const ext = extensionForMime2(mimeType, asset.original_filename); const dataUri = `data:${mimeType};base64,${buffer.toString("base64")}`; @@ -17072,7 +17336,7 @@ ${updated.content ?? ""}`, } let changedCount = 0; const updatedContent = String(sourceContent).replace( - PRIVATE_ASSET_URL_PATTERN, + PRIVATE_ASSET_URL_PATTERN2, (value, assetId) => { const replacement = replacements.get(assetId); if (!replacement) return value; @@ -17128,7 +17392,7 @@ ${updated.content ?? ""}`, ] }; }; - const collectLinkedAssets = async (conn, userId, pageId) => { + const collectLinkedAssets = async (conn, userId, pageId, options2 = {}) => { const [versions] = await conn.query( `SELECT pv.content_asset_id, pv.bundle_asset_id FROM h5_page_versions pv @@ -17140,6 +17404,12 @@ ${updated.content ?? ""}`, if (version.content_asset_id) assetKind.set(version.content_asset_id, "content"); if (version.bundle_asset_id) assetKind.set(version.bundle_asset_id, "bundle"); } + if (options2.sourceAssetId) { + assetKind.set(options2.sourceAssetId, "source"); + } + for (const assetId of options2.embeddedAssetIds ?? []) { + if (assetId) assetKind.set(assetId, "embedded"); + } const assetIds = [...assetKind.keys()]; if (assetIds.length === 0) { return { assets: [], totalBytes: 0, assetIds: [] }; @@ -17162,6 +17432,32 @@ ${updated.content ?? ""}`, assetIds: linkedAssets.map((asset) => asset.id) }; }; + const listPlazaPostsForPage = async (conn, userId, pageId) => { + const [rows] = await conn.query( + `SELECT pp.id, pp.title, pp.status, pp.publication_id + FROM plaza_posts pp + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pr.page_id = ? AND pr.user_id = ? AND pp.status != 'hidden' + ORDER BY pp.published_at DESC, pp.updated_at DESC`, + [pageId, userId] + ); + return rows.map((row) => ({ + id: row.id, + title: row.title, + status: row.status, + publicationId: row.publication_id + })); + }; + const hidePlazaPostsForPage = async (conn, userId, pageId, now) => { + const [result] = await conn.query( + `UPDATE plaza_posts pp + JOIN h5_publish_records pr ON pr.id = pp.publication_id + SET pp.status = 'hidden', pp.updated_at = ? + WHERE pr.page_id = ? AND pr.user_id = ? AND pp.status != 'hidden'`, + [now, pageId, userId] + ); + return asNumber4(result.affectedRows); + }; const offlineOnlinePublications = async (conn, userId, pageId, now) => { const [rows] = await conn.query( `SELECT id, page_version_id, access_mode @@ -17236,13 +17532,17 @@ ${updated.content ?? ""}`, ); const page = rows[0]; if (!page) throw pageError("\u9875\u9762\u4E0D\u5B58\u5728", "page_not_found"); + const pageDetail = await getPage(userId, pageId).catch(() => null); const conn = await pool2.getConnection(); try { const [versionRows] = await conn.query( `SELECT COUNT(*) AS version_count FROM h5_page_versions WHERE page_id = ?`, [pageId] ); - const { assets, totalBytes } = await collectLinkedAssets(conn, userId, pageId); + const { assets, totalBytes } = await collectLinkedAssets(conn, userId, pageId, { + sourceAssetId: page.source_asset_id, + embeddedAssetIds: pageDetail?.contentFormat === "html" ? extractAssetIdsFromHtml(pageDetail.content ?? "") : [] + }); const [publications] = await conn.query( `SELECT id, status, public_url, access_mode, view_count FROM h5_publish_records @@ -17258,6 +17558,7 @@ ${updated.content ?? ""}`, ); const onlinePublication = publications.find((item) => item.status === "online") ?? null; const offlinePublicationCount = publications.filter((item) => item.status !== "online").length; + const plazaPosts2 = await listPlazaPostsForPage(conn, userId, pageId); const preview = { page: { id: page.id, @@ -17272,36 +17573,60 @@ ${updated.content ?? ""}`, viewCount: asNumber4(onlinePublication.view_count) } : null, offlinePublicationCount, + plazaPosts: plazaPosts2, linkedAssets: assets, linkedAssetBytes: totalBytes, linkedAgentJobCount: asNumber4(agentJobs[0]?.job_count), - preservesSourceAsset: Boolean(page.source_asset_id) + preservesSourceAsset: false }; return { ...preview, summaryLines: buildPageDeleteSummary(preview) }; } finally { conn.release(); } }; - const deletePage = async (userId, pageId) => { + const deletePage = async (userId, pageId, options2 = {}) => { + const removeFromPlaza = Boolean(options2.removeFromPlaza); + const page = await getPage(userId, pageId); + let workspaceHtmlRelativePath = null; + try { + const [rows] = await pool2.query( + `SELECT pv.source_snapshot_json + FROM h5_page_records p + JOIN h5_page_versions pv ON pv.id = p.current_version_id + WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted' + LIMIT 1`, + [pageId, userId] + ); + const snapshot = parseJsonColumn(rows[0]?.source_snapshot_json); + workspaceHtmlRelativePath = snapshot.relative_path ?? null; + } catch { + workspaceHtmlRelativePath = null; + } + const embeddedAssetIds = page.contentFormat === "html" ? extractAssetIdsFromHtml(page.content ?? "") : []; const conn = await pool2.getConnection(); + let result; try { await conn.beginTransaction(); const [pages] = await conn.query( - `SELECT id, space_id, title + `SELECT id, space_id, title, source_asset_id FROM h5_page_records WHERE id = ? AND user_id = ? AND status <> 'deleted' LIMIT 1 FOR UPDATE`, [pageId, userId] ); - const page = pages[0]; - if (!page) throw pageError("\u9875\u9762\u4E0D\u5B58\u5728", "page_not_found"); + const pageRow = pages[0]; + if (!pageRow) throw pageError("\u9875\u9762\u4E0D\u5B58\u5728", "page_not_found"); const now = Date.now(); - const { assetIds } = await collectLinkedAssets(conn, userId, pageId); + const { assetIds } = await collectLinkedAssets(conn, userId, pageId, { + sourceAssetId: pageRow.source_asset_id, + embeddedAssetIds + }); const offlinedPublicationCount = await offlineOnlinePublications(conn, userId, pageId, now); + const hiddenPlazaPostCount = removeFromPlaza ? await hidePlazaPostsForPage(conn, userId, pageId, now) : 0; const { deletedAssetCount, freedBytes } = await softDeleteLinkedAssets( conn, userId, - page.space_id, + pageRow.space_id, assetIds, now ); @@ -17321,16 +17646,17 @@ ${updated.content ?? ""}`, await conn.query( `UPDATE h5_page_records SET status = 'deleted', deleted_at = ?, updated_at = ?, - current_publish_id = NULL, visibility = 'private' + current_publish_id = NULL, visibility = 'private', source_asset_id = NULL WHERE id = ? AND user_id = ?`, [now, now, pageId, userId] ); await conn.commit(); - return { + result = { deleted: true, pageId, - title: page.title, + title: pageRow.title, offlinedPublicationCount, + hiddenPlazaPostCount, deletedAssetCount, freedBytes, clearedAgentJobCount @@ -17341,6 +17667,16 @@ ${updated.content ?? ""}`, } finally { conn.release(); } + const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null; + const purgeResult = await purgeWorkspacePageArtifacts({ + publishDir: workspacePublishDir, + htmlRelativePath: workspaceHtmlRelativePath, + html: page.content ?? "" + }).catch((error) => { + console.warn("[MindSpace] workspace purge failed:", error?.message ?? error); + return { removed: [], skipped: [] }; + }); + return { ...result, workspaceFilesRemoved: purgeResult.removed ?? [] }; }; const loadPageThumbnailContext = async (userId, pageId) => { const [rows] = await pool2.query( @@ -17358,10 +17694,10 @@ ${updated.content ?? ""}`, throw pageError("\u8BE5\u9875\u9762\u4E0D\u652F\u6301\u7F29\u7565\u56FE", "thumbnail_not_supported"); } const storagePath = await resolveReadableStoragePath(row.storage_key); - const content = await fs14.readFile(storagePath, "utf8"); + const content = await fs16.readFile(storagePath, "utf8"); let snapshot = {}; try { - snapshot = JSON.parse(row.source_snapshot_json ?? "{}"); + snapshot = parseJsonColumn(row.source_snapshot_json); } catch { snapshot = {}; } @@ -17485,7 +17821,7 @@ ${updated.content ?? ""}`, listVersions, renderPreview: async (userId, pageId) => { const page = await getPage(userId, pageId); - const html = page.contentFormat === "html" ? renderHtmlPreview(page.content) : renderPreviewHtml(page); + const html = page.contentFormat === "html" ? await finalizeHtmlPreview(userId, pageId, page.content) : renderPreviewHtml(page); return { html, contentFormat: page.contentFormat }; }, renderDraftPreview: async (userId, pageId, input = {}) => { @@ -17494,7 +17830,7 @@ ${updated.content ?? ""}`, const summary = String(input.summary ?? page.summary ?? "").trim(); const content = String(input.content ?? page.content ?? ""); const templateId = input.templateId ?? page.templateId; - const html = page.contentFormat === "html" ? renderHtmlPreview(content) : renderPreviewHtml({ + const html = page.contentFormat === "html" ? await finalizeHtmlPreview(userId, pageId, content) : renderPreviewHtml({ title, summary, templateId, @@ -17525,6 +17861,8 @@ ${updated.content ?? ""}`, var pageInternals = { escapeHtml: escapeHtml2, normalizePageInput, + normalizeListPageFilters, + parseJsonColumn, renderContent, renderPreviewHtml, renderHtmlPreview, @@ -17536,10 +17874,10 @@ var pageInternals = { }; async function inlinePrivateAssetsInHtml(pool2, storageRoot, userId, html) { const PATTERN = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; - const resolvedRoot = path18.resolve(storageRoot); + const resolvedRoot = path20.resolve(storageRoot); const absoluteStoragePath2 = (key) => { - const resolved = path18.resolve(resolvedRoot, key); - if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path18.sep}`)) { + const resolved = path20.resolve(resolvedRoot, key); + if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path20.sep}`)) { throw Object.assign(new Error("\u8DEF\u5F84\u8D8A\u754C"), { code: "invalid_storage_path" }); } return resolved; @@ -17558,7 +17896,7 @@ async function inlinePrivateAssetsInHtml(pool2, storageRoot, userId, html) { const dataUriMap = /* @__PURE__ */ new Map(); for (const asset of assets) { try { - const buffer = await fs14.readFile(absoluteStoragePath2(asset.storage_key)); + const buffer = await fs16.readFile(absoluteStoragePath2(asset.storage_key)); dataUriMap.set(asset.id, `data:${asset.mime_type};base64,${buffer.toString("base64")}`); } catch { } @@ -17590,6 +17928,11 @@ function buildPageDeleteSummary(preview) { if (preview.linkedAgentJobCount > 0) { lines.push(`${preview.linkedAgentJobCount} \u4E2A Agent \u4EFB\u52A1\u7ED3\u679C\u5173\u8054\u5C06\u88AB\u6E05\u9664`); } + if (preview.plazaPosts?.length > 0) { + lines.push( + `\u8BE5\u9875\u9762\u5728\u5E7F\u573A\u6709 ${preview.plazaPosts.length} \u4E2A\u5E16\u5B50\uFF1B\u53EF\u5728\u4E0B\u65B9\u9009\u62E9\u662F\u5426\u4E00\u5E76\u4ECE\u5E7F\u573A\u79FB\u9664` + ); + } if (preview.preservesSourceAsset) { lines.push("\u539F\u59CB\u4E0A\u4F20\u8D44\u6599\u4E0D\u4F1A\u88AB\u5220\u9664"); } @@ -17995,9 +18338,9 @@ import { jsonrepair } from "jsonrepair"; // llm-providers.mjs import crypto13 from "node:crypto"; -import fs15 from "node:fs"; +import fs17 from "node:fs"; import os from "node:os"; -import path19 from "node:path"; +import path21 from "node:path"; import { spawn, spawnSync } from "node:child_process"; import { Agent as Agent3, fetch as undiciFetch3 } from "undici"; var CUSTOM_PROVIDER_ID = "__custom__"; @@ -18351,7 +18694,7 @@ function executorEnvForProfile(executor, profile, model, { includeSecret = false }; } function launchLogDir() { - return path19.join(os.tmpdir(), "memindadm-launches"); + return path21.join(os.tmpdir(), "memindadm-launches"); } function normalizeLaunchMode(mode) { const normalized = String(mode ?? "headless").trim().toLowerCase(); @@ -18381,13 +18724,13 @@ function spawnDetachedExecutor(plan, { logDir = launchLogDir() } = {}) { message: `${plan.command} \u672A\u5B89\u88C5\u6216\u4E0D\u5728 PATH \u4E2D` }; } - fs15.mkdirSync(logDir, { recursive: true }); + fs17.mkdirSync(logDir, { recursive: true }); const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); - const logFile = path19.join( + const logFile = path21.join( logDir, `${plan.executor ?? "executor"}-${stamp}-${crypto13.randomUUID().slice(0, 8)}.log` ); - const fd = fs15.openSync(logFile, "a"); + const fd = fs17.openSync(logFile, "a"); try { const child = spawn(plan.command, plan.args ?? [], { cwd: plan.cwd ?? process.cwd(), @@ -18418,7 +18761,7 @@ function spawnDetachedExecutor(plan, { logDir = launchLogDir() } = {}) { }; } finally { try { - fs15.closeSync(fd); + fs17.closeSync(fd); } catch { } } @@ -19916,8 +20259,8 @@ async function suggestCoverMetaWithAi(pool2, { title, summary, html, instruction // mindspace-publications.mjs import crypto14 from "node:crypto"; -import fs16 from "node:fs/promises"; -import path20 from "node:path"; +import fs18 from "node:fs/promises"; +import path22 from "node:path"; // mindspace-html-localize.mjs var GOOGLE_FONTS_CSS_IMPORT_RE = /@import\s+url\(\s*['"]?(https:\/\/fonts\.googleapis\.com\/[^'")\s]+)['"]?\s*\)\s*;?/gi; @@ -20131,9 +20474,9 @@ function replaceImgproxyStandardImageReferences(html, publicBaseUrl) { function workspacePublicAssetRelativeUrl(htmlRelativePath, assetRelativePath) { const htmlPath = String(htmlRelativePath ?? "").replace(/^\/+/, "") || "public/index.html"; const assetPath = String(assetRelativePath ?? "").replace(/^\/+/, ""); - const htmlDir = path20.posix.dirname(htmlPath); - const relativePath = path20.posix.relative(htmlDir === "." ? "" : htmlDir, assetPath).replace(/\\/g, "/"); - return relativePath || path20.posix.basename(assetPath); + const htmlDir = path22.posix.dirname(htmlPath); + const relativePath = path22.posix.relative(htmlDir === "." ? "" : htmlDir, assetPath).replace(/\\/g, "/"); + return relativePath || path22.posix.basename(assetPath); } function rewriteWorkspacePublicAssetReferences(html, htmlRelativePath) { const source = String(html ?? ""); @@ -20172,7 +20515,7 @@ async function localizePrivateImageReferences({ replacements.set(assetId, imgproxySigner.buildUrl(imgproxySigner.baseUrl, asset.storage_key, "display")); } else { const mimeType = String(asset.mime_type || "application/octet-stream"); - const buffer = await fs16.readFile(absoluteStoragePath2(asset.storage_key)); + const buffer = await fs18.readFile(absoluteStoragePath2(asset.storage_key)); replacements.set(assetId, `data:${mimeType};base64,${buffer.toString("base64")}`); } } @@ -20189,7 +20532,8 @@ async function prepareHtmlPublishContent({ urlSlug, htmlRelativePath = `public/${urlSlug}.html`, absoluteStoragePath: absoluteStoragePath2, - imgproxySigner = null + imgproxySigner = null, + h5Root = null }) { let publishContent = (await localizeGoogleFontsCss(html)).html; publishContent = replaceImgproxyStandardImageReferences( @@ -20204,13 +20548,24 @@ async function prepareHtmlPublishContent({ imgproxySigner }); publishContent = rewriteWorkspacePublicAssetReferences(publishContent, htmlRelativePath); + const publishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null; + const linkIndex = await loadWorkspaceDownloadLinkIndex(pool2, userId); + publishContent = rewriteRelativeDownloadLinks(publishContent, { + htmlRelativePath, + publishDir, + linkIndex, + publishKey: userId, + publicBaseUrl: resolvePublicBaseUrl(), + preferAssetDownload: false + }).html; return replacePrivateResourceReferences( publishContent, buildPublicationThumbnailFallback(ownerSlug, urlSlug) ); } function createPublicationService(pool2, options = {}) { - const storageRoot = path20.resolve(options.storageRoot ?? path20.join(process.cwd(), "data", "mindspace")); + const storageRoot = path22.resolve(options.storageRoot ?? path22.join(process.cwd(), "data", "mindspace")); + const h5Root = options.h5Root ?? null; const idFactory = options.idFactory ?? (() => crypto14.randomUUID()); const publicPageLimitFallback = Number(options.publicPageLimit ?? 5); let imgproxySigner = null; @@ -20235,8 +20590,8 @@ function createPublicationService(pool2, options = {}) { } }; const absoluteStoragePath2 = (storageKey) => { - const resolved = path20.resolve(storageRoot, storageKey); - if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path20.sep}`)) { + const resolved = path22.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path22.sep}`)) { throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); } return resolved; @@ -20258,7 +20613,7 @@ function createPublicationService(pool2, options = {}) { for (const candidate of resolveStoragePathCandidates(storageKey)) { const absolutePath = absoluteStoragePath2(candidate); try { - await fs16.stat(absolutePath); + await fs18.stat(absolutePath); return absolutePath; } catch (error) { if (error?.code === "ENOENT") { @@ -20287,7 +20642,7 @@ function createPublicationService(pool2, options = {}) { return { ...row, version_no: Number(row.version_no), - content: await fs16.readFile(await resolveReadableStoragePath(row.storage_key), "utf8") + content: await fs18.readFile(await resolveReadableStoragePath(row.storage_key), "utf8") }; }; const loadOwnerSlug = async (userId) => { @@ -20312,7 +20667,8 @@ function createPublicationService(pool2, options = {}) { ownerSlug, urlSlug, absoluteStoragePath: absoluteStoragePath2, - imgproxySigner: imgproxySigner ? { buildUrl: (path29, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path29, preset) } : null + h5Root, + imgproxySigner: imgproxySigner ? { buildUrl: (path31, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path31, preset) } : null }); }; const persistScan = async (conn, userId, pageVersionId, scan, now) => { @@ -20461,7 +20817,7 @@ ${publishContent}`, { const privateToken = result.accessMode === "private_link" ? crypto14.randomBytes(24).toString("base64url") : null; const tokenHash = privateToken ? crypto14.createHash("sha256").update(privateToken).digest("hex") : null; const passwordHash = result.accessMode === "password" ? hashPassword(input.password) : null; - const storageKey = path20.posix.join( + const storageKey = path22.posix.join( "users", userId, "publications", @@ -20469,8 +20825,8 @@ ${publishContent}`, { "index.html" ); writtenPath = absoluteStoragePath2(storageKey); - await fs16.mkdir(path20.dirname(writtenPath), { recursive: true }); - await fs16.writeFile(writtenPath, html, { flag: "wx" }); + await fs18.mkdir(path22.dirname(writtenPath), { recursive: true }); + await fs18.writeFile(writtenPath, html, { flag: "wx" }); const checksum = crypto14.createHash("sha256").update(html).digest("hex"); const publicBaseUrl = resolvePublicBaseUrl(); const publicUrl2 = privateToken ? `/s/${privateToken}` : `${publicBaseUrl}/u/${encodeURIComponent(ownerSlug)}/pages/${result.urlSlug}`; @@ -20621,7 +20977,7 @@ ${publishContent}`, { }; } catch (error) { await conn.rollback(); - if (writtenPath) await fs16.rm(writtenPath, { force: true }).catch(() => { + if (writtenPath) await fs18.rm(writtenPath, { force: true }).catch(() => { }); throw error; } finally { @@ -20762,7 +21118,7 @@ ${publishContent}`, { ] ); return { - html: await fs16.readFile(await resolveReadableStoragePath(row.storage_key), "utf8"), + html: await fs18.readFile(await resolveReadableStoragePath(row.storage_key), "utf8"), publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }) }; }; @@ -23903,8 +24259,8 @@ function injectPlazaEmbedBootstrap(html) { // mindspace-cleanup.mjs import crypto22 from "node:crypto"; -import fs17 from "node:fs/promises"; -import path21 from "node:path"; +import fs19 from "node:fs/promises"; +import path23 from "node:path"; var WORKSPACE_TEMP_SKIP = /* @__PURE__ */ new Set([ ".tkmindhints", ".goosehints", @@ -23917,7 +24273,7 @@ function candidateId(kind, key) { } async function fileSize(targetPath) { try { - const stat = await fs17.stat(targetPath); + const stat = await fs19.stat(targetPath); return stat.isFile() ? stat.size : 0; } catch { return 0; @@ -23926,12 +24282,12 @@ async function fileSize(targetPath) { async function walkFiles(rootDir, onFile) { let entries; try { - entries = await fs17.readdir(rootDir, { withFileTypes: true }); + entries = await fs19.readdir(rootDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { - const fullPath = path21.join(rootDir, entry.name); + const fullPath = path23.join(rootDir, entry.name); if (entry.isDirectory()) { if (WORKSPACE_TEMP_SKIP.has(entry.name)) continue; await walkFiles(fullPath, onFile); @@ -23942,11 +24298,11 @@ async function walkFiles(rootDir, onFile) { } } function createCleanupService(pool2, options = {}) { - const storageRoot = path21.resolve(options.storageRoot ?? path21.join(process.cwd(), "data", "mindspace")); - const h5Root = path21.resolve(options.h5Root ?? process.cwd()); + const storageRoot = path23.resolve(options.storageRoot ?? path23.join(process.cwd(), "data", "mindspace")); + const h5Root = path23.resolve(options.h5Root ?? process.cwd()); const absoluteStoragePath2 = (storageKey) => { - const resolved = path21.resolve(storageRoot, storageKey); - if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path21.sep}`)) { + const resolved = path23.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path23.sep}`)) { throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); } return resolved; @@ -23976,9 +24332,9 @@ function createCleanupService(pool2, options = {}) { refId: upload.id }); } - const tmpDir = path21.join(storageRoot, "tmp", userId); + const tmpDir = path23.join(storageRoot, "tmp", userId); await walkFiles(tmpDir, async (fullPath) => { - const key = path21.relative(storageRoot, fullPath).split(path21.sep).join("/"); + const key = path23.relative(storageRoot, fullPath).split(path23.sep).join("/"); const active = uploads.some( (upload) => upload.temporary_storage_key === key && upload.status === "reserved" ); @@ -23988,7 +24344,7 @@ function createCleanupService(pool2, options = {}) { candidates.push({ id: candidateId("tmp", key), kind: "orphan_tmp", - label: path21.basename(fullPath), + label: path23.basename(fullPath), path: key, sizeBytes, createdAt: null, @@ -23996,9 +24352,9 @@ function createCleanupService(pool2, options = {}) { refId: key }); }); - const workspaceTempDir = path21.join(h5Root, "temp", username); + const workspaceTempDir = path23.join(h5Root, "temp", username); await walkFiles(workspaceTempDir, async (fullPath) => { - const rel = path21.relative(workspaceTempDir, fullPath).split(path21.sep).join("/"); + const rel = path23.relative(workspaceTempDir, fullPath).split(path23.sep).join("/"); const sizeBytes = await fileSize(fullPath); if (!sizeBytes) return; candidates.push({ @@ -24080,7 +24436,7 @@ function createCleanupService(pool2, options = {}) { if (upload.temporary_storage_key) { const target = absoluteStoragePath2(upload.temporary_storage_key); const sizeBytes = await fileSize(target); - await fs17.rm(target, { force: true }); + await fs19.rm(target, { force: true }); freedBytes += sizeBytes; } removedCount += 1; @@ -24097,18 +24453,18 @@ function createCleanupService(pool2, options = {}) { if (item.kind === "orphan_tmp") { const target = absoluteStoragePath2(item.refId); const sizeBytes = await fileSize(target); - await fs17.rm(target, { force: true }); + await fs19.rm(target, { force: true }); freedBytes += sizeBytes; removedCount += 1; continue; } if (item.kind === "workspace_temp") { - const target = path21.join(h5Root, "temp", username, item.refId); - const resolvedRoot = path21.resolve(path21.join(h5Root, "temp", username)); - const resolved = path21.resolve(target); - if (!resolved.startsWith(`${resolvedRoot}${path21.sep}`)) continue; + const target = path23.join(h5Root, "temp", username, item.refId); + const resolvedRoot = path23.resolve(path23.join(h5Root, "temp", username)); + const resolved = path23.resolve(target); + if (!resolved.startsWith(`${resolvedRoot}${path23.sep}`)) continue; const sizeBytes = await fileSize(resolved); - await fs17.rm(resolved, { force: true }); + await fs19.rm(resolved, { force: true }); freedBytes += sizeBytes; removedCount += 1; continue; @@ -24194,7 +24550,7 @@ function createCleanupService(pool2, options = {}) { // mindspace-agent-jobs.mjs import crypto23 from "node:crypto"; -import path22 from "node:path"; +import path24 from "node:path"; var JOB_TYPES = /* @__PURE__ */ new Set(["generate_page", "analyze_asset", "summarize"]); var OUTPUT_TYPES = /* @__PURE__ */ new Set(["page_draft", "html_page", "markdown"]); var ACTIVE_JOB_STATUSES = /* @__PURE__ */ new Set(["queued", "running"]); @@ -24321,10 +24677,10 @@ function createAgentJobService(pool2, options = {}) { const tokenTtlMs = Number(options.tokenTtlMs ?? 30 * 60 * 1e3); const maxOutputBytes = Number(options.maxOutputBytes ?? 2 * 1024 * 1024); const pageService = options.pageService; - const storageRoot = path22.resolve(options.storageRoot ?? path22.join(process.cwd(), "data", "mindspace")); + const storageRoot = path24.resolve(options.storageRoot ?? path24.join(process.cwd(), "data", "mindspace")); const absoluteStoragePath2 = (storageKey) => { - const resolved = path22.resolve(storageRoot, storageKey); - if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path22.sep}`)) { + const resolved = path24.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path24.sep}`)) { throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); } return resolved; @@ -24868,7 +25224,7 @@ function createAgentJobService(pool2, options = {}) { // mindspace-agent-runner.mjs import crypto24 from "node:crypto"; -import fs18 from "node:fs/promises"; +import fs20 from "node:fs/promises"; import { Readable as Readable2 } from "node:stream"; import { Agent as Agent5, fetch as undiciFetch5 } from "undici"; import { jsonrepair as jsonrepair2 } from "jsonrepair"; @@ -25079,7 +25435,7 @@ async function readAssetContext(asset, maxBytes = DEFAULT_TEXT_BYTES) { note: "\u8BE5\u6587\u4EF6\u4E0D\u662F\u7EAF\u6587\u672C\uFF0CRunner \u5F53\u524D\u4E0D\u4F1A\u76F4\u63A5\u5185\u5D4C\u4E8C\u8FDB\u5236\u5185\u5BB9\u3002" }; } - const buffer = await fs18.readFile(asset.path); + const buffer = await fs20.readFile(asset.path); return { assetId: asset.assetId, displayName: asset.displayName, @@ -25323,12 +25679,12 @@ ${lines}`; } // mindspace-chat-save.mjs -import fs19 from "node:fs/promises"; -import path23 from "node:path"; -var PRIVATE_ASSET_URL_PATTERN2 = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; +import fs21 from "node:fs/promises"; +import path25 from "node:path"; +var PRIVATE_ASSET_URL_PATTERN3 = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; var PUBLIC_TEMP_IMAGE_DIR2 = ".tmp-images"; function extensionForMime3(mimeType, filename = "") { - const ext = path23.extname(String(filename ?? "")).replace(/^\./, "").toLowerCase(); + const ext = path25.extname(String(filename ?? "")).replace(/^\./, "").toLowerCase(); if (ext && /^[a-z0-9]{1,8}$/.test(ext)) return ext === "jpeg" ? "jpg" : ext; if (mimeType === "image/jpeg") return "jpg"; if (mimeType === "image/png") return "png"; @@ -25340,17 +25696,17 @@ function publicTempImageFilename2(assetId, mimeType, fallbackFilename = "") { return `${assetId}.${extensionForMime3(mimeType, fallbackFilename)}`; } function absoluteStoragePath(storageRoot, storageKey) { - const resolvedRoot = path23.resolve(storageRoot); - const resolved = path23.resolve(resolvedRoot, storageKey); - if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path23.sep}`)) { + const resolvedRoot = path25.resolve(storageRoot); + const resolved = path25.resolve(resolvedRoot, storageKey); + if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path25.sep}`)) { throw Object.assign(new Error("\u8DEF\u5F84\u8D8A\u754C"), { code: "invalid_storage_path" }); } return resolved; } function workspaceTempImageRelativeUrl(htmlRelativePath, filename) { - const htmlDir = path23.posix.dirname(String(htmlRelativePath ?? "").replace(/^\/+/, "")); - const tmpDir = path23.posix.join(PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR2); - const relDir = htmlDir === "." || !htmlDir ? tmpDir : path23.posix.relative(htmlDir, tmpDir).replace(/\\/g, "/"); + const htmlDir = path25.posix.dirname(String(htmlRelativePath ?? "").replace(/^\/+/, "")); + const tmpDir = path25.posix.join(PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR2); + const relDir = htmlDir === "." || !htmlDir ? tmpDir : path25.posix.relative(htmlDir, tmpDir).replace(/\\/g, "/"); return relDir ? `${relDir}/${filename}` : filename; } async function materializePrivateAssetsInWorkspaceHtml({ @@ -25362,7 +25718,7 @@ async function materializePrivateAssetsInWorkspaceHtml({ htmlRelativePath, writeBack = false }) { - const matches = [...String(html).matchAll(PRIVATE_ASSET_URL_PATTERN2)]; + const matches = [...String(html).matchAll(PRIVATE_ASSET_URL_PATTERN3)]; if (matches.length === 0) { return { html, count: 0, changed: false }; } @@ -25376,15 +25732,15 @@ async function materializePrivateAssetsInWorkspaceHtml({ [userId, assetIds] ); const publishDir = resolvePublishDir(h5Root, { id: userId }); - const tmpDir = path23.join(publishDir, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR2); + const tmpDir = path25.join(publishDir, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR2); const urlMap = /* @__PURE__ */ new Map(); for (const asset of assets) { const filename = publicTempImageFilename2(asset.id, asset.mime_type, asset.original_filename); - const target = path23.join(tmpDir, filename); + const target = path25.join(tmpDir, filename); try { const sourcePath = absoluteStoragePath(storageRoot, asset.storage_key); - await fs19.mkdir(tmpDir, { recursive: true }); - await fs19.copyFile(sourcePath, target); + await fs21.mkdir(tmpDir, { recursive: true }); + await fs21.copyFile(sourcePath, target); urlMap.set(asset.id, workspaceTempImageRelativeUrl(htmlRelativePath, filename)); } catch { } @@ -25393,7 +25749,7 @@ async function materializePrivateAssetsInWorkspaceHtml({ return { html, count: 0, changed: false }; } let count = 0; - const result = String(html).replace(PRIVATE_ASSET_URL_PATTERN2, (value, assetId) => { + const result = String(html).replace(PRIVATE_ASSET_URL_PATTERN3, (value, assetId) => { const nextUrl = urlMap.get(assetId); if (!nextUrl) return value; count += 1; @@ -25403,14 +25759,14 @@ async function materializePrivateAssetsInWorkspaceHtml({ return { html, count: 0, changed: false }; } if (writeBack) { - const htmlPath = path23.join(publishDir, htmlRelativePath); - await fs19.writeFile(htmlPath, result, "utf8"); + const htmlPath = path25.join(publishDir, htmlRelativePath); + await fs21.writeFile(htmlPath, result, "utf8"); } return { html: result, count, changed: true }; } async function repairMissingHtmlAssetReferences({ pool: pool2, userId, sourceContent, html }) { const collectIds = (text) => [ - ...new Set([...String(text).matchAll(PRIVATE_ASSET_URL_PATTERN2)].map((match) => match[1])) + ...new Set([...String(text).matchAll(PRIVATE_ASSET_URL_PATTERN3)].map((match) => match[1])) ]; const htmlIds = collectIds(html); const messageIds = collectIds(sourceContent); @@ -25438,7 +25794,7 @@ async function repairMissingHtmlAssetReferences({ pool: pool2, userId, sourceCon return { html, changed: false }; } let changed = false; - const nextHtml = String(html).replace(PRIVATE_ASSET_URL_PATTERN2, (value, assetId) => { + const nextHtml = String(html).replace(PRIVATE_ASSET_URL_PATTERN3, (value, assetId) => { const replacement = idMap.get(assetId); if (!replacement) return value; changed = true; @@ -25467,7 +25823,7 @@ function createAssetDataUriResolver(pool2, storageRoot, userId) { return null; } try { - const buffer = await fs19.readFile(absoluteStoragePath(storageRoot, asset.storage_key)); + const buffer = await fs21.readFile(absoluteStoragePath(storageRoot, asset.storage_key)); const mimeType = String(asset.mime_type || "image/jpeg"); const dataUri = `data:${mimeType};base64,${buffer.toString("base64")}`; cache.set(key, dataUri); @@ -25525,7 +25881,7 @@ function extractStaticPageLinks(content, { userId, username } = {}) { publicUrl: canonicalizeStaticPageUrl2(match[0], originalRelativePath, relativePath), owner, relativePath, - filename: path23.basename(relativePath) + filename: path25.basename(relativePath) }); } return links; @@ -25549,38 +25905,38 @@ function resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath) { if (!key || !clean || !clean.toLowerCase().endsWith(".html")) { throw Object.assign(new Error("\u65E0\u6548\u7684\u9875\u9762\u8DEF\u5F84"), { code: "invalid_page_path" }); } - const publishRoot = path23.resolve(h5Root, PUBLISH_ROOT_DIR, key); - const absolute = path23.resolve(publishRoot, clean); - if (absolute !== publishRoot && !absolute.startsWith(`${publishRoot}${path23.sep}`)) { + const publishRoot = path25.resolve(h5Root, PUBLISH_ROOT_DIR, key); + const absolute = path25.resolve(publishRoot, clean); + if (absolute !== publishRoot && !absolute.startsWith(`${publishRoot}${path25.sep}`)) { throw Object.assign(new Error("\u9875\u9762\u8DEF\u5F84\u8D8A\u754C"), { code: "invalid_page_path" }); } return absolute; } async function readPublishHtml(h5Root, userId, relativePath) { const absolute = resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath); - const content = await fs19.readFile(absolute, "utf8"); + const content = await fs21.readFile(absolute, "utf8"); if (!content.trim()) { throw Object.assign(new Error("\u9875\u9762\u5185\u5BB9\u4E3A\u7A7A"), { code: "empty_page_content" }); } - return { absolute, content, relativePath, filename: path23.basename(relativePath) }; + return { absolute, content, relativePath, filename: path25.basename(relativePath) }; } async function walkPublishHtmlByBasename(publishRoot, basename, maxDepth = 6, depth = 0) { if (depth > maxDepth || !basename.toLowerCase().endsWith(".html")) return null; let entries; try { - entries = await fs19.readdir(publishRoot, { withFileTypes: true }); + entries = await fs21.readdir(publishRoot, { withFileTypes: true }); } catch { return null; } for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules") continue; - const full = path23.join(publishRoot, entry.name); + const full = path25.join(publishRoot, entry.name); if (entry.isFile() && entry.name === basename) return full; } for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue; const found = await walkPublishHtmlByBasename( - path23.join(publishRoot, entry.name), + path25.join(publishRoot, entry.name), basename, maxDepth, depth + 1 @@ -25593,13 +25949,13 @@ async function collectPublishHtmlPaths(publishRoot, results, maxDepth = 6, depth if (depth > maxDepth || results.length >= 200) return; let entries; try { - entries = await fs19.readdir(publishRoot, { withFileTypes: true }); + entries = await fs21.readdir(publishRoot, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules") continue; - const full = path23.join(publishRoot, entry.name); + const full = path25.join(publishRoot, entry.name); if (entry.isFile() && entry.name.toLowerCase().endsWith(".html")) { results.push(full); if (results.length >= 200) return; @@ -25607,7 +25963,7 @@ async function collectPublishHtmlPaths(publishRoot, results, maxDepth = 6, depth } for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue; - await collectPublishHtmlPaths(path23.join(publishRoot, entry.name), results, maxDepth, depth + 1); + await collectPublishHtmlPaths(path25.join(publishRoot, entry.name), results, maxDepth, depth + 1); if (results.length >= 200) return; } } @@ -25634,7 +25990,7 @@ function levenshteinDistance(left, right) { return prev[right.length]; } function htmlNameForSimilarity(relativePath) { - return path23.basename(String(relativePath ?? ""), ".html").toLowerCase(); + return path25.basename(String(relativePath ?? ""), ".html").toLowerCase(); } function isAcceptableSimilarHtmlMatch(requested, candidate, score, nextScore = Infinity) { if (!requested || !candidate || requested === candidate) return false; @@ -25650,7 +26006,7 @@ async function resolveClosestHtmlRelativePath(rootDir, relativePath) { const candidates = []; await collectPublishHtmlPaths(rootDir, candidates); const ranked = candidates.map((absolute) => { - const candidateRelative = path23.relative(rootDir, absolute).split(path23.sep).join("/"); + const candidateRelative = path25.relative(rootDir, absolute).split(path25.sep).join("/"); const candidateName = htmlNameForSimilarity(candidateRelative); return { relativePath: candidateRelative, @@ -25667,10 +26023,10 @@ async function resolveClosestHtmlRelativePath(rootDir, relativePath) { } async function findPublishHtml(h5Root, userId, relativePath) { const normalized = normalizeStaticHtmlRelativePath2(relativePath); - const basename = path23.basename(normalized); + const basename = path25.basename(normalized); const candidates = [ normalized, - path23.posix.join("public", basename), + path25.posix.join("public", basename), basename ].filter((value, index, list) => value && list.indexOf(value) === index); for (const candidate of candidates) { @@ -25679,14 +26035,14 @@ async function findPublishHtml(h5Root, userId, relativePath) { } catch { } } - const publishRoot = path23.resolve( + const publishRoot = path25.resolve( h5Root, PUBLISH_ROOT_DIR, String(userId ?? "").trim().toLowerCase() ); const absolute = await walkPublishHtmlByBasename(publishRoot, basename); if (absolute) { - const resolvedRelativePath = path23.relative(publishRoot, absolute).split(path23.sep).join("/"); + const resolvedRelativePath = path25.relative(publishRoot, absolute).split(path25.sep).join("/"); return readPublishHtml(h5Root, userId, resolvedRelativePath); } const similarRelativePath = await resolveClosestHtmlRelativePath(publishRoot, normalized); @@ -25697,7 +26053,7 @@ async function findPublishHtml(h5Root, userId, relativePath) { } function buildWorkspaceBaseHref(userId, htmlRelativePath) { const key = String(userId ?? "").trim(); - const dir = path23.posix.dirname(String(htmlRelativePath ?? "").replace(/^\/+/, "")); + const dir = path25.posix.dirname(String(htmlRelativePath ?? "").replace(/^\/+/, "")); const segments = dir === "." ? [] : dir.split("/").filter(Boolean); const encoded = segments.map((part) => encodeURIComponent(part)).join("/"); return `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(key)}/${encoded ? `${encoded}/` : ""}`; @@ -25892,8 +26248,8 @@ async function resolveChatSaveAnalysis({ } // mindspace-public-finish-sync.mjs -import fs20 from "node:fs"; -import path24 from "node:path"; +import fs22 from "node:fs"; +import path26 from "node:path"; var PUBLIC_HTML_PATH_PATTERN = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/i; var PUBLIC_DOCX_HREF_PATTERN = /href=["']([^"'#?\s]+\.docx)["']/gi; function normalizePublicDocxHref(href) { @@ -25905,13 +26261,13 @@ function normalizePublicDocxHref(href) { return clean; } function extractPublicDocxReferencesFromHtml(publishDir) { - const root = path24.resolve(String(publishDir ?? "")); - const publicDir = path24.join(root, "public"); - if (!fs20.existsSync(publicDir) || !fs20.statSync(publicDir).isDirectory()) return []; + const root = path26.resolve(String(publishDir ?? "")); + const publicDir = path26.join(root, "public"); + if (!fs22.existsSync(publicDir) || !fs22.statSync(publicDir).isDirectory()) return []; const refs = /* @__PURE__ */ new Set(); - for (const file of fs20.readdirSync(publicDir)) { + for (const file of fs22.readdirSync(publicDir)) { if (!file.toLowerCase().endsWith(".html")) continue; - const content = fs20.readFileSync(path24.join(publicDir, file), "utf8"); + const content = fs22.readFileSync(path26.join(publicDir, file), "utf8"); for (const match of content.matchAll(PUBLIC_DOCX_HREF_PATTERN)) { const relativePath = normalizePublicDocxHref(match[1]); if (relativePath) refs.add(relativePath); @@ -25920,32 +26276,32 @@ function extractPublicDocxReferencesFromHtml(publishDir) { return [...refs]; } function findLatestCompleteOaDocx(publishDir, { minSize = 8e3 } = {}) { - const oaDir = path24.join(path24.resolve(String(publishDir ?? "")), "oa"); - if (!fs20.existsSync(oaDir) || !fs20.statSync(oaDir).isDirectory()) return null; - const candidates = fs20.readdirSync(oaDir).filter((name) => name.toLowerCase().endsWith(".docx")).map((name) => { - const absolutePath = path24.join(oaDir, name); - const stat = fs20.statSync(absolutePath); + const oaDir = path26.join(path26.resolve(String(publishDir ?? "")), "oa"); + if (!fs22.existsSync(oaDir) || !fs22.statSync(oaDir).isDirectory()) return null; + const candidates = fs22.readdirSync(oaDir).filter((name) => name.toLowerCase().endsWith(".docx")).map((name) => { + const absolutePath = path26.join(oaDir, name); + const stat = fs22.statSync(absolutePath); return { name, absolutePath, size: stat.size, mtimeMs: stat.mtimeMs }; }).filter((item) => item.size >= minSize).sort((a, b) => b.mtimeMs - a.mtimeMs || b.size - a.size); return candidates[0] ?? null; } function syncPublicDocxDownloads({ publishDir, minCompleteSize = 8e3 } = {}) { - const root = path24.resolve(String(publishDir ?? "")); + const root = path26.resolve(String(publishDir ?? "")); if (!root) return { synced: [], skipped: [] }; const source = findLatestCompleteOaDocx(root, { minSize: minCompleteSize }); if (!source) return { synced: [], skipped: [] }; const synced = []; const skipped = []; for (const relativePath of extractPublicDocxReferencesFromHtml(root)) { - const destination = path24.resolve(root, relativePath); - if (destination !== root && !destination.startsWith(`${root}${path24.sep}`)) { + const destination = path26.resolve(root, relativePath); + if (destination !== root && !destination.startsWith(`${root}${path26.sep}`)) { skipped.push(relativePath); continue; } - let shouldCopy = !fs20.existsSync(destination) || !fs20.statSync(destination).isFile(); + let shouldCopy = !fs22.existsSync(destination) || !fs22.statSync(destination).isFile(); if (!shouldCopy) { try { - shouldCopy = fs20.statSync(destination).size < source.size; + shouldCopy = fs22.statSync(destination).size < source.size; } catch { shouldCopy = true; } @@ -25955,8 +26311,8 @@ function syncPublicDocxDownloads({ publishDir, minCompleteSize = 8e3 } = {}) { continue; } try { - fs20.mkdirSync(path24.dirname(destination), { recursive: true }); - fs20.copyFileSync(source.absolutePath, destination); + fs22.mkdirSync(path26.dirname(destination), { recursive: true }); + fs22.copyFileSync(source.absolutePath, destination); synced.push(relativePath); } catch { skipped.push(relativePath); @@ -25988,13 +26344,13 @@ function isEditLikeHtmlTool(name, args) { return (normalizedName === "edit_file" || normalizedName.endsWith("__edit_file") || normalizedName === "developer" && String(args?.action ?? "").toLowerCase() === "edit") && typeof args?.path === "string"; } function readPublicHtmlBaseline(publishDir, relativePath) { - const root = path24.resolve(String(publishDir ?? "")); + const root = path26.resolve(String(publishDir ?? "")); if (!root) return ""; - const destination = path24.resolve(root, relativePath); - if (destination !== root && !destination.startsWith(`${root}${path24.sep}`)) return ""; - if (!fs20.existsSync(destination) || !fs20.statSync(destination).isFile()) return ""; + const destination = path26.resolve(root, relativePath); + if (destination !== root && !destination.startsWith(`${root}${path26.sep}`)) return ""; + if (!fs22.existsSync(destination) || !fs22.statSync(destination).isFile()) return ""; try { - return fs20.readFileSync(destination, "utf8"); + return fs22.readFileSync(destination, "utf8"); } catch { return ""; } @@ -26038,9 +26394,9 @@ function isStubPublicHtmlContent(content) { return PUBLIC_HTML_STUB_MARKERS.some((marker) => value.includes(marker)); } function shouldReplaceExistingPublicHtml(destination, nextContent) { - if (!fs20.existsSync(destination) || !fs20.statSync(destination).isFile()) return true; + if (!fs22.existsSync(destination) || !fs22.statSync(destination).isFile()) return true; try { - const existing = fs20.readFileSync(destination, "utf8"); + const existing = fs22.readFileSync(destination, "utf8"); if (isStubPublicHtmlContent(existing)) return true; return typeof nextContent === "string" && nextContent.length > 0 && existing !== nextContent; } catch { @@ -26048,25 +26404,25 @@ function shouldReplaceExistingPublicHtml(destination, nextContent) { } } function materializeMissingPublicHtmlWrites({ messages, publishDir }) { - const root = path24.resolve(String(publishDir ?? "")); + const root = path26.resolve(String(publishDir ?? "")); if (!root) return { materialized: [], skipped: [] }; const materialized = []; const skipped = []; for (const artifact of extractPublicHtmlWriteArtifacts(messages, { publishDir: root })) { - const destination = path24.resolve(root, artifact.relativePath); - if (destination !== root && !destination.startsWith(`${root}${path24.sep}`)) { + const destination = path26.resolve(root, artifact.relativePath); + if (destination !== root && !destination.startsWith(`${root}${path26.sep}`)) { skipped.push(artifact.relativePath); continue; } - if (fs20.existsSync(destination) && fs20.statSync(destination).isFile()) { + if (fs22.existsSync(destination) && fs22.statSync(destination).isFile()) { if (!shouldReplaceExistingPublicHtml(destination, artifact.content)) { skipped.push(artifact.relativePath); continue; } } try { - fs20.mkdirSync(path24.dirname(destination), { recursive: true }); - fs20.writeFileSync(destination, artifact.content, "utf8"); + fs22.mkdirSync(path26.dirname(destination), { recursive: true }); + fs22.writeFileSync(destination, artifact.content, "utf8"); materialized.push(artifact.relativePath); } catch { skipped.push(artifact.relativePath); @@ -26148,8 +26504,8 @@ async function syncPublicHtmlAfterFinish({ } // mindspace-page-sync.mjs -import fs21 from "node:fs/promises"; -import path25 from "node:path"; +import fs23 from "node:fs/promises"; +import path27 from "node:path"; var PUBLIC_HTML_SKIP_DIRS = /* @__PURE__ */ new Set([ ".tmp-images", "assets", @@ -26165,7 +26521,7 @@ function normalizePublicHtmlPath(relativePath) { return parts.join("/"); } function titleFromRelativePath(relativePath) { - const basename = path25.basename(String(relativePath ?? ""), ".html"); + const basename = path27.basename(String(relativePath ?? ""), ".html"); return basename.replace(/[-_]+/g, " ").trim() || "\u751F\u6210\u7684\u9875\u9762"; } function extractHtmlTitle(content) { @@ -26176,6 +26532,31 @@ function extractHtmlSummary(content) { const match = String(content).match(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i); return match?.[1]?.trim().slice(0, 1e3) ?? ""; } +async function findPageByWorkspaceRelativePath(pool2, userId, relativePath) { + const [rows] = await pool2.query( + `SELECT p.id, p.updated_at + FROM h5_page_records p + JOIN h5_page_versions pv ON pv.id = p.current_version_id + WHERE p.user_id = ? + AND p.status <> 'deleted' + AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ? + ORDER BY p.updated_at DESC, p.id DESC + LIMIT 1`, + [userId, relativePath] + ); + const row = rows[0]; + if (!row) return null; + return { id: row.id, updatedAt: Number(row.updated_at ?? 0) }; +} +function parseJsonColumn2(value, fallback = {}) { + if (value == null || value === "") return { ...fallback }; + if (typeof value === "object" && !Buffer.isBuffer(value)) return value; + try { + return JSON.parse(String(value)); + } catch { + return { ...fallback }; + } +} async function loadIndexedWorkspacePages(pool2, userId) { const [rows] = await pool2.query( `SELECT p.id, p.updated_at, p.source_asset_id, pv.source_snapshot_json @@ -26186,12 +26567,7 @@ async function loadIndexedWorkspacePages(pool2, userId) { ); const byPath = /* @__PURE__ */ new Map(); for (const row of rows) { - let snapshot = {}; - try { - snapshot = JSON.parse(row.source_snapshot_json ?? "{}"); - } catch { - snapshot = {}; - } + const snapshot = parseJsonColumn2(row.source_snapshot_json); const relativePath = normalizePublicHtmlPath(snapshot.relative_path); if (relativePath) { byPath.set(relativePath, { @@ -26206,25 +26582,25 @@ async function loadIndexedWorkspacePages(pool2, userId) { return byPath; } async function listPublishPublicHtmlFiles(publishDir) { - const publicDir = path25.join(publishDir, "public"); + const publicDir = path27.join(publishDir, "public"); const files = []; async function walk(absDir, relWithinPublic) { let entries; try { - entries = await fs21.readdir(absDir, { withFileTypes: true }); + entries = await fs23.readdir(absDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (entry.name.startsWith(".") || PUBLIC_HTML_SKIP_DIRS.has(entry.name)) continue; - const absPath = path25.join(absDir, entry.name); + const absPath = path27.join(absDir, entry.name); const relPath = relWithinPublic ? `${relWithinPublic}/${entry.name}` : entry.name; if (entry.isDirectory()) { await walk(absPath, relPath); continue; } if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".html")) continue; - const stat = await fs21.stat(absPath); + const stat = await fs23.stat(absPath); files.push({ relativePath: normalizePublicHtmlPath(`public/${relPath}`), absolutePath: absPath, @@ -26236,6 +26612,7 @@ async function listPublishPublicHtmlFiles(publishDir) { return files; } async function upsertWorkspaceHtmlPage({ + pool: pool2, pageService, userId, indexedPages, @@ -26246,7 +26623,14 @@ async function upsertWorkspaceHtmlPage({ }) { const title = extractHtmlTitle(content) || titleFromRelativePath(relativePath); const summary = extractHtmlSummary(content); - const existing = indexedPages.get(relativePath) ?? (assetId ? indexedPages.get(`asset:${assetId}`) : null); + let existing = indexedPages.get(relativePath) ?? (assetId ? indexedPages.get(`asset:${assetId}`) : null); + if (!existing && pool2) { + existing = await findPageByWorkspaceRelativePath(pool2, userId, relativePath); + if (existing) { + indexedPages.set(relativePath, existing); + if (assetId) indexedPages.set(`asset:${assetId}`, existing); + } + } const pageInput = { title, summary, @@ -26306,12 +26690,13 @@ async function syncGeneratedPagesFromPublicAssets({ const htmlFiles = await listPublishPublicHtmlFiles(publishDir); for (const file of htmlFiles) { try { - const content = await fs21.readFile(file.absolutePath, "utf8"); + const content = await fs23.readFile(file.absolutePath, "utf8"); if (!content.trim()) { skipped += 1; continue; } const result = await upsertWorkspaceHtmlPage({ + pool: pool2, pageService, userId, indexedPages, @@ -26356,8 +26741,9 @@ async function syncGeneratedPagesFromPublicAssets({ } try { const { path: assetPath } = await assetService.readAsset(userId, asset.id); - const content = await fs21.readFile(assetPath, "utf8"); + const content = await fs23.readFile(assetPath, "utf8"); const result = await upsertWorkspaceHtmlPage({ + pool: pool2, pageService, userId, indexedPages, @@ -26518,7 +26904,7 @@ ${source}`; } // mindspace-thumbnail-png.mjs -import fs22 from "node:fs"; +import fs24 from "node:fs"; import { Resvg } from "@resvg/resvg-js"; var RENDER_WIDTH = 540; function rasterizeThumbnailSvgToPng(svg) { @@ -26534,18 +26920,18 @@ function thumbnailPngPathForSvg(svgAbsPath) { return svgAbsPath.replace(/\.svg$/i, ".png"); } function ensureThumbnailPng(svgAbsPath) { - if (!fs22.existsSync(svgAbsPath)) return null; + if (!fs24.existsSync(svgAbsPath)) return null; const pngPath = thumbnailPngPathForSvg(svgAbsPath); try { - const svgStat = fs22.statSync(svgAbsPath); - if (fs22.existsSync(pngPath) && fs22.statSync(pngPath).mtimeMs >= svgStat.mtimeMs) { + const svgStat = fs24.statSync(svgAbsPath); + if (fs24.existsSync(pngPath) && fs24.statSync(pngPath).mtimeMs >= svgStat.mtimeMs) { return pngPath; } - const svg = fs22.readFileSync(svgAbsPath, "utf8"); - fs22.writeFileSync(pngPath, rasterizeThumbnailSvgToPng(svg)); + const svg = fs24.readFileSync(svgAbsPath, "utf8"); + fs24.writeFileSync(pngPath, rasterizeThumbnailSvgToPng(svg)); return pngPath; } catch { - return fs22.existsSync(pngPath) ? pngPath : null; + return fs24.existsSync(pngPath) ? pngPath : null; } } @@ -27242,20 +27628,20 @@ function createPlanCatalogService(pool2) { // wechat-pay.mjs import crypto26 from "node:crypto"; -import fs23 from "node:fs"; +import fs25 from "node:fs"; import { fetch as fetch2 } from "undici"; var API_BASE = "https://api.mch.weixin.qq.com"; function readPrivateKey(config) { if (config.privateKeyPem) return config.privateKeyPem; - if (config.privateKeyPath && fs23.existsSync(config.privateKeyPath)) { - return fs23.readFileSync(config.privateKeyPath, "utf8"); + if (config.privateKeyPath && fs25.existsSync(config.privateKeyPath)) { + return fs25.readFileSync(config.privateKeyPath, "utf8"); } return null; } function readPlatformCert(config) { if (config.platformCertPem) return config.platformCertPem; - if (config.platformCertPath && fs23.existsSync(config.platformCertPath)) { - return fs23.readFileSync(config.platformCertPath, "utf8"); + if (config.platformCertPath && fs25.existsSync(config.platformCertPath)) { + return fs25.readFileSync(config.platformCertPath, "utf8"); } return null; } @@ -27357,7 +27743,7 @@ function normalizeV2Transaction(fields) { amount: fields.total_fee ? { total: Number(fields.total_fee) } : void 0 }; } -async function wechatV2Request(config, path29, fields) { +async function wechatV2Request(config, path31, fields) { const payload = { appid: config.appId, mch_id: config.mchId, @@ -27366,7 +27752,7 @@ async function wechatV2Request(config, path29, fields) { }; payload.sign = signV2Params(payload, config.apiKey); const body = buildXml(payload); - const res = await fetch2(`${API_BASE}${path29}`, { + const res = await fetch2(`${API_BASE}${path31}`, { method: "POST", headers: { "Content-Type": "text/xml" }, body @@ -27408,10 +27794,10 @@ ${payload} ].join(","); return { authorization, timestamp, nonce, signature }; } -async function wechatV3Request(config, method, path29, bodyObj) { +async function wechatV3Request(config, method, path31, bodyObj) { const body = bodyObj ? JSON.stringify(bodyObj) : ""; - const { authorization } = buildAuthorization(config, method, path29, body); - const res = await fetch2(`${API_BASE}${path29}`, { + const { authorization } = buildAuthorization(config, method, path31, body); + const res = await fetch2(`${API_BASE}${path31}`, { method, headers: { Authorization: authorization, @@ -28084,8 +28470,8 @@ function createWechatOAuthService(pool2, config, { userAuth: userAuth2 } = {}) { // wechat-mp.mjs import crypto29 from "node:crypto"; -import fs25 from "node:fs"; -import path27 from "node:path"; +import fs27 from "node:fs"; +import path29 from "node:path"; import { fetch as undiciFetch7 } from "undici"; // schedule-intent.mjs @@ -28213,11 +28599,11 @@ function shouldUseScheduleAssistant(text) { // wechat-media.mjs import crypto28 from "node:crypto"; -import fs24 from "node:fs"; -import path26 from "node:path"; +import fs26 from "node:fs"; +import path28 from "node:path"; import { fileURLToPath as fileURLToPath5 } from "node:url"; import { fetch as undiciFetch6 } from "undici"; -var __dirname4 = path26.dirname(fileURLToPath5(import.meta.url)); +var __dirname4 = path28.dirname(fileURLToPath5(import.meta.url)); var DEFAULT_WECHAT_MEDIA_URL = "https://api.weixin.qq.com/cgi-bin/media/get"; var DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024; var ALLOWED_IMAGE_MIME_TYPES = /* @__PURE__ */ new Map([ @@ -28234,7 +28620,7 @@ function resolveImageExtension(contentType = "", fallbackUrl = "") { extension: ALLOWED_IMAGE_MIME_TYPES.get(normalized) }; } - const ext = path26.extname(String(fallbackUrl ?? "").split("?")[0]).replace(/^\./, "").toLowerCase(); + const ext = path28.extname(String(fallbackUrl ?? "").split("?")[0]).replace(/^\./, "").toLowerCase(); if (ext === "jpg" || ext === "jpeg") return { mimeType: "image/jpeg", extension: "jpg" }; if (ext === "png") return { mimeType: "image/png", extension: "png" }; if (ext === "webp") return { mimeType: "image/webp", extension: "webp" }; @@ -28293,8 +28679,8 @@ async function persistWechatImage({ h5Root = __dirname4 } = {}) { if (!userId) throw new Error("\u7F3A\u5C11 userId"); - const publishDir = path26.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR, "wechat-mp"); - fs24.mkdirSync(publishDir, { recursive: true }); + const publishDir = path28.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR, "wechat-mp"); + fs26.mkdirSync(publishDir, { recursive: true }); let source = "wechat_media"; let buffer; let contentType = ""; @@ -28329,8 +28715,8 @@ async function persistWechatImage({ const hash = crypto28.createHash("sha1").update(buffer).digest("hex").slice(0, 12); const fileBase = [appId || "wx", openid || "openid", msgId || timestamp, mediaId || hash].filter(Boolean).join("-").replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120); const filename = `${fileBase}.${resolved.extension}`; - const absolutePath = path26.join(publishDir, filename); - fs24.writeFileSync(absolutePath, buffer); + const absolutePath = path28.join(publishDir, filename); + fs26.writeFileSync(absolutePath, buffer); return { absolutePath, bytes: buffer.length, @@ -28801,7 +29187,7 @@ async function hasAnyValidPublishedHtmlLink(text, linkExists, { confirmedArtifac } const filename = String(match[2] ?? "").split("/").pop()?.trim(); if (filename && confirmedArtifacts.some((artifact) => { - const artifactName = path27.posix.basename(String(artifact.relativePath ?? "").replace(/\\/g, "/")); + const artifactName = path29.posix.basename(String(artifact.relativePath ?? "").replace(/\\/g, "/")); return artifactName === filename && artifactFileExists(artifact); })) { return true; @@ -28823,23 +29209,23 @@ function isMissingRequiredPublishSkill(reply, intent) { return !usedStaticPagePublishSkill(reply?.messages ?? []); } function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) { - const owner = path27.basename(path27.resolve(workingDir)); - const normalized = relativePath.split(path27.sep).map(encodeURIComponent).join("/"); + const owner = path29.basename(path29.resolve(workingDir)); + const normalized = relativePath.split(path29.sep).map(encodeURIComponent).join("/"); return `${String(publicBaseUrl ?? "").replace(/\/+$/, "")}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(owner)}/${normalized}`; } function ensurePublicHtmlArtifact(htmlPath, workingDir) { - const workspaceRoot = path27.resolve(workingDir); - const source = path27.isAbsolute(String(htmlPath ?? "")) ? path27.resolve(String(htmlPath)) : path27.resolve(workspaceRoot, String(htmlPath ?? "")); - if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path27.sep}`)) return null; - if (!fs25.existsSync(source) || !fs25.statSync(source).isFile()) return null; - const publicRoot = path27.join(workspaceRoot, "public"); + const workspaceRoot = path29.resolve(workingDir); + const source = path29.isAbsolute(String(htmlPath ?? "")) ? path29.resolve(String(htmlPath)) : path29.resolve(workspaceRoot, String(htmlPath ?? "")); + if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path29.sep}`)) return null; + if (!fs27.existsSync(source) || !fs27.statSync(source).isFile()) return null; + const publicRoot = path29.join(workspaceRoot, "public"); let publishedPath = source; - if (source !== publicRoot && !source.startsWith(`${publicRoot}${path27.sep}`)) { - fs25.mkdirSync(publicRoot, { recursive: true }); - publishedPath = path27.join(publicRoot, path27.basename(source)); - if (publishedPath !== source) fs25.copyFileSync(source, publishedPath); + if (source !== publicRoot && !source.startsWith(`${publicRoot}${path29.sep}`)) { + fs27.mkdirSync(publicRoot, { recursive: true }); + publishedPath = path29.join(publicRoot, path29.basename(source)); + if (publishedPath !== source) fs27.copyFileSync(source, publishedPath); } - const relativePath = path27.relative(workspaceRoot, publishedPath); + const relativePath = path29.relative(workspaceRoot, publishedPath); if (!relativePath || relativePath.startsWith("..")) return null; return { localPath: publishedPath, @@ -28888,14 +29274,14 @@ function collectPublicHtmlLinkFilenames(text) { const filenames = /* @__PURE__ */ new Set(); for (const match of String(text ?? "").matchAll(PUBLIC_HTML_LINK_PATTERN2)) { const relativePath = String(match[2] ?? "").trim(); - const filename = path27.posix.basename(relativePath); + const filename = path29.posix.basename(relativePath); if (filename) filenames.add(filename); } return filenames; } function collectRecentPublishedHtmlArtifacts(intent, { workingDir, publicBaseUrl, replyText = "", sinceMs = 0, limit = 20 } = {}) { - const publicRoot = path27.join(path27.resolve(workingDir), "public"); - if (!fs25.existsSync(publicRoot) || !fs25.statSync(publicRoot).isDirectory()) return []; + const publicRoot = path29.join(path29.resolve(workingDir), "public"); + if (!fs27.existsSync(publicRoot) || !fs27.statSync(publicRoot).isDirectory()) return []; const expectedTarget = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? ""); const expectedRelativePath = expectedTarget ? expectedTarget.replace(/^\/+/, "").replace(/\\/g, "/") : ""; const linkedFilenames = collectPublicHtmlLinkFilenames(replyText); @@ -28905,12 +29291,12 @@ function collectRecentPublishedHtmlArtifacts(intent, { workingDir, publicBaseUrl const current = stack.pop(); let entries = []; try { - entries = fs25.readdirSync(current, { withFileTypes: true }); + entries = fs27.readdirSync(current, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { - const absolutePath = path27.join(current, entry.name); + const absolutePath = path29.join(current, entry.name); if (entry.isDirectory()) { stack.push(absolutePath); continue; @@ -28918,15 +29304,15 @@ function collectRecentPublishedHtmlArtifacts(intent, { workingDir, publicBaseUrl if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".html")) continue; let stat; try { - stat = fs25.statSync(absolutePath); + stat = fs27.statSync(absolutePath); } catch { continue; } if (sinceMs > 0 && Number(stat.mtimeMs ?? 0) + 1 < sinceMs) continue; - const relativePath = path27.relative(path27.resolve(workingDir), absolutePath); + const relativePath = path29.relative(path29.resolve(workingDir), absolutePath); if (!relativePath || relativePath.startsWith("..")) continue; const normalizedRelativePath = relativePath.replace(/\\/g, "/"); - const filename = path27.posix.basename(normalizedRelativePath); + const filename = path29.posix.basename(normalizedRelativePath); if (linkedFilenames.size > 0 && filename && !linkedFilenames.has(filename)) continue; artifacts.push({ localPath: absolutePath, @@ -29065,14 +29451,14 @@ function defaultPublicHtmlLinkExists(urlText) { const owner = parts[1]; const rest = parts.slice(3); if (!owner || rest.some((part) => !part || part === "." || part === "..")) return false; - const root = path27.resolve(process.cwd(), PUBLISH_ROOT_DIR, owner, "public"); - const target = path27.resolve(root, ...rest); - if (target !== root && !target.startsWith(`${root}${path27.sep}`)) return false; - return fs25.existsSync(target) && fs25.statSync(target).isFile(); + const root = path29.resolve(process.cwd(), PUBLISH_ROOT_DIR, owner, "public"); + const target = path29.resolve(root, ...rest); + if (target !== root && !target.startsWith(`${root}${path29.sep}`)) return false; + return fs27.existsSync(target) && fs27.statSync(target).isFile(); } function createPublicHtmlLinkExists(workingDir) { - const workspaceRoot = path27.resolve(String(workingDir ?? "")); - const workspaceKey = path27.basename(workspaceRoot); + const workspaceRoot = path29.resolve(String(workingDir ?? "")); + const workspaceKey = path29.basename(workspaceRoot); return (urlText) => { let url; try { @@ -29092,10 +29478,10 @@ function createPublicHtmlLinkExists(workingDir) { const rest = parts.slice(3); if (!owner || rest.some((part) => !part || part === "." || part === "..")) return false; if (owner === workspaceKey) { - const root = path27.join(workspaceRoot, "public"); - const target = path27.resolve(root, ...rest); - if (target !== root && !target.startsWith(`${root}${path27.sep}`)) return false; - return fs25.existsSync(target) && fs25.statSync(target).isFile(); + const root = path29.join(workspaceRoot, "public"); + const target = path29.resolve(root, ...rest); + if (target !== root && !target.startsWith(`${root}${path29.sep}`)) return false; + return fs27.existsSync(target) && fs27.statSync(target).isFile(); } return defaultPublicHtmlLinkExists(urlText); }; @@ -29140,7 +29526,7 @@ function artifactFileExists(artifact) { const localPath = String(artifact?.localPath ?? "").trim(); if (!localPath) return false; try { - return fs25.existsSync(localPath) && fs25.statSync(localPath).isFile(); + return fs27.existsSync(localPath) && fs27.statSync(localPath).isFile(); } catch { return false; } @@ -29170,7 +29556,7 @@ function buildVerifiedHtmlArtifacts({ const linkedFilenames = collectPublicHtmlLinkFilenames(replyText); if (linkedFilenames.size === 0) return existing; const matched = existing.filter((artifact) => { - const filename = path27.posix.basename(String(artifact.relativePath ?? "").replace(/\\/g, "/")); + const filename = path29.posix.basename(String(artifact.relativePath ?? "").replace(/\\/g, "/")); return filename && linkedFilenames.has(filename); }); return matched; @@ -29377,9 +29763,12 @@ function buildWechatAgentPrompt(intent) { "\u6700\u7EC8\u53EA\u7ED9\u7528\u6237\u4E00\u4E2A\u6B63\u5F0F\u57DF\u540D\u7684\u552F\u4E00\u6B63\u786E\u94FE\u63A5\uFF1B\u4E0D\u8981\u8F93\u51FA\u9519\u8BEF\u57DF\u540D\u3001\u5907\u7528\u94FE\u63A5\u6216\u8BA9\u7528\u6237\u624B\u52A8\u4FDD\u5B58\u6587\u4EF6\u3002", "" ].join("\n") : ""; + const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || "Asia/Shanghai"; const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content) ? [ "\u3010\u65E5\u7A0B\u6280\u80FD\u8981\u6C42\u3011\u8FD9\u6761\u6D88\u606F\u6D89\u53CA\u5F85\u529E\u3001\u63D0\u9192\u6216\u65E5\u7A0B\u3002", "\u5F00\u59CB\u524D\u5148\u52A0\u8F7D `schedule-assistant` skill\uFF0C\u5E76\u4E25\u683C\u6309 skill \u91CC\u7684\u8FB9\u754C\u6267\u884C\u3002", + "\u5199\u5165\u5DE5\u5177\u65F6\u4F18\u5148\u4F7F\u7528 startLocal / endLocal / remindLocal\uFF08YYYY-MM-DD HH:mm\uFF09\uFF0C\u4E0D\u8981\u81EA\u884C\u4F30\u7B97 Unix \u6BEB\u79D2\u3002", + renderCurrentTimeAnchor({ timezone: scheduleTimezone }), "\u53EA\u6709\u5728 `schedule_create_item` / `schedule_create_reminder` \u7B49\u5DE5\u5177\u6210\u529F\u8FD4\u56DE\u540E\uFF0C\u624D\u80FD\u544A\u8BC9\u7528\u6237\u201C\u5DF2\u7ECF\u8BBE\u7F6E\u597D\u4E86\u201D\u3002", intent?.msgId ? `\u8C03\u7528 schedule_create_item \u65F6\u5FC5\u987B\u4F20\u5165 sourceMessageId: ${intent.msgId}` : "", "" @@ -29647,8 +30036,8 @@ function createWechatMpService({ const fetchForSession = (sessionId, pathname, init) => sessionApiFetch ? sessionApiFetch(sessionId, pathname, init) : apiFetch2(pathname, init); const buildBindUrl = () => { if (/^https?:\/\//i.test(config.bindPath)) return config.bindPath; - const path29 = config.bindPath.startsWith("/") ? config.bindPath : `/${config.bindPath}`; - return `${config.publicBaseUrl}${path29}`; + const path31 = config.bindPath.startsWith("/") ? config.bindPath : `/${config.bindPath}`; + return `${config.publicBaseUrl}${path31}`; }; const verifyRequest = (query = {}) => verifyWechatMpSignature({ token: config.token, @@ -30849,7 +31238,19 @@ function rowToReminder(row) { updatedAt: Number(row.updated_at) }; } -function parseJsonColumn(value) { +function rowToReminderWithItem(row) { + const reminder = rowToReminder(row); + if (!reminder) return null; + return { + ...reminder, + itemTitle: String(row.item_title ?? "").trim(), + itemKind: row.item_kind === "event" ? "event" : "task", + itemTimezone: row.item_timezone || DEFAULT_TIMEZONE3, + itemStartAt: row.item_start_at == null ? null : Number(row.item_start_at), + itemEndAt: row.item_end_at == null ? null : Number(row.item_end_at) + }; +} +function parseJsonColumn3(value) { if (value == null || value === "") return null; if (typeof value === "string") { try { @@ -30870,7 +31271,7 @@ function rowToUserNotification(row) { notificationType: row.notification_type, title: row.title, body: row.body, - data: parseJsonColumn(row.data_json), + data: parseJsonColumn3(row.data_json), status: row.status, readAt: row.read_at == null ? null : Number(row.read_at), createdAt: Number(row.created_at), @@ -31009,6 +31410,65 @@ function createScheduleService(pool2, options = {}) { const end = addLocalDays(start, 1, timezone); return listItems({ userId, from: start, to: end, status: "active", limit: 200 }); }; + const listUpcomingItems = async ({ + userId, + timezone = defaultTimezone, + days = 7, + now = clock.now() + } = {}) => { + const safeDays = Math.max(1, Math.min(30, Number(days) || 7)); + const start = startOfLocalDay(now, timezone); + const end = addLocalDays(start, safeDays, timezone); + return listItems({ userId, from: start, to: end, status: "active", limit: 200 }); + }; + const listUpcomingReminders = async ({ + userId, + timezone = defaultTimezone, + days = 7, + now = clock.now(), + limit = 100 + } = {}) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const safeDays = Math.max(1, Math.min(30, Number(days) || 7)); + const start = startOfLocalDay(now, timezone); + const end = addLocalDays(start, safeDays, timezone); + const [rows] = await pool2.query( + `SELECT r.*, + i.title AS item_title, + i.kind AS item_kind, + i.timezone AS item_timezone, + i.start_at AS item_start_at, + i.end_at AS item_end_at + FROM h5_schedule_reminders r + INNER JOIN h5_schedule_items i ON i.id = r.item_id + WHERE r.user_id = ? + AND r.status IN ('pending', 'locked') + AND r.remind_at >= ? + AND r.remind_at < ? + AND i.deleted_at IS NULL + AND i.status = 'active' + ORDER BY r.remind_at ASC + LIMIT ?`, + [ + userId, + start, + end, + Math.max(1, Math.min(200, Number(limit) || 100)) + ] + ); + return rows.map(rowToReminderWithItem); + }; + const getItem = async ({ userId, itemId }) => { + if (!userId || !itemId) throw new Error("\u7F3A\u5C11\u4E8B\u9879\u53C2\u6570"); + const [rows] = await pool2.query( + `SELECT * + FROM h5_schedule_items + WHERE id = ? AND user_id = ? AND deleted_at IS NULL + LIMIT 1`, + [itemId, userId] + ); + return rowToItem(rows[0]); + }; const createReminder = async ({ userId, itemId, @@ -31232,6 +31692,88 @@ function createScheduleService(pool2, options = {}) { attempts: 0 }; }; + const listDueReminders = async ({ now = clock.now(), limit = 50 } = {}) => { + const [rows] = await pool2.query( + `SELECT r.* + FROM h5_schedule_reminders r + INNER JOIN h5_schedule_items i ON i.id = r.item_id + WHERE r.status = 'pending' + AND r.remind_at <= ? + AND (r.locked_until IS NULL OR r.locked_until <= ?) + AND i.deleted_at IS NULL + AND i.status = 'active' + ORDER BY r.remind_at ASC + LIMIT ?`, + [now, now, Math.max(1, Math.min(200, Number(limit) || 50))] + ); + return rows.map(rowToReminder); + }; + const lockReminder = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => { + const lockedUntil = now + lockMs; + const [result] = await pool2.query( + `UPDATE h5_schedule_reminders + SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ? + WHERE id = ? AND status = 'pending' AND remind_at <= ?`, + [lockedUntil, now, id, now] + ); + if (Number(result?.affectedRows ?? 0) !== 1) return null; + const [rows] = await pool2.query( + `SELECT * FROM h5_schedule_reminders WHERE id = ? LIMIT 1`, + [id] + ); + return rowToReminder(rows[0]); + }; + const markReminderSent = async (reminder, { now = clock.now() } = {}) => { + await pool2.query( + `UPDATE h5_schedule_reminders + SET status = 'sent', sent_at = ?, locked_until = NULL, last_error = NULL, updated_at = ? + WHERE id = ?`, + [now, now, reminder.id] + ); + return { ...reminder, status: "sent", sentAt: now, lockedUntil: null }; + }; + const markReminderCancelled = async (reminder, reason, { now = clock.now() } = {}) => { + const lastError = String(reason ?? "\u5DF2\u53D6\u6D88").slice(0, 500); + await pool2.query( + `UPDATE h5_schedule_reminders + SET status = 'cancelled', locked_until = NULL, last_error = ?, updated_at = ? + WHERE id = ?`, + [lastError, now, reminder.id] + ); + return { ...reminder, status: "cancelled", lastError }; + }; + const markReminderFailed = async (reminder, error, { now = clock.now(), retryMs = 10 * 60 * 1e3, maxAttempts = 5 } = {}) => { + const attempts = Number(reminder.attempts ?? 0); + const status = attempts >= maxAttempts ? "failed" : "pending"; + const lockedUntil = status === "pending" ? now + retryMs : null; + await pool2.query( + `UPDATE h5_schedule_reminders + SET status = ?, locked_until = ?, last_error = ?, updated_at = ? + WHERE id = ?`, + [ + status, + lockedUntil, + String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), + now, + reminder.id + ] + ); + }; + const buildReminderText = async (reminder) => { + const item = await getItem({ userId: reminder.userId, itemId: reminder.itemId }); + if (!item || item.status !== "active") return null; + const timezone = item.timezone || defaultTimezone; + const remindLabel = formatLocalTime(reminder.remindAt, timezone); + const eventAt = item.startAt ?? item.dueAt ?? null; + const eventLabel = eventAt ? formatLocalTime(eventAt, timezone) : null; + const lines = [`\u3010\u5F85\u529E\u63D0\u9192\u3011${item.title}`]; + if (eventLabel && eventLabel !== remindLabel) { + lines.push(`\u4E8B\u9879\u65F6\u95F4\uFF1A${eventLabel}`); + } + lines.push(`\u63D0\u9192\u65F6\u95F4\uFF1A${remindLabel}`); + if (item.location) lines.push(`\u5730\u70B9\uFF1A${item.location}`); + return lines.join("\n"); + }; const listDueBalanceAlerts = async ({ now = clock.now(), limit = 50 } = {}) => { const [rows] = await pool2.query( `SELECT * @@ -31334,14 +31876,24 @@ function createScheduleService(pool2, options = {}) { [status, nextRunAt, String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), now, subscription.id] ); }; - const logDelivery = async ({ subscriptionId, userId, channel = "wechat", status, providerMessageId = null, errorCode = null, errorMessage = null }) => { + const logDelivery = async ({ + reminderId = null, + subscriptionId = null, + userId, + channel = "wechat", + status, + providerMessageId = null, + errorCode = null, + errorMessage = null + }) => { await pool2.query( `INSERT INTO h5_schedule_delivery_logs (id, reminder_id, subscription_id, user_id, channel, status, provider_message_id, error_code, error_message, created_at) - VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ crypto30.randomUUID(), + reminderId, subscriptionId, userId, channel, @@ -31457,13 +32009,22 @@ function createScheduleService(pool2, options = {}) { }; return { createItem, + getItem, createReminder, listItems, listItemsBySourceMessage, listTodayTodoItems, + listUpcomingItems, + listUpcomingReminders, listDigestSubscriptions, createDailyTodoDigest, createBalanceLowAlert, + listDueReminders, + lockReminder, + markReminderSent, + markReminderCancelled, + markReminderFailed, + buildReminderText, listDueDigestSubscriptions, lockDigestSubscription, markDigestSent, @@ -31715,6 +32276,50 @@ function startScheduleReminderWorker({ if (running || stopped) return; running = true; try { + const dueReminders = await scheduleService2.listDueReminders({ limit: 50 }); + for (const candidate of dueReminders) { + const reminder = await scheduleService2.lockReminder(candidate.id); + if (!reminder) continue; + try { + const text = await scheduleService2.buildReminderText(reminder); + if (!text) { + await scheduleService2.markReminderCancelled(reminder, "\u4E8B\u9879\u5DF2\u5931\u6548\u6216\u4E0D\u5B58\u5728"); + continue; + } + await scheduleService2.createUserNotification?.({ + userId: reminder.userId, + channel: "web", + notificationType: "schedule_reminder", + title: "\u5F85\u529E\u63D0\u9192", + body: text, + data: { + reminderId: reminder.id, + itemId: reminder.itemId + } + }); + if (reminder.channel === "wechat") { + await sendWechatTextToUser(reminder.userId, text); + } + await scheduleService2.logDelivery({ + reminderId: reminder.id, + userId: reminder.userId, + channel: reminder.channel, + status: "success" + }); + await scheduleService2.markReminderSent(reminder); + } catch (err) { + logger.warn?.("Schedule reminder delivery failed:", err); + await scheduleService2.logDelivery({ + reminderId: reminder.id, + userId: reminder.userId, + channel: reminder.channel, + status: "failed", + errorMessage: err instanceof Error ? err.message : String(err) + }).catch(() => { + }); + await scheduleService2.markReminderFailed(reminder, err, { maxAttempts }); + } + } const due = await scheduleService2.listDueDigestSubscriptions({ limit: 50 }); for (const candidate of due) { const subscription = await scheduleService2.lockDigestSubscription(candidate.id); @@ -32772,10 +33377,10 @@ function attachAsrRoutes(api2, deps) { } // server.mjs -var __dirname5 = path28.dirname(fileURLToPath6(import.meta.url)); +var __dirname5 = path30.dirname(fileURLToPath6(import.meta.url)); function loadEnvFile(filePath) { - if (!fs26.existsSync(filePath)) return; - for (const line of fs26.readFileSync(filePath, "utf8").split("\n")) { + if (!fs28.existsSync(filePath)) return; + for (const line of fs28.readFileSync(filePath, "utf8").split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eq = trimmed.indexOf("="); @@ -32785,8 +33390,8 @@ function loadEnvFile(filePath) { if (!process.env[key]) process.env[key] = value; } } -loadEnvFile(path28.join(__dirname5, "../../.env.local")); -loadEnvFile(path28.join(__dirname5, ".env")); +loadEnvFile(path30.join(__dirname5, "../../.env.local")); +loadEnvFile(path30.join(__dirname5, ".env")); function parseApiTargets() { const csvTargets = (process.env.TKMIND_API_TARGETS ?? "").split(",").map((value) => value.trim()).filter(Boolean); const legacyTargets = [ @@ -32804,7 +33409,7 @@ var INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_S var ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD; var WECHAT_MP_CONFIG = loadWechatMpConfig(); var WORKSPACE_MAINTENANCE_ENABLED = process.env.MEMIND_WORKSPACE_MAINTENANCE !== "0"; -var USERS_ROOT = process.env.H5_USERS_ROOT ?? path28.join(__dirname5, "users"); +var USERS_ROOT = process.env.H5_USERS_ROOT ?? path30.join(__dirname5, "users"); var app = express2(); app.set("trust proxy", 1); var isSecureRequest = (req) => req.secure || req.get("x-forwarded-proto")?.split(",")[0]?.trim() === "https"; @@ -32853,7 +33458,7 @@ var rawUploadBody = express2.raw({ type: "application/octet-stream", limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES) }); -var wikiAuth = createWikiAuth(path28.join(__dirname5, PUBLISH_ROOT_DIR, "wiki-db")); +var wikiAuth = createWikiAuth(path30.join(__dirname5, PUBLISH_ROOT_DIR, "wiki-db")); var legacyAuth = null; if (ACCESS_PASSWORD && !isDatabaseConfigured()) { legacyAuth = createAuthManager({ password: ACCESS_PASSWORD }); @@ -32915,12 +33520,12 @@ async function bootstrapUserAuth() { }); mindSpaceAssets = createAssetService(pool2, { h5Root: __dirname5, - storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"), + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path30.join(__dirname5, "data", "mindspace"), maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES) }); mindSpacePages = createPageService(pool2, { h5Root: __dirname5, - storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace") + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path30.join(__dirname5, "data", "mindspace") }); mindSpacePageLiveEdit = createPageLiveEditService({ pageService: mindSpacePages, @@ -32933,7 +33538,8 @@ async function bootstrapUserAuth() { } }); mindSpacePublications = createPublicationService(pool2, { - storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"), + h5Root: __dirname5, + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path30.join(__dirname5, "data", "mindspace"), publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5) }); setInterval(async () => { @@ -32987,7 +33593,7 @@ async function bootstrapUserAuth() { writebackPublications }); mindSpaceCleanup = createCleanupService(pool2, { - storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"), + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path30.join(__dirname5, "data", "mindspace"), h5Root: __dirname5 }); await ensurePlanCatalogSchema(pool2); @@ -33016,7 +33622,7 @@ async function bootstrapUserAuth() { } mindSpaceAgentJobs = createAgentJobService(pool2, { pageService: mindSpacePages, - storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"), + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path30.join(__dirname5, "data", "mindspace"), maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES) }); let experienceService = null; @@ -33101,9 +33707,9 @@ async function bootstrapUserAuth() { ); } if (WORKSPACE_MAINTENANCE_ENABLED) { - startWorkspaceThumbnailWatcher(path28.join(__dirname5, PUBLISH_ROOT_DIR)); + startWorkspaceThumbnailWatcher(path30.join(__dirname5, PUBLISH_ROOT_DIR)); startWorkspaceAssetSyncWatcher({ - publishRoot: path28.join(__dirname5, PUBLISH_ROOT_DIR), + publishRoot: path30.join(__dirname5, PUBLISH_ROOT_DIR), syncUserWorkspaceByDirKey: async (dirKey, options) => { let userId = dirKey; if (!PUBLISH_KEY_UUID.test(dirKey)) { @@ -33157,7 +33763,7 @@ async function bootstrapUserAuth() { conversationMemoryService, localFetchAsset: mindSpaceAssets ? async (userId, assetId) => { const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId); - const buffer = await fs26.promises.readFile(assetPath); + const buffer = await fs28.promises.readFile(assetPath); return { buffer, mimeType: asset.mimeType }; } : null }); @@ -34390,11 +34996,11 @@ api.post("/mindspace/v1/space/cleanup", async (req, res) => { return mindSpaceError(res, req, error); } }); -function isPlazaPublicRead(path29, method) { - if (!path29.startsWith("/plaza/v1/")) return false; - if (method === "POST" && path29 === "/plaza/v1/events") return true; +function isPlazaPublicRead(path31, method) { + if (!path31.startsWith("/plaza/v1/")) return false; + if (method === "POST" && path31 === "/plaza/v1/events") return true; if (method !== "GET") return false; - return path29 === "/plaza/v1/feed" || path29 === "/plaza/v1/categories" || path29 === "/plaza/v1/seo/sitemap" || /^\/plaza\/v1\/posts\/[^/]+$/.test(path29) || /^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path29) || /^\/plaza\/v1\/users\/[^/]+$/.test(path29) || /^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path29); + return path31 === "/plaza/v1/feed" || path31 === "/plaza/v1/categories" || path31 === "/plaza/v1/seo/sitemap" || /^\/plaza\/v1\/posts\/[^/]+$/.test(path31) || /^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path31) || /^\/plaza\/v1\/users\/[^/]+$/.test(path31) || /^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path31); } var PLAZA_SID_COOKIE = "plaza_sid"; function resolvePlazaSessionId(req, res) { @@ -34712,7 +35318,7 @@ api.get("/internal/agent/jobs/:jobId/assets/:assetId", async (req, res) => { res.set("Content-Disposition", `inline; filename="${encodeURIComponent(asset.displayName)}"`); res.set("Cache-Control", "private, no-store"); res.setHeader("X-Request-Id", req.requestId); - return res.send(await fs26.promises.readFile(asset.path)); + return res.send(await fs28.promises.readFile(asset.path)); } catch (error) { return mindSpaceError(res, req, error); } @@ -34980,7 +35586,7 @@ api.post("/mindspace/v1/pages/from-asset", async (req, res) => { req.currentUser.id, assetId ); - const content = await fs26.promises.readFile(assetPath, "utf8"); + const content = await fs28.promises.readFile(assetPath, "utf8"); const contentFormat = asset.mimeType === "text/html" ? "html" : "markdown"; const page = await mindSpacePages.createFromChat( req.currentUser.id, @@ -35054,18 +35660,28 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) { return { session, message, content }; } var SAVE_TARGET_CATEGORIES = /* @__PURE__ */ new Set(["draft", "oa", "public"]); +var pageSyncInFlight = /* @__PURE__ */ new Map(); async function syncUserGeneratedPages(userId) { if (!mindSpacePages || !authPool || !userId) return; - await syncGeneratedPagesFromPublicAssets({ - pool: authPool, - pageService: mindSpacePages, - assetService: mindSpaceAssets, - userId, - publishDir: resolvePublishDir(__dirname5, { id: userId }), - syncWorkspaceAssets: mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED ? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options) : null - }).catch((error) => { - console.warn("[MindSpace] page sync failed:", error?.message ?? error); - }); + let inFlight = pageSyncInFlight.get(userId); + if (!inFlight) { + inFlight = syncGeneratedPagesFromPublicAssets({ + pool: authPool, + pageService: mindSpacePages, + assetService: mindSpaceAssets, + userId, + publishDir: resolvePublishDir(__dirname5, { id: userId }), + syncWorkspaceAssets: mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED ? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options) : null + }).catch((error) => { + console.warn("[MindSpace] page sync failed:", error?.message ?? error); + }).finally(() => { + if (pageSyncInFlight.get(userId) === inFlight) { + pageSyncInFlight.delete(userId); + } + }); + pageSyncInFlight.set(userId, inFlight); + } + await inFlight; } async function resolveChatSaveBundle(user, h5Root, input = {}) { const sessionId = input.sessionId ?? input.session_id; @@ -35082,7 +35698,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) { selectedLinkIndex }); if (resolvedHtml?.content && resolvedHtml.relativePath && authPool) { - const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"); + const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ?? path30.join(__dirname5, "data", "mindspace"); let htmlContent = resolvedHtml.content; const repaired = await repairMissingHtmlAssetReferences({ pool: authPool, @@ -35108,7 +35724,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) { if (htmlContent !== resolvedHtml.content) { const publishDir = resolvePublishDir(h5Root, { id: user.id }); await fsPromises3.writeFile( - path28.join(publishDir, resolvedHtml.relativePath), + path30.join(publishDir, resolvedHtml.relativePath), htmlContent, "utf8" ); @@ -35169,7 +35785,7 @@ api.get("/mindspace/v1/pages/chat-save-thumbnail", async (req, res) => { { title, subtitle, force: true } ).catch(() => { }); - const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"); + const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ?? path30.join(__dirname5, "data", "mindspace"); const resolveAssetDataUri = createAssetDataUriResolver( authPool, storageRoot, @@ -35178,7 +35794,7 @@ api.get("/mindspace/v1/pages/chat-save-thumbnail", async (req, res) => { const svg = await generateHtmlThumbnail(publishDir, thumbRel, bundle.resolvedHtml.content, { title, subtitle, - contentBaseDir: path28.dirname(bundle.resolvedHtml.absolute), + contentBaseDir: path30.dirname(bundle.resolvedHtml.absolute), resolveAssetDataUri, force: true }); @@ -35256,7 +35872,7 @@ api.post("/mindspace/v1/pages/quick-share-from-chat", async (req, res) => { if (!bundle.resolvedHtml) { throw Object.assign(new Error("\u65E0\u6CD5\u8BFB\u53D6\u94FE\u63A5\u9875\u9762\u5185\u5BB9"), { code: "static_page_not_found" }); } - const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"); + const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ?? path30.join(__dirname5, "data", "mindspace"); const { html: localizedHtml } = await inlinePrivateAssetsInHtml( authPool, storageRoot, @@ -35264,13 +35880,13 @@ api.post("/mindspace/v1/pages/quick-share-from-chat", async (req, res) => { bundle.resolvedHtml.content ); const publishDir = resolvePublishDir(__dirname5, req.currentUser); - const sharedDir = path28.join(publishDir, PUBLIC_ZONE_DIR, "shared"); + const sharedDir = path30.join(publishDir, PUBLIC_ZONE_DIR, "shared"); await fsPromises3.mkdir(sharedDir, { recursive: true }); const basename = bundle.resolvedHtml.filename.replace(/\.html$/i, ""); const filename = `${basename}-${crypto35.randomUUID().slice(0, 8)}.html`; const sharedRelativePath = `${PUBLIC_ZONE_DIR}/shared/${filename}`; const sharedHtml = rewriteWorkspacePublicAssetReferences(localizedHtml, sharedRelativePath); - const destPath = path28.join(sharedDir, filename); + const destPath = path30.join(sharedDir, filename); await fsPromises3.writeFile(destPath, sharedHtml, "utf8"); const publishKey = req.currentUser.id; const publicUrl2 = `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/${sharedRelativePath.split("/").map((part) => encodeURIComponent(part)).join("/")}`; @@ -35419,10 +36035,24 @@ api.get("/mindspace/v1/pages", async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); try { await syncUserGeneratedPages(req.currentUser.id); - const pages = await mindSpacePages.listPages(req.currentUser.id, { - status: typeof req.query.status === "string" ? req.query.status : void 0 + const limit = Number.parseInt(String(req.query.limit ?? ""), 10); + const offset = Number.parseInt(String(req.query.offset ?? ""), 10); + const result = await mindSpacePages.listPages(req.currentUser.id, { + status: typeof req.query.status === "string" ? req.query.status : void 0, + categoryCode: typeof req.query.category_code === "string" ? req.query.category_code : void 0, + ...Number.isFinite(limit) ? { limit } : {}, + ...Number.isFinite(offset) ? { offset } : {} + }); + return res.json({ + data: result.items, + page: { + total: result.total, + limit: result.limit, + offset: result.offset, + has_more: result.hasMore, + next_cursor: null + } }); - return res.json({ data: pages, page: { next_cursor: null, has_more: false } }); } catch (error) { return mindSpaceError(res, req, error); } @@ -35443,7 +36073,10 @@ api.get("/mindspace/v1/pages/:pageId", async (req, res) => { api.delete("/mindspace/v1/pages/:pageId", async (req, res) => { if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return; try { - const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId); + const removeFromPlaza = String(req.query?.remove_from_plaza ?? req.body?.remove_from_plaza ?? "").toLowerCase() === "true" || req.query?.remove_from_plaza === "1" || req.body?.remove_from_plaza === true; + const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId, { + removeFromPlaza + }); const quota = await mindSpace.getQuota(req.currentUser.id); await mindSpaceAudit?.write({ userId: req.currentUser.id, @@ -35453,6 +36086,7 @@ api.delete("/mindspace/v1/pages/:pageId", async (req, res) => { ip: req.ip, detail: { offlinedPublicationCount: result.offlinedPublicationCount, + hiddenPlazaPostCount: result.hiddenPlazaPostCount, deletedAssetCount: result.deletedAssetCount, freedBytes: result.freedBytes } @@ -36667,7 +37301,7 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
- +
- 今天 {scheduleItems.length || 0} 项 - - {scheduleItems.length > 0 - ? '已有待办记录' - : scheduleDigests.length > 0 - ? `${scheduleDigests.length} 个服务号待办推送` - : '还没有安排'} - + + 未来 7 天 {scheduleReminders.length} 条提醒 · {pendingReminderCount} 待通知 + + {hasScheduleContent ? '仅展示提醒通知' : '还没有安排'}
+ {scheduleReminders.length > 0 && ( +
+ + +
+ )} + {scheduleReminders.length > 0 && ( +
+ {scheduleReminders.map((item) => ( +
+ + + + {item.notifyLabel} + +
+ {item.title} + {item.meta} +
+ {item.canIgnore && !previewMode && ( + + )} +
+ ))} +
+ )} {scheduleDigests.length > 0 && (
+

订阅

{scheduleDigests.map((digest) => ( -
+
- - 订阅 - -
+ 订阅 +
每天待办摘要 {formatDigestSchedule(digest)}
@@ -2107,22 +2296,9 @@ export function MindSpaceView({ ))}
)} - {scheduleItems.length > 0 ? ( -
- {scheduleItems.map((item) => ( -
- - {item.kind} -
- {item.title} - {item.meta} -
-
- ))} -
- ) : ( + {!hasScheduleContent && (

- 今天还没有待办,可以在聊天里说“明天上午提醒我带材料”。 + 未来 7 天还没有提醒,可以在聊天里说「明天早上 6 点跑步,5 点 55 提醒我」。

)} diff --git a/src/index.css b/src/index.css index e1d261c..5c1d7d3 100644 --- a/src/index.css +++ b/src/index.css @@ -5416,6 +5416,15 @@ body, font-size: 13px; } +.mindspace-schedule-subhead { + margin: 0 0 0.35rem; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #7a857f; +} + .mindspace-schedule-list { display: grid; gap: 7px; @@ -5433,6 +5442,94 @@ body, background: rgba(24, 33, 29, 0.045); } +.mindspace-schedule-item.has-checkbox { + grid-template-columns: 22px 72px 52px minmax(0, 1fr) auto; +} + +.mindspace-schedule-item.mindspace-schedule-item-digest { + grid-template-columns: 72px 52px minmax(0, 1fr); +} + +.mindspace-schedule-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 10px; +} + +.mindspace-schedule-select-all { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #68716c; +} + +.mindspace-schedule-toolbar-btn { + border: 0; + border-radius: 999px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + background: rgba(24, 33, 29, 0.08); + color: #18211d; +} + +.mindspace-schedule-toolbar-btn.danger { + background: rgba(196, 74, 58, 0.12); + color: #a33f32; +} + +.mindspace-schedule-toolbar-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.mindspace-schedule-checkbox { + display: flex; + align-items: center; +} + +.mindspace-schedule-item-body strong { + display: block; +} + +.mindspace-schedule-notify { + display: inline-flex; + justify-content: center; + border-radius: 999px; + padding: 3px 6px; + font-size: 11px; + line-height: 1.2; + white-space: nowrap; +} + +.mindspace-schedule-notify.is-pending { + color: #8a5b16; + background: rgba(238, 176, 78, 0.22); +} + +.mindspace-schedule-notify.is-sent { + color: #2f6f57; + background: rgba(47, 111, 87, 0.16); +} + +.mindspace-schedule-ignore { + border: 0; + border-radius: 999px; + padding: 5px 10px; + font-size: 11px; + cursor: pointer; + color: #68716c; + background: rgba(24, 33, 29, 0.08); +} + +.mindspace-schedule-ignore:disabled { + opacity: 0.45; + cursor: not-allowed; +} + .mindspace-schedule-item time { color: #2f6f57; font-size: 12px; diff --git a/src/types.ts b/src/types.ts index c8be92c..6a2fa85 100644 --- a/src/types.ts +++ b/src/types.ts @@ -359,8 +359,25 @@ export type MindSpaceDigestSubscription = { attempts?: number; }; +export type MindSpaceScheduleReminder = { + id: string; + itemId: string; + itemTitle: string; + itemKind: 'task' | 'event'; + itemTimezone: string; + itemStartAt?: number | null; + itemEndAt?: number | null; + remindAt: number; + offsetMinutes?: number | null; + channel: 'wechat' | 'in_app'; + status: 'pending' | 'locked' | 'sent' | 'failed' | 'cancelled'; + sentAt?: number | null; +}; + export type MindSpaceSchedule = { todayTodoItems: MindSpaceScheduleItem[]; + upcomingItems: MindSpaceScheduleItem[]; + upcomingReminders: MindSpaceScheduleReminder[]; digestSubscriptions: MindSpaceDigestSubscription[]; }; diff --git a/user-memory-profile.mjs b/user-memory-profile.mjs index d27be13..5d8c675 100644 --- a/user-memory-profile.mjs +++ b/user-memory-profile.mjs @@ -214,6 +214,19 @@ export function buildSessionMemoryEntries({ content: renderCurrentTimeAnchor({ now, timezone }), }); + const scheduleTools = (sessionPolicy?.extensionOverrides ?? []).find((ext) => ext.name === 'sandbox-fs'); + if ((scheduleTools?.available_tools ?? []).includes('schedule_create_item')) { + entries.push({ + title: 'TKMind 日程写入规则', + content: [ + '创建或提醒待办/日程时:', + '- 必须使用 startLocal / endLocal / remindLocal,格式 YYYY-MM-DD HH:mm(用户时区墙上时钟)。', + '- 禁止自行估算 Unix 毫秒时间戳;传错会被工具拒绝。', + '- 写入后调用 schedule_list_items 核对日期是否与用户表述一致。', + ].join('\n'), + }); + } + if (!hasMemoryStore(sessionPolicy)) { return entries; } diff --git a/wechat-mp.mjs b/wechat-mp.mjs index fdf3172..bee38af 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -6,6 +6,7 @@ import { developerToolsFromPolicy } from './capabilities.mjs'; import { mergeMessageContent } from './message-stream.mjs'; import { reconcileAgentSession } from './session-reconcile.mjs'; import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs'; +import { renderCurrentTimeAnchor } from './user-memory-profile.mjs'; import { materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs'; import { PUBLISH_ROOT_DIR } from './user-publish.mjs'; import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs'; @@ -1079,10 +1080,13 @@ function buildWechatAgentPrompt(intent) { '', ].join('\n') : ''; + const scheduleTimezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai'; const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content) ? [ '【日程技能要求】这条消息涉及待办、提醒或日程。', '开始前先加载 `schedule-assistant` skill,并严格按 skill 里的边界执行。', + '写入工具时优先使用 startLocal / endLocal / remindLocal(YYYY-MM-DD HH:mm),不要自行估算 Unix 毫秒。', + renderCurrentTimeAnchor({ timezone: scheduleTimezone }), '只有在 `schedule_create_item` / `schedule_create_reminder` 等工具成功返回后,才能告诉用户“已经设置好了”。', intent?.msgId ? `调用 schedule_create_item 时必须传入 sourceMessageId: ${intent.msgId}` : '', '',