diff --git a/.runtime/portal/mindspace-sandbox-mcp.mjs b/.runtime/portal/mindspace-sandbox-mcp.mjs index 3250642..3f59298 100755 --- a/.runtime/portal/mindspace-sandbox-mcp.mjs +++ b/.runtime/portal/mindspace-sandbox-mcp.mjs @@ -174,15 +174,31 @@ function resolveScheduleTimestamp({ epochMs = null, localString = null, timezone = DEFAULT_TIMEZONE, - fieldName = "\u65F6\u95F4" + fieldName = "\u65F6\u95F4", + now = Date.now() } = {}) { const local = String(localString ?? "").trim(); if (local) return parseLocalDateTimeString(local, timezone); if (epochMs == null || epochMs === "") return null; + return assertReasonableScheduleEpoch(epochMs, { now, fieldName }); +} +function assertReasonableScheduleEpoch(epochMs, { now = Date.now(), fieldName = "\u65F6\u95F4", maxPastDays = 2, maxFutureDays = 400 } = {}) { 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`); } + const min = now - maxPastDays * 864e5; + const max = now + maxFutureDays * 864e5; + if (safe < min || safe > max) { + const label = new Intl.DateTimeFormat("zh-CN", { + timeZone: normalizeTimezone(process.env.H5_DEFAULT_TIMEZONE), + dateStyle: "short", + timeStyle: "short" + }).format(new Date(safe)); + throw new Error( + `${fieldName}\uFF08${label}\uFF09\u8D85\u51FA\u5408\u7406\u8303\u56F4\u3002\u8BF7\u6539\u7528 startLocal/remindLocal\uFF08YYYY-MM-DD HH:mm\uFF0C\u7528\u6237\u65F6\u533A\uFF09\uFF0C\u7981\u6B62\u81EA\u884C\u4F30\u7B97 Unix \u6BEB\u79D2\u3002` + ); + } return safe; } @@ -467,7 +483,7 @@ function createScheduleService(pool, options = {}) { 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.status IN ('pending', 'locked', 'sent') AND r.remind_at >= ? AND r.remind_at < ? AND i.deleted_at IS NULL @@ -494,6 +510,48 @@ function createScheduleService(pool, options = {}) { ); return rowToItem(rows[0]); }; + const getReminder = async ({ userId, reminderId }) => { + if (!userId || !reminderId) throw new Error("\u7F3A\u5C11\u63D0\u9192\u53C2\u6570"); + 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.id = ? AND r.user_id = ? AND i.deleted_at IS NULL + LIMIT 1`, + [reminderId, userId] + ); + return rowToReminderWithItem(rows[0]); + }; + const cancelReminder = async ({ userId, reminderId, reason = "\u7528\u6237\u5FFD\u7565" } = {}) => { + const reminder = await getReminder({ userId, reminderId }); + if (!reminder) throw new Error("\u63D0\u9192\u4E0D\u5B58\u5728\u6216\u65E0\u6743\u8BBF\u95EE"); + if (reminder.status === "cancelled") return reminder; + if (reminder.status === "sent") throw new Error("\u5DF2\u901A\u77E5\u7684\u63D0\u9192\u4E0D\u80FD\u5FFD\u7565"); + return markReminderCancelled(reminder, reason); + }; + const deleteReminder = async ({ userId, reminderId }) => { + if (!userId || !reminderId) throw new Error("\u7F3A\u5C11\u63D0\u9192\u53C2\u6570"); + const [result] = await pool.query( + `DELETE FROM h5_schedule_reminders WHERE id = ? AND user_id = ?`, + [reminderId, userId] + ); + return Number(result?.affectedRows ?? 0) > 0; + }; + const deleteReminders = async ({ userId, reminderIds }) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const ids = [...new Set((reminderIds ?? []).map((id) => String(id ?? "").trim()).filter(Boolean))]; + if (ids.length === 0) return 0; + const [result] = await pool.query( + `DELETE FROM h5_schedule_reminders WHERE user_id = ? AND id IN (${ids.map(() => "?").join(", ")})`, + [userId, ...ids] + ); + return Number(result?.affectedRows ?? 0); + }; const createReminder = async ({ userId, itemId, @@ -1041,6 +1099,10 @@ function createScheduleService(pool, options = {}) { listTodayTodoItems, listUpcomingItems, listUpcomingReminders, + getReminder, + cancelReminder, + deleteReminder, + deleteReminders, listDigestSubscriptions, createDailyTodoDigest, createBalanceLowAlert, diff --git a/.runtime/portal/server.mjs b/.runtime/portal/server.mjs index 5448bfc..1635960 100644 --- a/.runtime/portal/server.mjs +++ b/.runtime/portal/server.mjs @@ -5747,14 +5747,14 @@ function createMindSpaceService(pool2, options = {}) { c.ai_access_policy, c.publish_policy, c.is_system, c.sort_order, CASE WHEN c.category_code = 'draft' THEN COALESCE(pc.item_count, 0) - WHEN c.category_code = 'public' THEN COALESCE(pub.item_count, 0) + WHEN c.category_code IN ('oa', 'public') THEN COALESCE(pc.item_count, 0) + COALESCE(ac.item_count, 0) ELSE COALESCE(ac.item_count, 0) END AS item_count FROM h5_space_categories c LEFT JOIN ( SELECT category_id, user_id, COUNT(*) AS item_count FROM h5_assets - WHERE user_id = ? AND status <> 'deleted' AND source_type = 'upload' + WHERE user_id = ? AND status <> 'deleted' GROUP BY category_id, user_id ) ac ON ac.category_id = c.id AND ac.user_id = c.user_id LEFT JOIN ( @@ -5763,15 +5763,9 @@ function createMindSpaceService(pool2, options = {}) { WHERE user_id = ? AND status <> 'deleted' GROUP BY category_id, user_id ) pc ON pc.category_id = c.id AND pc.user_id = c.user_id - LEFT JOIN ( - SELECT user_id, COUNT(DISTINCT page_id) AS item_count - FROM h5_publish_records - WHERE user_id = ? AND status = 'online' - GROUP BY user_id - ) pub ON pub.user_id = c.user_id WHERE c.user_id = ? AND c.space_id = ? ORDER BY c.sort_order, c.category_name`, - [userId, userId, userId, userId, row.id] + [userId, userId, userId, row.id] ); const quotaBytes = asNumber(row.quota_bytes); const usedBytes = asNumber(row.used_bytes); @@ -7503,7 +7497,7 @@ function renderBrandingBlock(userAddressName) { - \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4ECB\u7ECD\u4EA7\u54C1\u65F6\u7528 TKMind\uFF0C\u4E0D\u8981\u79F0 goose\u3001Goose\u3001goosed - \u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\uFF0C\u7528 **${name}** \u79F0\u547C\u7528\u6237\uFF08\u53EF\u8F85\u4EE5\u300C\u4F60/\u60A8\u300D\uFF09\uFF0C**\u7981\u6B62**\u628A\u7528\u6237\u53EB\u4F5C TKMind -- \u95EE\u5019\u793A\u4F8B\uFF1A\u300C${name}\uFF0C\u4E0B\u5348\u597D\u300D\u2014\u2014\u4E0D\u8981\u7528\u300CTKMind\uFF0C\u4E0B\u5348\u597D\u300D +- \u4EC5\u5728\u5F00\u573A\u6216\u7528\u6237\u6253\u62DB\u547C\u65F6\u4F7F\u7528\u65F6\u6BB5\u95EE\u5019\uFF0C\u4E14\u987B\u4E0E\u300CTKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6\u300D\u4E00\u81F4\uFF08\u5982\u300C${name}\uFF0C\u65E9\u4E0A\u597D\u300D\uFF09\uFF1B\u666E\u901A\u56DE\u590D\u76F4\u63A5\u4F5C\u7B54\uFF0C\u4E0D\u8981\u6BCF\u6761\u90FD\u52A0\u95EE\u5019\uFF1B\u7981\u6B62\u628A\u7528\u6237\u53EB\u4F5C TKMind - \u4E0D\u8981\u63CF\u8FF0\u672C\u5DE5\u4F5C\u533A\u4E3A\u300CRust goose \u9879\u76EE\u300D\u6216\u300Cgoose AI \u6846\u67B6\u300D - \u672C\u5DE5\u4F5C\u533A\u662F TKMind **MindSpace \u7528\u6237\u7A7A\u95F4**\uFF0C\u7528\u4E8E\u6587\u4EF6\u7BA1\u7406\u4E0E\u9759\u6001\u9875\u9762\u751F\u6210 `; @@ -7662,6 +7656,13 @@ description: \u5728\u4E13\u5C5E MindSpace \u76EE\u5F55\u751F\u6210\u53EF\u516C\u - \u5B8C\u6574 \`\`\uFF0C\`lang="zh-CN"\` - \u79FB\u52A8\u7AEF\u53CB\u597D\uFF1A\`\` - \u6DF1\u8272/\u6D45\u8272\u4E0E\u5185\u5BB9\u4E00\u81F4\uFF1B\u4E3B\u8272\u5728 CSS \u4E0E mindspace-cover \u4E2D\u4FDD\u6301\u4E00\u81F4 +- \u9875\u811A\u5E73\u53F0\u8054\u7CFB\u884C**\u5FC5\u987B**\u4F7F\u7528 \`data-mindspace-page-tag="platform-brand"\` \u6807\u8BB0\uFF0C\u4E14\u90AE\u7BB1/\u57DF\u540D\u53EA\u7528 **tkmind.cn**\uFF08\u5982 \`contact@tkmind.cn\`\uFF09\uFF0C**\u7981\u6B62** \`tkmind.ai\` + +\`\`\`html +

\u{1F4E7} contact@tkmind.cn

+\`\`\` + +- \u5E26 \`data-mindspace-page-tag\` \u7684\u533A\u57DF\u4E3A\u5E73\u53F0\u56FA\u5B9A\u4FE1\u606F\uFF1A\u7528\u6237\u5728\u7F16\u8F91\u6A21\u5F0F\u4E2D\u4E0D\u53EF\u89C1\u3001\u4E0D\u53EF\u6539\uFF1B\u9884\u89C8\u4E0E\u53D1\u5E03\u540E\u6B63\u5E38\u663E\u793A ## \u67E5\u627E\u6587\u4EF6\uFF08CSV\u3001\u6587\u6863\u7B49\uFF09 @@ -7763,6 +7764,7 @@ function buildPublishConstraints({ slug, username, publicBaseUrl, publishDir, di "- **\u7981\u6B62**\uFF1A\u8BBF\u95EE `assets/` \u5185\u90E8\u8DEF\u5F84\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\u4E3B\u673A\u7EDD\u5BF9\u8DEF\u5F84\uFF1B\u7981\u6B62\u7528\u516C\u7F51 URL \u5217\u76EE\u5F55\u6216\u8BFB CSV", "- **\u8DEF\u5F84\u89C4\u5219**\uFF1A\u53EA\u7528\u76F8\u5BF9\u8DEF\u5F84\uFF1B\u7981\u6B62 `../`\uFF1B\u5DE5\u4F5C\u533A\u5916\u7684\u8DEF\u5F84\u4F1A\u88AB\u7CFB\u7EDF\u62D2\u7EDD\uFF08OS \u5C42\u5F3A\u5236\uFF0C\u975E\u8F6F\u7EA6\u675F\uFF09", "- **\u751F\u6210\u9875\u9762\uFF08\u5FC5\u987B\u4EB2\u81EA\u5B8C\u6210\uFF09**\uFF1A\u5148 `load_skill` \u2192 `static-page-publish`\uFF0C\u518D\u7528 `write_file`/`edit_file` \u5199\u5165 `public/\u9875\u9762.html`", + "- **\u7528\u6237\u53EF\u89C1\u56DE\u590D**\uFF1A\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0 load_skill\u3001\u6280\u80FD\u66F4\u65B0\u3001\u9875\u811A\u6807\u8BB0\u3001mindspace-cover \u7B49\u5185\u90E8\u5B9E\u73B0\uFF1B\u5B8C\u6210\u540E\u76F4\u63A5\u7ED9\u51FA\u9875\u9762\u94FE\u63A5\u6216\u7ED3\u679C", "- **\u7981\u6B62**\u7528 shell \u5199\u5165 HTML\uFF1B**\u7981\u6B62**\u8BA9\u7528\u6237\u300C\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u6216\u8BF4\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E write_file \u5DF2\u5931\u8D25\u5E76\u62A5\u544A\u9519\u8BEF", "- \u5B8C\u6210\u540E\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 `[\u6807\u9898](URL)`\uFF1B\u5199\u5165 `public/\u9875\u9762.html` \u65F6 URL \u4E3A `.../MindSpace/<\u7528\u6237ID>/public/\u9875\u9762.html`", `- \u53D1\u5E03\u6280\u80FD\uFF1A\`${PUBLISH_SKILL_NAME}\`\uFF08\u751F\u6210\u9875\u9762\u524D\u5E94 load_skill\uFF09` @@ -7975,8 +7977,35 @@ function formatDateParts(now, timezone) { day: parts.day ?? "01" }; } +function formatLocalClockParts(now, timezone) { + const formatter = new Intl.DateTimeFormat("en-GB", { + timeZone: timezone, + hour: "2-digit", + minute: "2-digit", + hour12: false + }); + const parts = Object.fromEntries( + formatter.formatToParts(new Date(now)).map((part) => [part.type, part.value]) + ); + return { + hour: Number(parts.hour ?? 0), + minute: Number(parts.minute ?? 0) + }; +} +function resolveTimeOfDayPeriod(hour) { + const h = Number(hour); + if (h < 5) return { period: "\u51CC\u6668", greeting: "\u4F60\u597D" }; + if (h < 9) return { period: "\u65E9\u4E0A", greeting: "\u65E9\u4E0A\u597D" }; + if (h < 12) return { period: "\u4E0A\u5348", greeting: "\u4E0A\u5348\u597D" }; + if (h < 14) return { period: "\u4E2D\u5348", greeting: "\u4E2D\u5348\u597D" }; + if (h < 18) return { period: "\u4E0B\u5348", greeting: "\u4E0B\u5348\u597D" }; + return { period: "\u665A\u4E0A", greeting: "\u665A\u4E0A\u597D" }; +} function renderCurrentTimeAnchor({ now = Date.now(), timezone = DEFAULT_TIMEZONE } = {}) { const { year, month, day } = formatDateParts(now, timezone); + const { hour, minute } = formatLocalClockParts(now, timezone); + const { period, greeting } = resolveTimeOfDayPeriod(hour); + const clock = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; const weekday = new Intl.DateTimeFormat("zh-CN", { timeZone: timezone, weekday: "long" @@ -7986,7 +8015,9 @@ function renderCurrentTimeAnchor({ now = Date.now(), timezone = DEFAULT_TIMEZONE "", `- \u5F53\u524D\u65F6\u533A\uFF1A${timezone}`, `- \u5F53\u524D\u65E5\u671F\uFF1A${year}-${month}-${day}\uFF08${weekday}\uFF09`, - "- \u56DE\u7B54\u4E2D\u6D89\u53CA\u201C\u4ECA\u5929 / \u660E\u5929 / \u540E\u5929 / \u5468\u51E0\u201D\u65F6\uFF0C\u5FC5\u987B\u4EE5\u4E0A\u8FF0\u65E5\u671F\u4E3A\u51C6\u7EE7\u7EED\u63A8\u7B97\uFF0C\u4E0D\u8981\u81EA\u884C\u5047\u8BBE\u5F53\u524D\u65E5\u671F\u3002" + `- \u5F53\u524D\u65F6\u523B\uFF1A${clock}\uFF08${period}\uFF09`, + `- \u82E5\u9700\u65F6\u6BB5\u95EE\u5019\u53EF\u53C2\u8003\uFF1A${greeting}\uFF08\u4EC5\u65B0\u4F1A\u8BDD\u5F00\u573A\u6216\u7528\u6237\u4E3B\u52A8\u6253\u62DB\u547C\u65F6\u4F7F\u7528\uFF0C\u666E\u901A\u4EFB\u52A1\u56DE\u590D\u4E0D\u8981\u6BCF\u6761\u90FD\u52A0\uFF09`, + "- \u56DE\u7B54\u4E2D\u6D89\u53CA\u201C\u4ECA\u5929 / \u660E\u5929 / \u540E\u5929 / \u5468\u51E0 / \u65E9\u4E0A / \u4E0B\u5348 / \u665A\u4E0A\u201D\u7B49\u65F6\u95F4\u8868\u8FF0\u65F6\uFF0C\u5FC5\u987B\u4EE5\u4E0A\u8FF0\u65E5\u671F\u4E0E\u65F6\u523B\u4E3A\u51C6\uFF0C\u7981\u6B62\u81EA\u884C\u5047\u8BBE\u5F53\u524D\u65F6\u95F4\u6216\u4F7F\u7528 UTC \u7B49\u5176\u5B83\u65F6\u533A\u3002" ].join("\n"); } function buildSessionMemoryEntries({ @@ -8018,6 +8049,18 @@ function buildSessionMemoryEntries({ title: "TKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6", 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 \u65E5\u7A0B\u5199\u5165\u89C4\u5219", + content: [ + "\u521B\u5EFA\u6216\u63D0\u9192\u5F85\u529E/\u65E5\u7A0B\u65F6\uFF1A", + "- \u5FC5\u987B\u4F7F\u7528 startLocal / endLocal / remindLocal\uFF0C\u683C\u5F0F YYYY-MM-DD HH:mm\uFF08\u7528\u6237\u65F6\u533A\u5899\u4E0A\u65F6\u949F\uFF09\u3002", + "- \u7981\u6B62\u81EA\u884C\u4F30\u7B97 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF1B\u4F20\u9519\u4F1A\u88AB\u5DE5\u5177\u62D2\u7EDD\u3002", + "- \u5199\u5165\u540E\u8C03\u7528 schedule_list_items \u6838\u5BF9\u65E5\u671F\u662F\u5426\u4E0E\u7528\u6237\u8868\u8FF0\u4E00\u81F4\u3002" + ].join("\n") + }); + } if (!hasMemoryStore(sessionPolicy)) { return entries; } @@ -9328,7 +9371,7 @@ function createTkmindProxy({ try { const upstream = await apiFetch(target, apiSecret, "/sessions", { method: "GET", - signal: AbortSignal.timeout(3e3) + signal: AbortSignal.timeout(12e3) }); const text = await upstream.text(); if (!upstream.ok) { @@ -10016,7 +10059,7 @@ function renderUserSpaceBrandingBlock(userAddressName) { - \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4ECB\u7ECD\u4EA7\u54C1\u65F6\u7528 TKMind\uFF0C\u4E0D\u8981\u79F0 goose\u3001Goose\u3001goosed - \u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\uFF0C\u7528 **${name}** \u79F0\u547C\u7528\u6237\uFF08\u53EF\u8F85\u4EE5\u300C\u4F60/\u60A8\u300D\uFF09\uFF0C**\u7981\u6B62**\u628A\u7528\u6237\u53EB\u4F5C TKMind -- \u95EE\u5019\u793A\u4F8B\uFF1A\u300C${name}\uFF0C\u4E0B\u5348\u597D\u300D\u2014\u2014\u4E0D\u8981\u7528\u300CTKMind\uFF0C\u4E0B\u5348\u597D\u300D +- \u4EC5\u5728\u5F00\u573A\u6216\u7528\u6237\u6253\u62DB\u547C\u65F6\u4F7F\u7528\u65F6\u6BB5\u95EE\u5019\uFF0C\u4E14\u987B\u4E0E\u300CTKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6\u300D\u4E00\u81F4\uFF08\u5982\u300C${name}\uFF0C\u65E9\u4E0A\u597D\u300D\uFF09\uFF1B\u666E\u901A\u56DE\u590D\u76F4\u63A5\u4F5C\u7B54\uFF0C\u4E0D\u8981\u6BCF\u6761\u90FD\u52A0\u95EE\u5019\uFF1B\u7981\u6B62\u628A\u7528\u6237\u53EB\u4F5C TKMind - \u4E0D\u8981\u63CF\u8FF0\u672C\u5DE5\u4F5C\u533A\u4E3A\u300CRust goose \u9879\u76EE\u300D\u6216\u300Cgoose AI \u6846\u67B6\u300D - \u672C\u5DE5\u4F5C\u533A\u662F TKMind **MindSpace \u7528\u6237\u7A7A\u95F4**\uFF0C\u7528\u4E8E OA/\u516C\u5F00\u6587\u4EF6\u7BA1\u7406\u4E0E\u9759\u6001\u9875\u9762\u751F\u6210 `; @@ -16398,6 +16441,45 @@ function resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef) { if (!htmlDir || htmlDir === ".") return `public/${ref}`; return path18.posix.join(htmlDir, ref).replace(/\\/g, "/"); } +function resolveSiblingDocxPath(htmlRelativePath) { + const htmlPath = String(htmlRelativePath ?? "").replace(/^\/+/, ""); + if (!htmlPath.toLowerCase().endsWith(".html")) return null; + return htmlPath.replace(/\.html$/i, ".docx"); +} +function inferWorkspaceHtmlRelativePath({ + snapshotRelativePath = null, + pageTitle = null, + htmlContent = null, + publishDir = null +} = {}) { + const fromSnapshot = String(snapshotRelativePath ?? "").trim(); + if (fromSnapshot) { + const normalized = fromSnapshot.replace(/^\/+/, ""); + if (normalized.toLowerCase().startsWith("public/")) return normalized; + if (normalized.toLowerCase().endsWith(".html")) return `public/${path18.posix.basename(normalized)}`; + return normalized; + } + const publicDir = publishDir ? path18.join(publishDir, "public") : null; + if (!publicDir || !fs14.existsSync(publicDir) || !htmlContent) { + return "public/index.html"; + } + const normalizedContent = String(htmlContent); + const title = String(pageTitle ?? "").trim(); + for (const name of fs14.readdirSync(publicDir)) { + if (!name.toLowerCase().endsWith(".html")) continue; + const absolutePath = path18.join(publicDir, name); + try { + const diskContent = fs14.readFileSync(absolutePath, "utf8"); + if (diskContent === normalizedContent) return `public/${name}`; + if (title) { + const titleMatch = diskContent.match(/]*>([^<]+)<\/title>/i); + if (titleMatch?.[1]?.trim() === title) return `public/${name}`; + } + } catch { + } + } + return "public/index.html"; +} function buildWorkspaceDownloadLinkIndex(assets = []) { const byBasename = /* @__PURE__ */ new Map(); const byPath = /* @__PURE__ */ new Map(); @@ -16409,6 +16491,8 @@ function buildWorkspaceDownloadLinkIndex(assets = []) { byBasename.set(path18.posix.basename(filename), id); if (filename.startsWith("public/")) { byPath.set(filename.slice("public/".length), id); + } else if (!filename.includes("/")) { + byPath.set(`public/${filename}`, id); } } return { byBasename, byPath }; @@ -16434,6 +16518,19 @@ function workspaceFileExists(publishDir, workspaceRelativePath) { return false; } } +function collectDownloadWorkspaceCandidates(htmlRelativePath, relativeRef, publishDir) { + const workspacePath = resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef); + const candidates = /* @__PURE__ */ new Set([ + workspacePath, + path18.posix.join("public", path18.posix.basename(workspacePath)) + ]); + const siblingDocx = resolveSiblingDocxPath(htmlRelativePath); + if (siblingDocx) { + candidates.add(siblingDocx); + candidates.add(path18.posix.join("public", path18.posix.basename(siblingDocx))); + } + return [...candidates]; +} function resolveDownloadTargetUrl({ relativeRef, htmlRelativePath = "public/index.html", @@ -16444,24 +16541,25 @@ function resolveDownloadTargetUrl({ 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}`; + const candidates = collectDownloadWorkspaceCandidates(htmlRelativePath, relativeRef, publishDir); + for (const candidate of candidates) { + const assetId = lookupAssetIdForWorkspacePath(linkIndex, candidate); + if (preferAssetDownload && assetId) { + return `${buildAssetDownloadUrl(assetId)}${suffix}`; + } } if (publishDir && publishKey) { - for (const candidate of publicCandidates) { + for (const candidate of candidates) { if (workspaceFileExists(publishDir, candidate)) { return `${buildPublicWorkspaceFileUrl(publicBaseUrl, publishKey, candidate)}${suffix}`; } } } - if (assetId) { - return `${buildAssetDownloadUrl(assetId)}${suffix}`; + for (const candidate of candidates) { + const assetId = lookupAssetIdForWorkspacePath(linkIndex, candidate); + if (assetId) { + return `${buildAssetDownloadUrl(assetId)}${suffix}`; + } } return null; } @@ -16492,6 +16590,37 @@ async function prepareHtmlDownloadLinks(pool2, userId, html, options = {}) { return rewriteRelativeDownloadLinks(html, { ...options, linkIndex }); } +// mindspace-page-tag.mjs +var MINDSPACE_PAGE_TAG_ATTR = "data-mindspace-page-tag"; +var MINDSPACE_PAGE_TAG_PLATFORM_BRAND = "platform-brand"; +var PLATFORM_BRAND_INNER_RE = /(?:contact@tkmind\.(?:ai|cn)|📧[^<]*tkmind)/i; +var SIMPLE_BRAND_ELEMENT_RE = /<(p|div|span|small|footer|a)(\s[^>]*)?>([^<]*(?:contact@tkmind\.(?:ai|cn)|📧[^<]*tkmind)[^<]*)<\/\1>/gi; +function normalizePlatformDomainText(text) { + return String(text ?? "").replace(/tkmind\.ai/gi, "tkmind.cn"); +} +function normalizePlatformBrandHtml(html) { + let next = normalizePlatformDomainText(html); + next = next.replace(/mailto:([^"'>\s]+@tkmind)\.ai/gi, "mailto:$1.cn"); + next = next.replace( + /https?:\/\/([a-z0-9.-]*\.)?tkmind\.ai/gi, + (match) => match.replace(/tkmind\.ai/gi, "tkmind.cn") + ); + return next; +} +function ensurePlatformBrandPageTags(html) { + const normalized = normalizePlatformBrandHtml(html); + return normalized.replace(SIMPLE_BRAND_ELEMENT_RE, (match, tag, attrs, inner) => { + const attrStr = attrs ?? ""; + if (attrStr.includes(MINDSPACE_PAGE_TAG_ATTR)) return match; + if (!PLATFORM_BRAND_INNER_RE.test(inner)) return match; + const spacer = attrStr ? "" : " "; + return `<${tag}${attrStr}${spacer}${MINDSPACE_PAGE_TAG_ATTR}="${MINDSPACE_PAGE_TAG_PLATFORM_BRAND}">${inner}`; + }); +} +function prepareHtmlPageBrandMarkers(html) { + return ensurePlatformBrandPageTags(html); +} + // mindspace-page-purge.mjs import fs15 from "node:fs/promises"; import path19 from "node:path"; @@ -16623,6 +16752,13 @@ function parseJsonColumn(value, fallback = {}) { function pageError(message, code, details) { return Object.assign(new Error(message), { code, details }); } +function normalizeWorkspaceRelativePath2(relativePath) { + const parts = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== ".."); + if (parts.length === 0) return ""; + if (parts[0].toLowerCase() === "public") return parts.join("/"); + if (parts.length === 1 && parts[0].toLowerCase().endsWith(".html")) return `public/${parts[0]}`; + return parts.join("/"); +} function normalizeText(value, maxLength, fieldName) { const text = String(value ?? "").normalize("NFKC").trim(); if (!text) throw pageError(`${fieldName}\u4E0D\u80FD\u4E3A\u7A7A`, "invalid_page_input"); @@ -16633,13 +16769,16 @@ function normalizeText(value, maxLength, fieldName) { } function normalizePageInput(input) { const title = normalizeText(input.title, MAX_TITLE_LENGTH, "\u6807\u9898"); - const content = normalizeText(input.content, MAX_CONTENT_BYTES, "\u9875\u9762\u5185\u5BB9"); + let content = normalizeText(input.content, MAX_CONTENT_BYTES, "\u9875\u9762\u5185\u5BB9"); + const contentFormat = input.contentFormat === "html" ? "html" : "markdown"; + if (contentFormat === "html") { + content = prepareHtmlPageBrandMarkers(content); + } const contentBytes = Buffer.byteLength(content, "utf8"); if (contentBytes > MAX_CONTENT_BYTES) { throw pageError("\u9875\u9762\u5185\u5BB9\u8D85\u8FC7 1 MB \u9650\u5236", "page_content_too_large"); } const summary = String(input.summary ?? "").normalize("NFKC").trim().slice(0, MAX_SUMMARY_LENGTH); - const contentFormat = input.contentFormat === "html" ? "html" : "markdown"; const templateId = contentFormat === "html" ? "static-html" : TEMPLATE_IDS.has(input.templateId) ? input.templateId : "editorial"; const plainSummary = contentFormat === "html" ? content.replace(//gi, " ").replace(//gi, " ").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 180) : content.replace(/\s+/g, " ").slice(0, 180); return { @@ -17105,6 +17244,24 @@ function createPageService(pool2, options = {}) { ); return rows[0] ? pageResponse(rows[0]) : null; }; + const findPageByRelativePath = async (userId, relativePath) => { + const normalized = normalizeWorkspaceRelativePath2(relativePath); + if (!normalized) return null; + 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 + JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id + 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 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, normalized] + ); + return rows[0] ? pageResponse(rows[0]) : null; + }; const listPages = async (userId, filters = {}) => { const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`]; const params = [userId]; @@ -17250,9 +17407,9 @@ ${updated.content ?? ""}`, changes }; }; - const loadPageWorkspaceContext = async (userId, pageId) => { + const loadPageWorkspaceContext = async (userId, pageId, htmlContent = null) => { const [rows] = await pool2.query( - `SELECT pv.source_snapshot_json + `SELECT pv.source_snapshot_json, p.title 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' @@ -17265,18 +17422,34 @@ ${updated.content ?? ""}`, } catch { snapshot = {}; } - const workspaceHtmlRelativePath = snapshot.relative_path ?? "public/index.html"; const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null; + const workspaceHtmlRelativePath = inferWorkspaceHtmlRelativePath({ + snapshotRelativePath: snapshot.relative_path, + pageTitle: rows[0]?.title, + htmlContent, + publishDir: workspacePublishDir + }); return { workspaceHtmlRelativePath, workspacePublishDir, publishKey: userId }; }; + const rewriteHtmlDownloadLinksForPage = async (userId, pageId, html) => { + const ctx = await loadPageWorkspaceContext(userId, pageId, html); + const { html: rewritten } = await prepareHtmlDownloadLinks(pool2, userId, html, { + htmlRelativePath: ctx.workspaceHtmlRelativePath, + publishDir: ctx.workspacePublishDir, + publishKey: ctx.publishKey, + publicBaseUrl: "", + preferAssetDownload: true + }); + return rewritten; + }; const finalizeHtmlPreview = async (userId, pageId, html) => { const shell = renderHtmlPreview(html); try { - const ctx = await loadPageWorkspaceContext(userId, pageId); + const ctx = await loadPageWorkspaceContext(userId, pageId, html); const { html: rewritten } = await prepareHtmlDownloadLinks(pool2, userId, shell, { htmlRelativePath: ctx.workspaceHtmlRelativePath, publishDir: ctx.workspacePublishDir, @@ -17815,10 +17988,12 @@ ${updated.content ?? ""}`, listPages, findPageBySourceAsset, findPageBySourceMessage, + findPageByRelativePath, getPage, getDeletePreview, deletePage, listVersions, + rewriteHtmlDownloadLinksForPage, renderPreview: async (userId, pageId) => { const page = await getPage(userId, pageId); const html = page.contentFormat === "html" ? await finalizeHtmlPreview(userId, pageId, page.content) : renderPreviewHtml(page); @@ -18090,6 +18265,33 @@ function createPageLiveEditService({ pageService, resolveUserIdForAgentSession } }; } +// mindspace-asset-agent.mjs +function createAssetAgentService({ assetService, resolveUserIdForAgentSession }) { + return { + async applyAgentDelete(input = {}) { + const sessionId = String(input?.sessionId ?? input?.session_id ?? "").trim(); + const assetId = String(input?.assetId ?? input?.asset_id ?? "").trim(); + const confirmed = input?.confirmed === true || String(input?.confirmed ?? "").toLowerCase() === "true"; + if (!sessionId || !assetId) { + throw Object.assign(new Error("\u7F3A\u5C11 session_id \u6216 asset_id"), { code: "invalid_request" }); + } + if (!confirmed) { + throw Object.assign(new Error("\u5220\u9664\u524D\u987B\u5DF2\u5728\u5BF9\u8BDD\u4E2D\u83B7\u5F97\u7528\u6237\u786E\u8BA4"), { code: "confirmation_required" }); + } + const userId = await resolveUserIdForAgentSession(sessionId); + if (!userId) { + throw Object.assign(new Error("\u65E0\u6548\u7684 Agent \u4F1A\u8BDD"), { code: "forbidden" }); + } + const result = await assetService.deleteAsset(userId, assetId); + return { + assetId, + userId, + ...result + }; + } + }; +} + // mindspace-page-edit-session.mjs import { Agent as Agent2, fetch as undiciFetch2 } from "undici"; @@ -20530,7 +20732,8 @@ async function prepareHtmlPublishContent({ html, ownerSlug, urlSlug, - htmlRelativePath = `public/${urlSlug}.html`, + htmlRelativePath = null, + pageTitle = null, absoluteStoragePath: absoluteStoragePath2, imgproxySigner = null, h5Root = null @@ -20547,11 +20750,16 @@ async function prepareHtmlPublishContent({ absoluteStoragePath: absoluteStoragePath2, imgproxySigner }); - publishContent = rewriteWorkspacePublicAssetReferences(publishContent, htmlRelativePath); const publishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null; + const resolvedHtmlRelativePath = htmlRelativePath ?? inferWorkspaceHtmlRelativePath({ + pageTitle, + htmlContent: publishContent, + publishDir + }); + publishContent = rewriteWorkspacePublicAssetReferences(publishContent, resolvedHtmlRelativePath); const linkIndex = await loadWorkspaceDownloadLinkIndex(pool2, userId); publishContent = rewriteRelativeDownloadLinks(publishContent, { - htmlRelativePath, + htmlRelativePath: resolvedHtmlRelativePath, publishDir, linkIndex, publishKey: userId, @@ -20629,7 +20837,8 @@ function createPublicationService(pool2, options = {}) { const [rows] = await pool2.query( `SELECT p.id AS page_id, p.title, p.summary, p.page_type, p.template_id, p.current_version_id, p.user_id, p.space_id, pv.id AS page_version_id, pv.version_no, - pv.bundle_asset_id, av.storage_key + pv.bundle_asset_id, av.storage_key, + JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) AS source_relative_path FROM h5_page_records p JOIN h5_page_versions pv ON pv.page_id = p.id JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1 @@ -20660,12 +20869,20 @@ function createPublicationService(pool2, options = {}) { const preparePublishContent = async (page, ownerSlug, urlSlug) => { let publishContent = page.content; if (page.page_type !== "html") return publishContent; + const publishDir = h5Root ? resolvePublishDir(h5Root, { id: page.user_id }) : null; return prepareHtmlPublishContent({ pool: pool2, userId: page.user_id, html: publishContent, ownerSlug, urlSlug, + pageTitle: page.title, + htmlRelativePath: inferWorkspaceHtmlRelativePath({ + snapshotRelativePath: page.source_relative_path, + pageTitle: page.title, + htmlContent: publishContent, + publishDir + }), absoluteStoragePath: absoluteStoragePath2, h5Root, imgproxySigner: imgproxySigner ? { buildUrl: (path31, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path31, preset) } : null @@ -21117,16 +21334,37 @@ ${publishContent}`, { now ] ); + const html = await fs18.readFile(await resolveReadableStoragePath(row.storage_key), "utf8"); return { - html: await fs18.readFile(await resolveReadableStoragePath(row.storage_key), "utf8"), + html: await refreshPublishedHtmlDownloadLinks(row, html), publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }) }; }; + const refreshPublishedHtmlDownloadLinks = async (row, html) => { + if (!h5Root || !row?.owner_id) return html; + const source = String(html ?? ""); + if (!source.trim()) return html; + const publishDir = resolvePublishDir(h5Root, { id: row.owner_id }); + const linkIndex = await loadWorkspaceDownloadLinkIndex(pool2, row.owner_id); + return rewriteRelativeDownloadLinks(html, { + htmlRelativePath: inferWorkspaceHtmlRelativePath({ + pageTitle: row.title, + htmlContent: html, + publishDir + }), + publishDir, + linkIndex, + publishKey: row.owner_id, + publicBaseUrl: resolvePublicBaseUrl(), + preferAssetDownload: false + }).html; + }; const resolvePublic = async (ownerSlug, urlSlug, viewerId, password, requestMeta) => { const [rows] = await pool2.query( - `SELECT pr.*, u.id AS owner_id, av.storage_key + `SELECT pr.*, u.id AS owner_id, p.title, av.storage_key FROM h5_publish_records pr JOIN h5_users u ON u.id = pr.user_id + JOIN h5_page_records p ON p.id = pr.page_id JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1 JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1 WHERE COALESCE(u.slug, u.username) = ? AND pr.url_slug = ? AND pr.status = 'online' @@ -21139,9 +21377,10 @@ ${publishContent}`, { const resolvePrivateLink = async (token, viewerId, requestMeta) => { const tokenHash = crypto14.createHash("sha256").update(String(token)).digest("hex"); const [rows] = await pool2.query( - `SELECT pr.*, u.id AS owner_id, av.storage_key + `SELECT pr.*, u.id AS owner_id, p.title, av.storage_key FROM h5_publish_records pr JOIN h5_users u ON u.id = pr.user_id + JOIN h5_page_records p ON p.id = pr.page_id JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1 JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1 WHERE pr.token_hash = ? AND pr.status = 'online' AND pr.access_mode = 'private_link' @@ -26514,11 +26753,7 @@ var PUBLIC_HTML_SKIP_DIRS = /* @__PURE__ */ new Set([ "node_modules" ]); function normalizePublicHtmlPath(relativePath) { - const parts = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== ".."); - if (parts.length === 0) return ""; - if (parts[0].toLowerCase() === "public") return parts.join("/"); - if (parts.length === 1 && parts[0].toLowerCase().endsWith(".html")) return `public/${parts[0]}`; - return parts.join("/"); + return normalizeWorkspaceRelativePath2(relativePath); } function titleFromRelativePath(relativePath) { const basename = path27.basename(String(relativePath ?? ""), ".html"); @@ -31442,7 +31677,7 @@ function createScheduleService(pool2, options = {}) { 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.status IN ('pending', 'locked', 'sent') AND r.remind_at >= ? AND r.remind_at < ? AND i.deleted_at IS NULL @@ -31469,6 +31704,48 @@ function createScheduleService(pool2, options = {}) { ); return rowToItem(rows[0]); }; + const getReminder = async ({ userId, reminderId }) => { + if (!userId || !reminderId) throw new Error("\u7F3A\u5C11\u63D0\u9192\u53C2\u6570"); + 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.id = ? AND r.user_id = ? AND i.deleted_at IS NULL + LIMIT 1`, + [reminderId, userId] + ); + return rowToReminderWithItem(rows[0]); + }; + const cancelReminder = async ({ userId, reminderId, reason = "\u7528\u6237\u5FFD\u7565" } = {}) => { + const reminder = await getReminder({ userId, reminderId }); + if (!reminder) throw new Error("\u63D0\u9192\u4E0D\u5B58\u5728\u6216\u65E0\u6743\u8BBF\u95EE"); + if (reminder.status === "cancelled") return reminder; + if (reminder.status === "sent") throw new Error("\u5DF2\u901A\u77E5\u7684\u63D0\u9192\u4E0D\u80FD\u5FFD\u7565"); + return markReminderCancelled(reminder, reason); + }; + const deleteReminder = async ({ userId, reminderId }) => { + if (!userId || !reminderId) throw new Error("\u7F3A\u5C11\u63D0\u9192\u53C2\u6570"); + const [result] = await pool2.query( + `DELETE FROM h5_schedule_reminders WHERE id = ? AND user_id = ?`, + [reminderId, userId] + ); + return Number(result?.affectedRows ?? 0) > 0; + }; + const deleteReminders = async ({ userId, reminderIds }) => { + if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); + const ids = [...new Set((reminderIds ?? []).map((id) => String(id ?? "").trim()).filter(Boolean))]; + if (ids.length === 0) return 0; + const [result] = await pool2.query( + `DELETE FROM h5_schedule_reminders WHERE user_id = ? AND id IN (${ids.map(() => "?").join(", ")})`, + [userId, ...ids] + ); + return Number(result?.affectedRows ?? 0); + }; const createReminder = async ({ userId, itemId, @@ -32016,6 +32293,10 @@ function createScheduleService(pool2, options = {}) { listTodayTodoItems, listUpcomingItems, listUpcomingReminders, + getReminder, + cancelReminder, + deleteReminder, + deleteReminders, listDigestSubscriptions, createDailyTodoDigest, createBalanceLowAlert, @@ -33475,6 +33756,7 @@ var mindSpaceAssets = null; var mindSpaceAudit = null; var mindSpacePages = null; var mindSpacePageLiveEdit = null; +var mindSpaceAssetAgent = null; var mindSpacePageEditSession = null; var mindSpacePublications = null; var plazaPosts = null; @@ -33527,15 +33809,20 @@ async function bootstrapUserAuth() { h5Root: __dirname5, storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path30.join(__dirname5, "data", "mindspace") }); + const resolveUserIdForAgentSession = async (sessionId) => { + const [rows] = await pool2.query( + `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, + [sessionId] + ); + return rows[0]?.user_id ?? null; + }; mindSpacePageLiveEdit = createPageLiveEditService({ pageService: mindSpacePages, - resolveUserIdForAgentSession: async (sessionId) => { - const [rows] = await pool2.query( - `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, - [sessionId] - ); - return rows[0]?.user_id ?? null; - } + resolveUserIdForAgentSession + }); + mindSpaceAssetAgent = createAssetAgentService({ + assetService: mindSpaceAssets, + resolveUserIdForAgentSession }); mindSpacePublications = createPublicationService(pool2, { h5Root: __dirname5, @@ -34805,6 +35092,7 @@ api.use(async (req, res, next) => { if (req.path === "/status") return next(); if (req.path.startsWith("/internal/agent/")) return next(); if (req.path === "/agent/mindspace_page_patch") return next(); + if (req.path === "/agent/mindspace_asset_delete") return next(); if (req.path === "/config/blocked-words") return next(); if (req.method === "GET" && /^\/mindspace\/v1\/assets\/[^/]+\/download$/.test(req.path)) { return next(); @@ -34953,6 +35241,37 @@ api.get("/mindspace/v1/space", async (req, res) => { if (!space) return sendError(res, req, 404, "resource_not_found", "\u7528\u6237\u7A7A\u95F4\u4E0D\u5B58\u5728"); return sendData(res, req, space); }); +api.post("/mindspace/v1/schedule/reminders/:reminderId/ignore", async (req, res) => { + if (!ensureMindSpaceEnabled(res, req) || !scheduleService) { + return sendError(res, req, 503, "feature_disabled", "\u65E5\u7A0B\u670D\u52A1\u672A\u542F\u7528"); + } + try { + const reminder = await scheduleService.cancelReminder({ + userId: req.currentUser.id, + reminderId: req.params.reminderId + }); + return sendData(res, req, reminder); + } catch (err) { + const message = err instanceof Error ? err.message : "\u5FFD\u7565\u63D0\u9192\u5931\u8D25"; + return sendError(res, req, 400, "invalid_schedule_input", message); + } +}); +api.post("/mindspace/v1/schedule/reminders/bulk-delete", async (req, res) => { + if (!ensureMindSpaceEnabled(res, req) || !scheduleService) { + return sendError(res, req, 503, "feature_disabled", "\u65E5\u7A0B\u670D\u52A1\u672A\u542F\u7528"); + } + try { + const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String) : []; + const deleted = await scheduleService.deleteReminders({ + userId: req.currentUser.id, + reminderIds: ids + }); + return sendData(res, req, { deleted }); + } catch (err) { + const message = err instanceof Error ? err.message : "\u5220\u9664\u63D0\u9192\u5931\u8D25"; + return sendError(res, req, 400, "invalid_schedule_input", message); + } +}); api.get("/mindspace/v1/space/quota", async (req, res) => { if (!mindSpace) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); const quota = await mindSpace.getQuota(req.currentUser.id); @@ -35586,8 +35905,24 @@ api.post("/mindspace/v1/pages/from-asset", async (req, res) => { req.currentUser.id, assetId ); + if (asset.mimeType === "text/html") { + const relativePath = normalizeWorkspaceRelativePath2( + String(asset.originalFilename ?? asset.original_filename ?? "").includes("/") ? asset.originalFilename ?? asset.original_filename : `public/${asset.originalFilename ?? asset.original_filename ?? ""}` + ); + const existingByPath = relativePath ? await mindSpacePages.findPageByRelativePath(req.currentUser.id, relativePath).catch(() => null) : null; + if (existingByPath) { + return sendData(res, req, { + kind: "page", + categoryCode: existingByPath.categoryCode ?? "draft", + page: existingByPath + }); + } + } const content = await fs28.promises.readFile(assetPath, "utf8"); const contentFormat = asset.mimeType === "text/html" ? "html" : "markdown"; + const htmlRelativePath = contentFormat === "html" ? normalizeWorkspaceRelativePath2( + String(asset.originalFilename ?? asset.original_filename ?? "").includes("/") ? asset.originalFilename ?? asset.original_filename : `public/${asset.originalFilename ?? asset.original_filename ?? ""}` + ) : null; const page = await mindSpacePages.createFromChat( req.currentUser.id, { @@ -35603,7 +35938,8 @@ api.post("/mindspace/v1/pages/from-asset", async (req, res) => { snapshot: { source_asset_id: asset.id, source_category: asset.categoryCode, - content_mode: contentFormat + content_mode: contentFormat, + ...htmlRelativePath ? { relative_path: htmlRelativePath } : {} } } ); @@ -35659,6 +35995,14 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) { } return { session, message, content }; } +async function resolveExistingSavedPage(userId, { sessionId, messageId, relativePath } = {}) { + if (!mindSpacePages || !userId) return null; + const byMessage = await mindSpacePages.findPageBySourceMessage(userId, sessionId, messageId).catch(() => null); + if (byMessage) return byMessage; + const normalizedPath = normalizeWorkspaceRelativePath2(relativePath); + if (!normalizedPath) return null; + return mindSpacePages.findPageByRelativePath(userId, normalizedPath).catch(() => null); +} var SAVE_TARGET_CATEGORIES = /* @__PURE__ */ new Set(["draft", "oa", "public"]); var pageSyncInFlight = /* @__PURE__ */ new Map(); async function syncUserGeneratedPages(userId) { @@ -35837,11 +36181,11 @@ api.post("/mindspace/v1/pages/analyze-chat-save", async (req, res) => { thumbnailReady = false; } } - const existingPage = await mindSpacePages.findPageBySourceMessage( - req.currentUser.id, + const existingPage = await resolveExistingSavedPage(req.currentUser.id, { sessionId, - messageId - ).catch(() => null); + messageId, + relativePath: resolvedHtml?.relativePath ?? analysis.relativePath + }); return sendData(res, req, { contentMode: analysis.contentMode, links: analysis.links, @@ -35923,7 +36267,7 @@ api.post("/mindspace/v1/pages/save-from-chat", async (req, res) => { role: source.message.role, content_mode: analysis.contentMode, public_url: analysis.previewUrl, - relative_path: analysis.relativePath + relative_path: analysis.relativePath ? normalizeWorkspaceRelativePath2(analysis.relativePath) : analysis.relativePath }; let resolvedHtml = null; if (analysis.contentMode === "static_html") { @@ -35994,7 +36338,12 @@ api.post("/mindspace/v1/pages/save-from-chat", async (req, res) => { }).catch(() => { }); } - const replacePageId = req.body?.replace_page_id ? String(req.body.replace_page_id).trim() : null; + const saveAsNew = Boolean(req.body?.save_as_new); + let replacePageId = req.body?.replace_page_id ? String(req.body.replace_page_id).trim() : null; + if (!replacePageId && !saveAsNew && analysis.contentMode === "static_html" && analysis.relativePath) { + const existingByPath = await mindSpacePages.findPageByRelativePath(req.currentUser.id, analysis.relativePath).catch(() => null); + if (existingByPath) replacePageId = existingByPath.id; + } let page; if (replacePageId) { const existingPage = await mindSpacePages.getPage(req.currentUser.id, replacePageId); @@ -36253,6 +36602,31 @@ api.post("/agent/mindspace_page_patch", async (req, res) => { return mindSpaceError(res, req, error); } }); +api.post("/agent/mindspace_asset_delete", async (req, res) => { + if (!mindSpaceAssetAgent) { + return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); + } + try { + const result = await mindSpaceAssetAgent.applyAgentDelete(req.body ?? {}); + await mindSpaceAudit?.write({ + userId: result.userId ?? null, + action: "asset.delete", + objectType: "asset", + objectId: result.assetId, + ip: req.ip, + metadata: { via: "agent" } + }); + return sendData(res, req, result); + } catch (error) { + if (error?.code === "forbidden") { + return sendError(res, req, 403, error.code, error.message); + } + if (error?.code === "invalid_request" || error?.code === "confirmation_required") { + return sendError(res, req, 400, error.code, error.message); + } + return mindSpaceError(res, req, error); + } +}); api.get("/mindspace/v1/pages/:pageId/thumbnail", async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); try { @@ -36310,6 +36684,23 @@ api.post("/mindspace/v1/pages/:pageId/thumbnail/regenerate", async (req, res) => return mindSpaceError(res, req, error); } }); +api.post("/mindspace/v1/pages/:pageId/rewrite-download-links", async (req, res) => { + if (!mindSpacePages) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); + try { + const content = String(req.body?.content ?? "").trim(); + if (!content) { + throw Object.assign(new Error("\u7F3A\u5C11\u9875\u9762\u5185\u5BB9"), { code: "invalid_page_input" }); + } + const html = await mindSpacePages.rewriteHtmlDownloadLinksForPage( + req.currentUser.id, + req.params.pageId, + content + ); + return sendData(res, req, { html }); + } catch (error) { + return mindSpaceError(res, req, error); + } +}); api.get("/mindspace/v1/pages/:pageId/preview", async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); try { diff --git a/.runtime/portal/skills/static-page-publish/SKILL.md b/.runtime/portal/skills/static-page-publish/SKILL.md index 4612f47..a6ba391 100644 --- a/.runtime/portal/skills/static-page-publish/SKILL.md +++ b/.runtime/portal/skills/static-page-publish/SKILL.md @@ -78,6 +78,16 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 本地对比示例:`node scripts/thumbnail-preview-demo.mjs` → `/thumbnail-demo/` +## 平台页脚标记(必须) + +页脚平台联系行**必须**使用 `data-mindspace-page-tag="platform-brand"`,且邮箱/域名只用 **tkmind.cn**(如 `contact@tkmind.cn`),**禁止** `tkmind.ai`: + +```html +

📧 contact@tkmind.cn

+``` + +带 `data-mindspace-page-tag` 的区域为平台固定信息:用户在编辑模式中不可见、不可改;预览与发布后正常显示。 + ## 附带文件下载(Word / PDF) - 二进制文件用 `docx-generate` 脚本或平台允许的方式**单独生成**,保存到 `public/`(或 `oa/` 再复制到 `public/`) diff --git a/conversation-display.mjs b/conversation-display.mjs index 5e0663a..06a27b1 100644 --- a/conversation-display.mjs +++ b/conversation-display.mjs @@ -25,6 +25,36 @@ export function stripMindSpaceContextPrefix(text) { return next; } +const ASSISTANT_DELIVERABLE_RE = + /\[[^\]]+\]\(https?:\/\/[^)]+\)|https?:\/\/(?:m\.)?[^/\s]*tkmind\.(?:cn|ai)\/MindSpace\//i; + +const INTERNAL_ASSISTANT_MARKERS = [ + /data-mindspace-page-tag/i, + /mindspace-cover/i, + /\bload_skill\b/i, + /static-page-publish/i, + /\.agents\/skills/i, + /SKILL\.md/i, + /注意到技能/u, + /技能更新/u, + /页脚要用.*platform-brand/u, +]; + +/** Agent-only process narration that should not appear in the chat UI. */ +export function isInternalAssistantProcessNarration(text) { + const trimmed = String(text ?? '').trim(); + if (!trimmed) return false; + if (ASSISTANT_DELIVERABLE_RE.test(trimmed)) return false; + return INTERNAL_ASSISTANT_MARKERS.some((pattern) => pattern.test(trimmed)); +} + +export function deriveAssistantFacingText(text) { + const trimmed = String(text ?? '').trim(); + if (!trimmed) return ''; + if (isInternalAssistantProcessNarration(trimmed)) return ''; + return trimmed; +} + /** Strip agent-only prefixes from persisted user message content for UI display. */ export function deriveUserFacingText(text) { let next = String(text ?? ''); diff --git a/conversation-display.test.mjs b/conversation-display.test.mjs index c07d924..e8b46a5 100644 --- a/conversation-display.test.mjs +++ b/conversation-display.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { buildAutoChatSkillPrefix, stripKnownChatSkillPrompt } from './chat-skills.mjs'; -import { deriveUserFacingText } from './conversation-display.mjs'; +import { deriveUserFacingText, deriveAssistantFacingText } from './conversation-display.mjs'; test('stripKnownChatSkillPrompt removes generate-page preface', () => { const userText = '帮我根据这些图片生成一个主题页面,页面要做得精美一点'; @@ -24,3 +24,15 @@ test('deriveUserFacingText removes routing hint and skill preface from agent pay ].join('\n'); assert.equal(deriveUserFacingText(agentPayload), userText); }); + +test('deriveAssistantFacingText hides skill/process narration without a deliverable link', () => { + const internal = + '好的,John!注意到技能更新了几个细节要求,比如页脚要用`data-mindspace-page-tag="platform-brand"`和`tkmind.cn`。我来为你重新生成全面增强版的AI机器人研究报告页面:'; + assert.equal(deriveAssistantFacingText(internal), ''); +}); + +test('deriveAssistantFacingText keeps user-facing replies with public links', () => { + const external = + '页面已生成:[AI 机器人研究报告 2026](https://m.tkmind.cn/MindSpace/john/public/ai-robot-report.html)'; + assert.equal(deriveAssistantFacingText(external), external); +}); diff --git a/mindspace-asset-agent.mjs b/mindspace-asset-agent.mjs new file mode 100644 index 0000000..3599b89 --- /dev/null +++ b/mindspace-asset-agent.mjs @@ -0,0 +1,58 @@ +export function buildSelectedAssetDeleteInstructions(context) { + const selected = context.selectedAssets ?? []; + if (selected.length !== 1) return []; + + const asset = selected[0]; + const h5ApiBase = String(context.h5ApiBase ?? '').replace(/\/$/, ''); + const sessionId = String(context.agentSessionId ?? '').trim(); + if (!h5ApiBase) return []; + + const sessionToken = sessionId || ''; + const sessionHint = sessionId + ? '' + : '- session_id 未写入上下文时,使用当前 Agent 会话 ID 替换 ``。\n'; + + return [ + '【勾选资料删除(硬性)】', + `- 你对用户 MindSpace 拥有完整读写删改权限;用户已勾选「${asset.displayName}」(id: ${asset.id})。`, + '- 用户要求删除这份资料时:先说明将删除哪一份并等待用户明确确认(如「确定」「删吧」「确认删除」);**禁止**未确认就删。', + sessionHint, + '- 用户确认后,**必须立即**用 shell 执行(不要只口头说已删,也不要让用户自己去界面点删除):', + '```bash', + `curl -sS -X POST '${h5ApiBase}/api/agent/mindspace_asset_delete' \\`, + " -H 'Content-Type: application/json' \\", + ` -d '{"session_id":"${sessionToken}","asset_id":"${asset.id}","confirmed":true}'`, + '```', + '- curl 成功后再回复「已删除」;若返回 asset_in_use,说明被页面引用,告知用户先删关联页面。', + '- 禁止删除未勾选的其它文件。', + '', + ].filter(Boolean); +} + +export function createAssetAgentService({ assetService, resolveUserIdForAgentSession }) { + return { + async applyAgentDelete(input = {}) { + const sessionId = String(input?.sessionId ?? input?.session_id ?? '').trim(); + const assetId = String(input?.assetId ?? input?.asset_id ?? '').trim(); + const confirmed = input?.confirmed === true || String(input?.confirmed ?? '').toLowerCase() === 'true'; + if (!sessionId || !assetId) { + throw Object.assign(new Error('缺少 session_id 或 asset_id'), { code: 'invalid_request' }); + } + if (!confirmed) { + throw Object.assign(new Error('删除前须已在对话中获得用户确认'), { code: 'confirmation_required' }); + } + + const userId = await resolveUserIdForAgentSession(sessionId); + if (!userId) { + throw Object.assign(new Error('无效的 Agent 会话'), { code: 'forbidden' }); + } + + const result = await assetService.deleteAsset(userId, assetId); + return { + assetId, + userId, + ...result, + }; + }, + }; +} diff --git a/mindspace-asset-agent.test.mjs b/mindspace-asset-agent.test.mjs new file mode 100644 index 0000000..b90d554 --- /dev/null +++ b/mindspace-asset-agent.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildSelectedAssetDeleteInstructions, + createAssetAgentService, +} from './mindspace-asset-agent.mjs'; + +test('buildSelectedAssetDeleteInstructions includes agent delete curl', () => { + const lines = buildSelectedAssetDeleteInstructions({ + selectedAssets: [ + { + id: 'asset-9', + displayName: 'sales.xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }, + ], + h5ApiBase: 'http://127.0.0.1:8081', + agentSessionId: 'session-1', + }); + const text = lines.join('\n'); + assert.match(text, /完整读写删改权限/); + assert.match(text, /mindspace_asset_delete/); + assert.match(text, /asset-9/); + assert.match(text, /session-1/); + assert.match(text, /不要让用户自己去界面点删除/); +}); + +test('applyAgentDelete requires confirmation and valid session', async () => { + const service = createAssetAgentService({ + assetService: { + deleteAsset: async () => ({ deleted: true }), + }, + resolveUserIdForAgentSession: async (sessionId) => + sessionId === 'session-1' ? 'user-1' : null, + }); + + await assert.rejects( + () => service.applyAgentDelete({ session_id: 'session-1', asset_id: 'asset-9' }), + (error) => error.code === 'confirmation_required', + ); + + const result = await service.applyAgentDelete({ + session_id: 'session-1', + asset_id: 'asset-9', + confirmed: true, + }); + assert.equal(result.deleted, true); + assert.equal(result.userId, 'user-1'); +}); diff --git a/mindspace-chat-context.mjs b/mindspace-chat-context.mjs index 8a1f13b..54a3819 100644 --- a/mindspace-chat-context.mjs +++ b/mindspace-chat-context.mjs @@ -1,4 +1,5 @@ import { buildMindSpacePageSaveInstructions } from './mindspace-page-patch.mjs'; +import { buildSelectedAssetDeleteInstructions } from './mindspace-asset-agent.mjs'; const VIEW_LABELS = { home: '空间首页', @@ -107,7 +108,7 @@ function buildSelectedAssetInstructions(context) { `- 用户在界面勾选了 1 份${kind}:「${asset.displayName}」(id: ${asset.id},${asset.mimeType})`, '- 用户说「这个 / 这份 / 这篇 / 这份报告 / 这个数据 / 帮我分析 / 帮我删除」等指代词时,**必须**指这份勾选资料,不得猜测其它文件。', `- 读取/下载:GET /api/mindspace/v1/assets/${asset.id}/download`, - `- 删除:DELETE /api/mindspace/v1/assets/${asset.id}(执行删除前必须向用户确认)`, + '- 删除:须先与用户确认,确认后由你直接执行删除(见下方「勾选资料删除」);你对该用户空间有完整读写删改权限,**禁止**推给用户自行去界面删除。', ); if (context.h5ApiBase) { lines.push(`- H5 API 基址:${context.h5ApiBase}`); @@ -129,7 +130,7 @@ function buildSelectedAssetInstructions(context) { export function buildSelectedAssetGreeting(asset) { const kind = inferAssetKindLabel(asset); const name = asset.displayName ?? asset.filename ?? '这份资料'; - return `我看到你在界面里勾选了「${name}」。你是想查看或处理这份${kind}吗?可以直接说「帮我分析」「总结一下」或「帮我删除」——我会针对这份资料来操作。`; + return `我看到你在界面里勾选了「${name}」。你是想查看或处理这份${kind}吗?可以直接说「帮我分析」「总结一下」或「帮我删除」——确认后我可以直接在这份${kind}上操作(含删除)。`; } export function buildMindSpaceChatContext(input) { @@ -362,6 +363,7 @@ export function buildContextPrefix(context) { } lines.push(...buildSelectedAssetInstructions(context)); + lines.push(...buildSelectedAssetDeleteInstructions(context)); if (context.homeCategories?.length) { lines.push('- 空间分类概览:'); diff --git a/mindspace-chat-context.test.mjs b/mindspace-chat-context.test.mjs index 1648f8d..ff4c9d3 100644 --- a/mindspace-chat-context.test.mjs +++ b/mindspace-chat-context.test.mjs @@ -214,15 +214,39 @@ test('buildContextPrefix binds deictic references to a single checked asset', () selectedAssets: [asset], route: '/space?category=oa', h5ApiBase: 'http://127.0.0.1:8081', + agentSessionId: 'session-1', }), ); assert.match(prefix, /【界面勾选资料(硬性)】/); assert.match(prefix, /sales\.xlsx(id: asset-9/); - assert.match(prefix, /DELETE \/api\/mindspace\/v1\/assets\/asset-9/); + assert.match(prefix, /【勾选资料删除(硬性)】/); + assert.match(prefix, /mindspace_asset_delete/); + assert.match(prefix, /完整读写删改权限/); assert.match(prefix, /指代「这个文件」「这份报告」「这个数据」时\*\*必须\*\*指该勾选资料/); }); +test('buildContextPrefix includes delete fallback when agent session id is missing', () => { + const asset = { + id: 'asset-9', + displayName: 'sales.xlsx', + mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }; + const prefix = buildContextPrefix( + buildMindSpaceChatContext({ + space: sampleSpace, + selectedCategory: sampleCategory, + selectedPageId: null, + pages: [], + assets: [asset], + selectedAssets: [asset], + route: '/space?category=oa', + h5ApiBase: 'http://127.0.0.1:8081', + }), + ); + assert.match(prefix, /CURRENT_AGENT_SESSION/); +}); + test('buildContextPrefix requires clarification when multiple assets are checked', () => { const assets = [ { id: 'asset-a', displayName: 'a.pdf', mimeType: 'application/pdf' }, diff --git a/mindspace-html-download-links.mjs b/mindspace-html-download-links.mjs index fee7187..817c3ee 100644 --- a/mindspace-html-download-links.mjs +++ b/mindspace-html-download-links.mjs @@ -34,6 +34,57 @@ export function resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef) return path.posix.join(htmlDir, ref).replace(/\\/g, '/'); } +export function resolveSiblingDocxPath(htmlRelativePath) { + const htmlPath = String(htmlRelativePath ?? '').replace(/^\/+/, ''); + if (!htmlPath.toLowerCase().endsWith('.html')) return null; + return htmlPath.replace(/\.html$/i, '.docx'); +} + +function resolveExistingSiblingDocxPath(htmlRelativePath, publishDir) { + const sibling = resolveSiblingDocxPath(htmlRelativePath); + if (!sibling || !publishDir) return null; + return workspaceFileExists(publishDir, sibling) ? sibling : null; +} + +export function inferWorkspaceHtmlRelativePath({ + snapshotRelativePath = null, + pageTitle = null, + htmlContent = null, + publishDir = null, +} = {}) { + const fromSnapshot = String(snapshotRelativePath ?? '').trim(); + if (fromSnapshot) { + const normalized = fromSnapshot.replace(/^\/+/, ''); + if (normalized.toLowerCase().startsWith('public/')) return normalized; + if (normalized.toLowerCase().endsWith('.html')) return `public/${path.posix.basename(normalized)}`; + return normalized; + } + + const publicDir = publishDir ? path.join(publishDir, 'public') : null; + if (!publicDir || !fs.existsSync(publicDir) || !htmlContent) { + return 'public/index.html'; + } + + const normalizedContent = String(htmlContent); + const title = String(pageTitle ?? '').trim(); + for (const name of fs.readdirSync(publicDir)) { + if (!name.toLowerCase().endsWith('.html')) continue; + const absolutePath = path.join(publicDir, name); + try { + const diskContent = fs.readFileSync(absolutePath, 'utf8'); + if (diskContent === normalizedContent) return `public/${name}`; + if (title) { + const titleMatch = diskContent.match(/]*>([^<]+)<\/title>/i); + if (titleMatch?.[1]?.trim() === title) return `public/${name}`; + } + } catch { + // ignore unreadable files + } + } + + return 'public/index.html'; +} + export function buildWorkspaceDownloadLinkIndex(assets = []) { const byBasename = new Map(); const byPath = new Map(); @@ -45,6 +96,8 @@ export function buildWorkspaceDownloadLinkIndex(assets = []) { byBasename.set(path.posix.basename(filename), id); if (filename.startsWith('public/')) { byPath.set(filename.slice('public/'.length), id); + } else if (!filename.includes('/')) { + byPath.set(`public/${filename}`, id); } } return { byBasename, byPath }; @@ -80,6 +133,20 @@ function workspaceFileExists(publishDir, workspaceRelativePath) { } } +function collectDownloadWorkspaceCandidates(htmlRelativePath, relativeRef, publishDir) { + const workspacePath = resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef); + const candidates = new Set([ + workspacePath, + path.posix.join('public', path.posix.basename(workspacePath)), + ]); + const siblingDocx = resolveSiblingDocxPath(htmlRelativePath); + if (siblingDocx) { + candidates.add(siblingDocx); + candidates.add(path.posix.join('public', path.posix.basename(siblingDocx))); + } + return [...candidates]; +} + export function resolveDownloadTargetUrl({ relativeRef, htmlRelativePath = 'public/index.html', @@ -90,27 +157,28 @@ export function resolveDownloadTargetUrl({ preferAssetDownload = true, }) { const { suffix } = splitReferenceParts(relativeRef); - const workspacePath = resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef); - const assetId = lookupAssetIdForWorkspacePath(linkIndex, workspacePath); - const publicCandidates = [ - workspacePath, - path.posix.join('public', path.posix.basename(workspacePath)), - ]; + const candidates = collectDownloadWorkspaceCandidates(htmlRelativePath, relativeRef, publishDir); - if (preferAssetDownload && assetId) { - return `${buildAssetDownloadUrl(assetId)}${suffix}`; + for (const candidate of candidates) { + const assetId = lookupAssetIdForWorkspacePath(linkIndex, candidate); + if (preferAssetDownload && assetId) { + return `${buildAssetDownloadUrl(assetId)}${suffix}`; + } } if (publishDir && publishKey) { - for (const candidate of publicCandidates) { + for (const candidate of candidates) { if (workspaceFileExists(publishDir, candidate)) { return `${buildPublicWorkspaceFileUrl(publicBaseUrl, publishKey, candidate)}${suffix}`; } } } - if (assetId) { - return `${buildAssetDownloadUrl(assetId)}${suffix}`; + for (const candidate of candidates) { + const assetId = lookupAssetIdForWorkspacePath(linkIndex, candidate); + if (assetId) { + return `${buildAssetDownloadUrl(assetId)}${suffix}`; + } } return null; @@ -150,6 +218,8 @@ export async function prepareHtmlDownloadLinks(pool, userId, html, options = {}) export const downloadLinkInternals = { isRelativeDownloadReference, resolveWorkspaceRelativeFilePath, + resolveSiblingDocxPath, + inferWorkspaceHtmlRelativePath, buildWorkspaceDownloadLinkIndex, lookupAssetIdForWorkspacePath, resolveDownloadTargetUrl, diff --git a/mindspace-html-download-links.test.mjs b/mindspace-html-download-links.test.mjs index 816e3b7..5318f75 100644 --- a/mindspace-html-download-links.test.mjs +++ b/mindspace-html-download-links.test.mjs @@ -1,4 +1,7 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; import { downloadLinkInternals } from './mindspace-html-download-links.mjs'; @@ -8,6 +11,7 @@ const { buildWorkspaceDownloadLinkIndex, resolveDownloadTargetUrl, rewriteRelativeDownloadLinks, + inferWorkspaceHtmlRelativePath, } = downloadLinkInternals; test('isRelativeDownloadReference accepts sibling docx links only', () => { @@ -31,17 +35,38 @@ test('resolveWorkspaceRelativeFilePath resolves from public html directory', () test('resolveDownloadTargetUrl prefers asset download API in preview mode', () => { const linkIndex = buildWorkspaceDownloadLinkIndex([ - { id: 'asset-1', original_filename: 'public/半导体行业趋势分析报告.docx' }, + { id: 'asset-1', original_filename: 'ai-robot-report.docx' }, ]); const url = resolveDownloadTargetUrl({ - relativeRef: '半导体行业趋势分析报告.docx', - htmlRelativePath: 'public/index.html', + relativeRef: 'AI机器人研究报告.docx', + htmlRelativePath: 'public/ai-robot-report.html', linkIndex, preferAssetDownload: true, }); assert.equal(url, '/api/mindspace/v1/assets/asset-1/download'); }); +test('resolveDownloadTargetUrl falls back to sibling docx beside html page', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'download-links-')); + const publicDir = path.join(publishDir, 'public'); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(publicDir, 'ai-robot-report.html'), ''); + fs.writeFileSync(path.join(publicDir, 'ai-robot-report.docx'), 'docx'); + try { + const url = resolveDownloadTargetUrl({ + relativeRef: 'AI机器人研究报告.docx', + htmlRelativePath: 'public/ai-robot-report.html', + publishDir, + publishKey: 'user-1', + publicBaseUrl: 'https://m.tkmind.cn', + preferAssetDownload: false, + }); + assert.match(url, /MindSpace\/user-1\/public\/ai-robot-report\.docx$/); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + test('rewriteRelativeDownloadLinks rewrites href and src download attributes', () => { const html = [ '下载', @@ -59,3 +84,26 @@ test('rewriteRelativeDownloadLinks rewrites href and src download attributes', ( assert.match(rewritten, /href="\/api\/mindspace\/v1\/assets\/asset-1\/download"/); assert.match(rewritten, /href="#summary"/); }); + +test('inferWorkspaceHtmlRelativePath matches workspace html by title', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'infer-html-path-')); + const publicDir = path.join(publishDir, 'public'); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync( + path.join(publicDir, 'ai-robot-report.html'), + 'AI 机器人研究报告 2026', + 'utf8', + ); + try { + assert.equal( + inferWorkspaceHtmlRelativePath({ + pageTitle: 'AI 机器人研究报告 2026', + htmlContent: 'AI 机器人研究报告 2026', + publishDir, + }), + 'public/ai-robot-report.html', + ); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); diff --git a/mindspace-og-tags.mjs b/mindspace-og-tags.mjs index 0e6677c..3faba0f 100644 --- a/mindspace-og-tags.mjs +++ b/mindspace-og-tags.mjs @@ -3,9 +3,13 @@ // // Source of truth for the cover is the page's own (mindspace-cover JSON / // / hero ), reused via extractCoverSignals. Authors who -// already wrote their own og:image are left untouched. +// already wrote their own og:image are left untouched; missing site_name / description / +// brand icon are still backfilled. import { extractCoverSignals } from './mindspace-thumbnails.mjs'; +export const PLATFORM_SITE_NAME = 'TKMind 智趣'; +export const PLATFORM_BRAND_ICON_PATH = '/brand/tkmind-icon.png'; + function rawTitleFromHtml(html) { // Keep the original verbatim (emoji included) for og:title. return String(html).match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? ''; @@ -45,48 +49,151 @@ function rawMetaContent(html, attr, name) { return match?.[1] || match?.[2] || ''; } +function hasMeta(html, attr, name) { + return new RegExp(`<meta[^>]+${attr}=["']${name}["']`, 'i').test(String(html)); +} + +function hasLinkRel(html, rel) { + return new RegExp(`<link[^>]+rel=["'][^"']*\\b${rel}\\b[^"']*["']`, 'i').test(String(html)); +} + function escapeScriptString(value) { return JSON.stringify(String(value ?? '')).replace(/</g, '\\u003c'); } +function appendBeforeHeadClose(html, block) { + const source = String(html); + if (!block) return source; + if (/<\/head>/i.test(source)) { + return source.replace(/<\/head>/i, `${block}\n</head>`); + } + if (/<head[^>]*>/i.test(source)) { + return source.replace(/(<head[^>]*>)/i, `$1${block}`); + } + return `${block}\n${source}`; +} + +function resolveShareDescription(html, { siteName = PLATFORM_SITE_NAME, meta = {} } = {}) { + return ( + rawMetaContent(html, 'property', 'og:description') || + rawMetaContent(html, 'name', 'description') || + extractCoverSignals(html, meta).subtitle || + `${siteName} · 精选作品` + ); +} + +function resolveShareImageUrl(html, { origin, pageDirUrl, fallbackImageUrl = '', meta = {} } = {}) { + const existing = rawMetaContent(html, 'property', 'og:image'); + if (existing) return existing; + const signals = extractCoverSignals(html, meta); + return resolveImageUrl(signals.image, { origin, pageDirUrl }) || fallbackImageUrl || ''; +} + +/** + * Extract the share-card fields WeChat / IM clients consume from HTML. + */ +export function extractSharePreviewMeta( + html, + { + origin = '', + pageUrl = '', + pageDirUrl = '', + fallbackImageUrl = '', + siteName = PLATFORM_SITE_NAME, + brandIconUrl = '', + meta = {}, + } = {}, +) { + const source = String(html ?? ''); + const title = rawMetaContent(source, 'property', 'og:title') || rawTitleFromHtml(source) || 'MindSpace 页面'; + const description = resolveShareDescription(source, { siteName, meta }); + const imageUrl = resolveShareImageUrl(source, { origin, pageDirUrl, fallbackImageUrl, meta }); + const resolvedSiteName = rawMetaContent(source, 'property', 'og:site_name') || siteName; + const iconUrl = brandIconUrl || (origin ? `${origin}${PLATFORM_BRAND_ICON_PATH}` : PLATFORM_BRAND_ICON_PATH); + return { + title, + description, + imageUrl, + siteName: resolvedSiteName, + iconUrl, + pageUrl, + }; +} + /** * Inject og:/twitter: meta into an HTML document. * @param {string} html raw page HTML - * @param {{ origin: string, pageUrl: string, pageDirUrl: string, fallbackImageUrl?: string }} ctx - * origin: https://host pageUrl: canonical page URL pageDirUrl: page directory URL (trailing '/') - * fallbackImageUrl: absolute raster URL to use when the page has no cover of its own + * @param {{ origin: string, pageUrl: string, pageDirUrl: string, fallbackImageUrl?: string, siteName?: string, brandIconUrl?: string, meta?: object }} ctx * @returns {string} HTML with tags injected (or unchanged when not applicable) */ -export function injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl = '' }) { +export function injectOgTags( + html, + { + origin, + pageUrl, + pageDirUrl, + fallbackImageUrl = '', + siteName = PLATFORM_SITE_NAME, + brandIconUrl = '', + meta = {}, + } = {}, +) { const source = String(html); - // Respect a page that already declares its own Open Graph image. - if (/<meta[^>]+property=["']og:image["']/i.test(source)) return source; + const preview = extractSharePreviewMeta(source, { + origin, + pageUrl, + pageDirUrl, + fallbackImageUrl, + siteName, + brandIconUrl, + meta, + }); - const signals = extractCoverSignals(source); - const title = rawTitleFromHtml(source) || signals.title; - const description = signals.subtitle || ''; - const imageUrl = resolveImageUrl(signals.image, { origin, pageDirUrl }) || fallbackImageUrl || null; - - const tags = [ - '<meta property="og:type" content="article">', - pageUrl ? `<meta property="og:url" content="${escapeAttr(pageUrl)}">` : '', - title ? `<meta property="og:title" content="${escapeAttr(title)}">` : '', - description ? `<meta property="og:description" content="${escapeAttr(description)}">` : '', - imageUrl ? `<meta property="og:image" content="${escapeAttr(imageUrl)}">` : '', - `<meta name="twitter:card" content="${imageUrl ? 'summary_large_image' : 'summary'}">`, - title ? `<meta name="twitter:title" content="${escapeAttr(title)}">` : '', - description ? `<meta name="twitter:description" content="${escapeAttr(description)}">` : '', - imageUrl ? `<meta name="twitter:image" content="${escapeAttr(imageUrl)}">` : '', - ].filter(Boolean); - - const block = `\n${tags.map((t) => ` ${t}`).join('\n')}\n`; - if (/<\/head>/i.test(source)) { - return source.replace(/<\/head>/i, `${block}</head>`); + const tags = []; + if (!hasMeta(source, 'property', 'og:type')) { + tags.push('<meta property="og:type" content="article">'); } - if (/<head[^>]*>/i.test(source)) { - return source.replace(/(<head[^>]*>)/i, `$1${block}`); + if (pageUrl && !hasMeta(source, 'property', 'og:url')) { + tags.push(`<meta property="og:url" content="${escapeAttr(pageUrl)}">`); } - return source; + if (preview.title && !hasMeta(source, 'property', 'og:title')) { + tags.push(`<meta property="og:title" content="${escapeAttr(preview.title)}">`); + } + if (preview.description && !hasMeta(source, 'property', 'og:description')) { + tags.push(`<meta property="og:description" content="${escapeAttr(preview.description)}">`); + } + if (preview.imageUrl && !hasMeta(source, 'property', 'og:image')) { + tags.push(`<meta property="og:image" content="${escapeAttr(preview.imageUrl)}">`); + } + if (preview.siteName && !hasMeta(source, 'property', 'og:site_name')) { + tags.push(`<meta property="og:site_name" content="${escapeAttr(preview.siteName)}">`); + } + + const twitterCard = preview.imageUrl ? 'summary_large_image' : 'summary'; + if (!hasMeta(source, 'name', 'twitter:card')) { + tags.push(`<meta name="twitter:card" content="${twitterCard}">`); + } + if (preview.title && !hasMeta(source, 'name', 'twitter:title')) { + tags.push(`<meta name="twitter:title" content="${escapeAttr(preview.title)}">`); + } + if (preview.description && !hasMeta(source, 'name', 'twitter:description')) { + tags.push(`<meta name="twitter:description" content="${escapeAttr(preview.description)}">`); + } + if (preview.imageUrl && !hasMeta(source, 'name', 'twitter:image')) { + tags.push(`<meta name="twitter:image" content="${escapeAttr(preview.imageUrl)}">`); + } + + const links = []; + if (preview.iconUrl && !hasLinkRel(source, 'icon')) { + links.push(`<link rel="icon" type="image/png" href="${escapeAttr(preview.iconUrl)}">`); + } + if (preview.iconUrl && !hasLinkRel(source, 'apple-touch-icon')) { + links.push(`<link rel="apple-touch-icon" href="${escapeAttr(preview.iconUrl)}">`); + } + + const block = [...tags, ...links].map((t) => ` ${t}`).join('\n'); + if (!block) return source; + return appendBeforeHeadClose(source, `\n${block}\n`); } export function injectWechatShareBridge( @@ -94,6 +201,7 @@ export function injectWechatShareBridge( { pageUrl, signatureEndpoint = '/auth/wechat/public-js-sdk-signature', + siteName = PLATFORM_SITE_NAME, } = {}, ) { const source = String(html ?? ''); @@ -101,10 +209,8 @@ export function injectWechatShareBridge( if (source.includes('data-tkmind-wechat-share="1"')) return source; const title = rawMetaContent(source, 'property', 'og:title') || rawTitleFromHtml(source); - const description = - rawMetaContent(source, 'property', 'og:description') || - rawMetaContent(source, 'name', 'description') || - ''; + const resolvedSiteName = rawMetaContent(source, 'property', 'og:site_name') || siteName; + const description = resolveShareDescription(source, { siteName: resolvedSiteName }); const imageUrl = rawMetaContent(source, 'property', 'og:image') || ''; const script = ` @@ -167,11 +273,79 @@ export function injectWechatShareBridge( })(); </script>`; - if (/<\/head>/i.test(source)) { - return source.replace(/<\/head>/i, `${script}\n</head>`); - } - if (/<head[^>]*>/i.test(source)) { - return source.replace(/(<head[^>]*>)/i, `$1${script}`); - } - return `${script}\n${source}`; + return appendBeforeHeadClose(source, script); +} + +/** + * Render a lightweight WeChat-style share card for local preview. + */ +export function renderWechatSharePreviewHtml(preview, { note = '' } = {}) { + const safe = (value) => + String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); + const title = safe(preview.title || '页面标题'); + const description = safe(preview.description || ''); + const siteName = safe(preview.siteName || PLATFORM_SITE_NAME); + const imageUrl = safe(preview.imageUrl || ''); + const iconUrl = safe(preview.iconUrl || PLATFORM_BRAND_ICON_PATH); + const pageUrl = safe(preview.pageUrl || ''); + const noteHtml = note ? `<p class="note">${safe(note)}</p>` : ''; + + return `<!doctype html> +<html lang="zh-CN"> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <title>微信分享预览 · ${title} + + + +
+

微信链接卡片预览

+

本地模拟的是「粘贴链接后看到的卡片」,不是 JS-SDK 内部分享弹层。${pageUrl ? `源链接:${pageUrl}` : ''}

+ ${noteHtml} +
+
+

${title}

+ ${description ? `

${description}

` : ''} +
+ + ${siteName} +
+
+ ${ + imageUrl + ? `` + : `
无图
` + } +
+
+
og:title ${title}
+
og:description ${description || '(空)'}
+
og:site_name ${siteName}
+
og:image ${imageUrl || '(空)'}
+
icon ${iconUrl}
+
+
+ +`; } diff --git a/mindspace-og-tags.test.mjs b/mindspace-og-tags.test.mjs index 23720d6..12758d1 100644 --- a/mindspace-og-tags.test.mjs +++ b/mindspace-og-tags.test.mjs @@ -1,6 +1,12 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { injectOgTags, injectWechatShareBridge } from './mindspace-og-tags.mjs'; +import { + extractSharePreviewMeta, + injectOgTags, + injectWechatShareBridge, + PLATFORM_SITE_NAME, + renderWechatSharePreviewHtml, +} from './mindspace-og-tags.mjs'; const ctx = { origin: 'https://m.tkmind.cn', @@ -16,8 +22,9 @@ test('injects og/twitter tags with absolute image from a relative cover', () => assert.match(out, //); assert.match(out, //); assert.match(out, //); + assert.match(out, //); + assert.match(out, //); assert.match(out, //); - // injected before assert.ok(out.indexOf('og:image') < out.indexOf('')); }); @@ -28,6 +35,7 @@ test('falls back to the thumbnail png when the page has no cover of its own', () fallbackImageUrl: 'https://m.tkmind.cn/MindSpace/john/public/space.thumbnail.png', }); assert.match(out, //); + assert.match(out, //); assert.match(out, //); }); @@ -38,10 +46,11 @@ test('prefers the page cover over the thumbnail fallback', () => { assert.doesNotMatch(out, /thumbnail\.png/); }); -test('keeps an author-provided og:image and does not duplicate', () => { +test('keeps an author-provided og:image and still backfills site_name', () => { const html = `T`; const out = injectOgTags(html, ctx); assert.equal((out.match(/og:image/g) ?? []).length, 1); + assert.match(out, //); }); test('skips og:image for svg-only cover, falls back to summary card', () => { @@ -49,6 +58,7 @@ test('skips og:image for svg-only cover, falls back to summary card', () => { const out = injectOgTags(html, ctx); assert.doesNotMatch(out, /og:image/); assert.match(out, //); + assert.match(out, //); }); test('passes through a full https cover url unchanged', () => { @@ -69,10 +79,49 @@ test('escapes quotes and ampersands in title to keep the meta tag well-formed', assert.match(out, //); }); +test('uses meta override for shell pages that only expose a title', () => { + const shell = `连云港三天两夜攻略 🌊`; + const out = injectOgTags(shell, { + ...ctx, + fallbackImageUrl: 'https://m.tkmind.cn/MindSpace/john/public/demo.thumbnail.png', + meta: { subtitle: '三天两夜·沙滩酒店·必吃美食' }, + }); + assert.match(out, //); + assert.match(out, //); +}); + +test('extractSharePreviewMeta returns the card fields used by preview tooling', () => { + const html = injectOgTags( + `标题`, + ctx, + ); + const preview = extractSharePreviewMeta(html, ctx); + assert.equal(preview.title, '标题'); + assert.equal(preview.description, '描述'); + assert.equal(preview.siteName, PLATFORM_SITE_NAME); + assert.match(preview.iconUrl, /\/brand\/tkmind-icon\.png$/); +}); + +test('renderWechatSharePreviewHtml includes title, description and site footer', () => { + const html = renderWechatSharePreviewHtml({ + title: '连云港三天两夜攻略 🌊', + description: '三天两夜·沙滩酒店·必吃美食', + siteName: PLATFORM_SITE_NAME, + imageUrl: 'https://example.com/cover.png', + iconUrl: '/brand/tkmind-icon.png', + pageUrl: ctx.pageUrl, + }); + assert.match(html, /连云港三天两夜攻略/); + assert.match(html, /三天两夜·沙滩酒店·必吃美食/); + assert.match(html, /TKMind 智趣/); + assert.match(html, /example\.com\/cover\.png/); +}); + test('injectWechatShareBridge adds WeChat share bootstrap with og-derived metadata', () => { const html = `页面标题` + `` + `` + + `` + ``; const out = injectWechatShareBridge(html, { pageUrl: 'https://m.tkmind.cn/u/john/pages/demo', @@ -85,6 +134,12 @@ test('injectWechatShareBridge adds WeChat share bootstrap with og-derived metada assert.match(out, /分享描述/); }); +test('injectWechatShareBridge falls back description when og tags are missing', () => { + const html = `页面标题`; + const out = injectWechatShareBridge(html, { pageUrl: 'https://m.tkmind.cn/u/john/pages/demo' }); + assert.match(out, /TKMind 智趣 · 精选作品/); +}); + test('injectWechatShareBridge is idempotent', () => { const html = `X`; const first = injectWechatShareBridge(html, { pageUrl: 'https://m.tkmind.cn/u/john/pages/demo' }); diff --git a/mindspace-page-sync.mjs b/mindspace-page-sync.mjs index 883c04e..d2a4006 100644 --- a/mindspace-page-sync.mjs +++ b/mindspace-page-sync.mjs @@ -1,5 +1,6 @@ import fs from 'node:fs/promises'; import path from 'node:path'; +import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; const PUBLIC_HTML_SKIP_DIRS = new Set([ '.tmp-images', @@ -10,14 +11,7 @@ const PUBLIC_HTML_SKIP_DIRS = new Set([ ]); function normalizePublicHtmlPath(relativePath) { - const parts = String(relativePath ?? '') - .replace(/^\/+/, '') - .split('/') - .filter((part) => part && part !== '.' && part !== '..'); - if (parts.length === 0) return ''; - if (parts[0].toLowerCase() === 'public') return parts.join('/'); - if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`; - return parts.join('/'); + return normalizeWorkspaceRelativePath(relativePath); } function titleFromRelativePath(relativePath) { diff --git a/mindspace-page-tag.mjs b/mindspace-page-tag.mjs new file mode 100644 index 0000000..b1554f5 --- /dev/null +++ b/mindspace-page-tag.mjs @@ -0,0 +1,35 @@ +export const MINDSPACE_PAGE_TAG_ATTR = 'data-mindspace-page-tag'; +export const MINDSPACE_PAGE_TAG_PLATFORM_BRAND = 'platform-brand'; +export const PLATFORM_CONTACT_EMAIL = 'contact@tkmind.cn'; + +const PLATFORM_BRAND_INNER_RE = /(?:contact@tkmind\.(?:ai|cn)|📧[^<]*tkmind)/i; +const SIMPLE_BRAND_ELEMENT_RE = + /<(p|div|span|small|footer|a)(\s[^>]*)?>([^<]*(?:contact@tkmind\.(?:ai|cn)|📧[^<]*tkmind)[^<]*)<\/\1>/gi; + +export function normalizePlatformDomainText(text) { + return String(text ?? '').replace(/tkmind\.ai/gi, 'tkmind.cn'); +} + +export function normalizePlatformBrandHtml(html) { + let next = normalizePlatformDomainText(html); + next = next.replace(/mailto:([^"'>\s]+@tkmind)\.ai/gi, 'mailto:$1.cn'); + next = next.replace(/https?:\/\/([a-z0-9.-]*\.)?tkmind\.ai/gi, (match) => + match.replace(/tkmind\.ai/gi, 'tkmind.cn'), + ); + return next; +} + +export function ensurePlatformBrandPageTags(html) { + const normalized = normalizePlatformBrandHtml(html); + return normalized.replace(SIMPLE_BRAND_ELEMENT_RE, (match, tag, attrs, inner) => { + const attrStr = attrs ?? ''; + if (attrStr.includes(MINDSPACE_PAGE_TAG_ATTR)) return match; + if (!PLATFORM_BRAND_INNER_RE.test(inner)) return match; + const spacer = attrStr ? '' : ' '; + return `<${tag}${attrStr}${spacer}${MINDSPACE_PAGE_TAG_ATTR}="${MINDSPACE_PAGE_TAG_PLATFORM_BRAND}">${inner}`; + }); +} + +export function prepareHtmlPageBrandMarkers(html) { + return ensurePlatformBrandPageTags(html); +} diff --git a/mindspace-page-tag.test.mjs b/mindspace-page-tag.test.mjs new file mode 100644 index 0000000..4f035c5 --- /dev/null +++ b/mindspace-page-tag.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + MINDSPACE_PAGE_TAG_ATTR, + MINDSPACE_PAGE_TAG_PLATFORM_BRAND, + ensurePlatformBrandPageTags, + normalizePlatformBrandHtml, + prepareHtmlPageBrandMarkers, +} from './mindspace-page-tag.mjs'; + +test('normalizePlatformBrandHtml rewrites tkmind.ai to tkmind.cn', () => { + const html = '📧 contact@tkmind.ai'; + const result = normalizePlatformBrandHtml(html); + assert.match(result, /contact@tkmind\.cn/); + assert.doesNotMatch(result, /tkmind\.ai/i); +}); + +test('ensurePlatformBrandPageTags adds platform-brand marker to contact line', () => { + const html = '

📧 contact@tkmind.ai

'; + const result = ensurePlatformBrandPageTags(html); + assert.match(result, new RegExp(`${MINDSPACE_PAGE_TAG_ATTR}="${MINDSPACE_PAGE_TAG_PLATFORM_BRAND}"`)); + assert.match(result, /contact@tkmind\.cn/); +}); + +test('prepareHtmlPageBrandMarkers keeps existing marker', () => { + const html = `

📧 contact@tkmind.cn

`; + const result = prepareHtmlPageBrandMarkers(html); + assert.equal(result.match(new RegExp(MINDSPACE_PAGE_TAG_ATTR, 'g'))?.length, 1); +}); diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index 411671c..9b661b8 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -12,7 +12,8 @@ import { bufferToImageDataUri, } from './mindspace-thumbnails.mjs'; import { resolvePublishDir } from './user-publish.mjs'; -import { prepareHtmlDownloadLinks } from './mindspace-html-download-links.mjs'; +import { prepareHtmlDownloadLinks, inferWorkspaceHtmlRelativePath } from './mindspace-html-download-links.mjs'; +import { prepareHtmlPageBrandMarkers } from './mindspace-page-tag.mjs'; import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.mjs'; import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs'; @@ -43,6 +44,18 @@ function pageError(message, code, details) { return Object.assign(new Error(message), { code, details }); } +/** Normalize workspace HTML paths so `demo.html` and `public/demo.html` match. */ +export function normalizeWorkspaceRelativePath(relativePath) { + const parts = String(relativePath ?? '') + .replace(/^\/+/, '') + .split('/') + .filter((part) => part && part !== '.' && part !== '..'); + if (parts.length === 0) return ''; + if (parts[0].toLowerCase() === 'public') return parts.join('/'); + if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`; + return parts.join('/'); +} + function normalizeText(value, maxLength, fieldName) { const text = String(value ?? '').normalize('NFKC').trim(); if (!text) throw pageError(`${fieldName}不能为空`, 'invalid_page_input'); @@ -54,13 +67,16 @@ function normalizeText(value, maxLength, fieldName) { function normalizePageInput(input) { const title = normalizeText(input.title, MAX_TITLE_LENGTH, '标题'); - const content = normalizeText(input.content, MAX_CONTENT_BYTES, '页面内容'); + let content = normalizeText(input.content, MAX_CONTENT_BYTES, '页面内容'); + const contentFormat = input.contentFormat === 'html' ? 'html' : 'markdown'; + if (contentFormat === 'html') { + content = prepareHtmlPageBrandMarkers(content); + } const contentBytes = Buffer.byteLength(content, 'utf8'); if (contentBytes > MAX_CONTENT_BYTES) { throw pageError('页面内容超过 1 MB 限制', 'page_content_too_large'); } const summary = String(input.summary ?? '').normalize('NFKC').trim().slice(0, MAX_SUMMARY_LENGTH); - const contentFormat = input.contentFormat === 'html' ? 'html' : 'markdown'; const templateId = contentFormat === 'html' ? 'static-html' @@ -579,6 +595,25 @@ export function createPageService(pool, options = {}) { return rows[0] ? pageResponse(rows[0]) : null; }; + const findPageByRelativePath = async (userId, relativePath) => { + const normalized = normalizeWorkspaceRelativePath(relativePath); + if (!normalized) return null; + const [rows] = await pool.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 + JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id + 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 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, normalized], + ); + return rows[0] ? pageResponse(rows[0]) : null; + }; + const listPages = async (userId, filters = {}) => { const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`]; const params = [userId]; @@ -729,9 +764,9 @@ export function createPageService(pool, options = {}) { }; }; - const loadPageWorkspaceContext = async (userId, pageId) => { + const loadPageWorkspaceContext = async (userId, pageId, htmlContent = null) => { const [rows] = await pool.query( - `SELECT pv.source_snapshot_json + `SELECT pv.source_snapshot_json, p.title 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' @@ -744,8 +779,13 @@ export function createPageService(pool, options = {}) { } catch { snapshot = {}; } - const workspaceHtmlRelativePath = snapshot.relative_path ?? 'public/index.html'; const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null; + const workspaceHtmlRelativePath = inferWorkspaceHtmlRelativePath({ + snapshotRelativePath: snapshot.relative_path, + pageTitle: rows[0]?.title, + htmlContent, + publishDir: workspacePublishDir, + }); return { workspaceHtmlRelativePath, workspacePublishDir, @@ -753,10 +793,22 @@ export function createPageService(pool, options = {}) { }; }; + const rewriteHtmlDownloadLinksForPage = async (userId, pageId, html) => { + const ctx = await loadPageWorkspaceContext(userId, pageId, html); + const { html: rewritten } = await prepareHtmlDownloadLinks(pool, userId, html, { + htmlRelativePath: ctx.workspaceHtmlRelativePath, + publishDir: ctx.workspacePublishDir, + publishKey: ctx.publishKey, + publicBaseUrl: '', + preferAssetDownload: true, + }); + return rewritten; + }; + const finalizeHtmlPreview = async (userId, pageId, html) => { const shell = renderHtmlPreview(html); try { - const ctx = await loadPageWorkspaceContext(userId, pageId); + const ctx = await loadPageWorkspaceContext(userId, pageId, html); const { html: rewritten } = await prepareHtmlDownloadLinks(pool, userId, shell, { htmlRelativePath: ctx.workspaceHtmlRelativePath, publishDir: ctx.workspacePublishDir, @@ -1334,10 +1386,12 @@ export function createPageService(pool, options = {}) { listPages, findPageBySourceAsset, findPageBySourceMessage, + findPageByRelativePath, getPage, getDeletePreview, deletePage, listVersions, + rewriteHtmlDownloadLinksForPage, renderPreview: async (userId, pageId) => { const page = await getPage(userId, pageId); const html = diff --git a/mindspace-pages.test.mjs b/mindspace-pages.test.mjs index 6801a80..aeec147 100644 --- a/mindspace-pages.test.mjs +++ b/mindspace-pages.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { pageInternals } from './mindspace-pages.mjs'; +import { pageInternals, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; test('parseJsonColumn accepts mysql json objects and strings', () => { const objectValue = { relative_path: 'public/demo.html', auto_synced: true }; @@ -126,3 +126,9 @@ test('buildPageDeleteSummary lists cascade delete impact', () => { assert.ok(lines.some((line) => line.includes('广场'))); assert.ok(lines.some((line) => line.includes('原始上传资料不会被删除'))); }); + +test('normalizeWorkspaceRelativePath unifies bare html filenames under public/', () => { + assert.equal(normalizeWorkspaceRelativePath('ai-robot-report.html'), 'public/ai-robot-report.html'); + assert.equal(normalizeWorkspaceRelativePath('public/ai-robot-report.html'), 'public/ai-robot-report.html'); + assert.equal(normalizeWorkspaceRelativePath('/public/demo.html'), 'public/demo.html'); +}); diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs index 985793c..2844732 100644 --- a/mindspace-publications.mjs +++ b/mindspace-publications.mjs @@ -9,6 +9,7 @@ import { buildPublicUrl, resolvePublicBaseUrl, resolvePublishDir } from './user- import { loadWorkspaceDownloadLinkIndex, rewriteRelativeDownloadLinks, + inferWorkspaceHtmlRelativePath, } from './mindspace-html-download-links.mjs'; import { createImgproxySigner } from './imgproxy-signer.mjs'; @@ -248,7 +249,8 @@ async function prepareHtmlPublishContent({ html, ownerSlug, urlSlug, - htmlRelativePath = `public/${urlSlug}.html`, + htmlRelativePath = null, + pageTitle = null, absoluteStoragePath, imgproxySigner = null, h5Root = null, @@ -265,11 +267,18 @@ async function prepareHtmlPublishContent({ absoluteStoragePath, imgproxySigner, }); - publishContent = rewriteWorkspacePublicAssetReferences(publishContent, htmlRelativePath); const publishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null; + const resolvedHtmlRelativePath = + htmlRelativePath + ?? inferWorkspaceHtmlRelativePath({ + pageTitle, + htmlContent: publishContent, + publishDir, + }); + publishContent = rewriteWorkspacePublicAssetReferences(publishContent, resolvedHtmlRelativePath); const linkIndex = await loadWorkspaceDownloadLinkIndex(pool, userId); publishContent = rewriteRelativeDownloadLinks(publishContent, { - htmlRelativePath, + htmlRelativePath: resolvedHtmlRelativePath, publishDir, linkIndex, publishKey: userId, @@ -354,7 +363,8 @@ export function createPublicationService(pool, options = {}) { const [rows] = await pool.query( `SELECT p.id AS page_id, p.title, p.summary, p.page_type, p.template_id, p.current_version_id, p.user_id, p.space_id, pv.id AS page_version_id, pv.version_no, - pv.bundle_asset_id, av.storage_key + pv.bundle_asset_id, av.storage_key, + JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) AS source_relative_path FROM h5_page_records p JOIN h5_page_versions pv ON pv.page_id = p.id JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1 @@ -387,12 +397,20 @@ export function createPublicationService(pool, options = {}) { const preparePublishContent = async (page, ownerSlug, urlSlug) => { let publishContent = page.content; if (page.page_type !== 'html') return publishContent; + const publishDir = h5Root ? resolvePublishDir(h5Root, { id: page.user_id }) : null; return prepareHtmlPublishContent({ pool, userId: page.user_id, html: publishContent, ownerSlug, urlSlug, + pageTitle: page.title, + htmlRelativePath: inferWorkspaceHtmlRelativePath({ + snapshotRelativePath: page.source_relative_path, + pageTitle: page.title, + htmlContent: publishContent, + publishDir, + }), absoluteStoragePath, h5Root, imgproxySigner: imgproxySigner ? { buildUrl: (path, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path, preset) } : null, @@ -875,17 +893,39 @@ export function createPublicationService(pool, options = {}) { now, ], ); + const html = await fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8'); return { - html: await fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8'), + html: await refreshPublishedHtmlDownloadLinks(row, html), publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }), }; }; + const refreshPublishedHtmlDownloadLinks = async (row, html) => { + if (!h5Root || !row?.owner_id) return html; + const source = String(html ?? ''); + if (!source.trim()) return html; + const publishDir = resolvePublishDir(h5Root, { id: row.owner_id }); + const linkIndex = await loadWorkspaceDownloadLinkIndex(pool, row.owner_id); + return rewriteRelativeDownloadLinks(html, { + htmlRelativePath: inferWorkspaceHtmlRelativePath({ + pageTitle: row.title, + htmlContent: html, + publishDir, + }), + publishDir, + linkIndex, + publishKey: row.owner_id, + publicBaseUrl: resolvePublicBaseUrl(), + preferAssetDownload: false, + }).html; + }; + const resolvePublic = async (ownerSlug, urlSlug, viewerId, password, requestMeta) => { const [rows] = await pool.query( - `SELECT pr.*, u.id AS owner_id, av.storage_key + `SELECT pr.*, u.id AS owner_id, p.title, av.storage_key FROM h5_publish_records pr JOIN h5_users u ON u.id = pr.user_id + JOIN h5_page_records p ON p.id = pr.page_id JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1 JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1 WHERE COALESCE(u.slug, u.username) = ? AND pr.url_slug = ? AND pr.status = 'online' @@ -899,9 +939,10 @@ export function createPublicationService(pool, options = {}) { const resolvePrivateLink = async (token, viewerId, requestMeta) => { const tokenHash = crypto.createHash('sha256').update(String(token)).digest('hex'); const [rows] = await pool.query( - `SELECT pr.*, u.id AS owner_id, av.storage_key + `SELECT pr.*, u.id AS owner_id, p.title, av.storage_key FROM h5_publish_records pr JOIN h5_users u ON u.id = pr.user_id + JOIN h5_page_records p ON p.id = pr.page_id JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1 JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1 WHERE pr.token_hash = ? AND pr.status = 'online' AND pr.access_mode = 'private_link' diff --git a/mindspace-visual-editor.test.mjs b/mindspace-visual-editor.test.mjs index 5fb7ab2..eb3d52d 100644 --- a/mindspace-visual-editor.test.mjs +++ b/mindspace-visual-editor.test.mjs @@ -14,6 +14,18 @@ test('buildEditablePreviewDocument injects visual editor into body', () => { assert.match(result, /data-mindspace-editing/); assert.match(result, /

Hi<\/p>/); assert.match(result, new RegExp(MINDSPACE_PAGE_CONTENT_MESSAGE.replace(':', '\\:'))); + assert.match(result, /data-collapsed/); + assert.match(result, /5000/); + assert.match(result, /点击展开/); + assert.match(result, /isProtectedNode/); +}); + +test('buildEditablePreviewDocument normalizes platform brand contact line', () => { + const html = '

📧 contact@tkmind.ai

'; + const result = buildEditablePreviewDocument(html); + assert.match(result, /contact@tkmind\.cn/); + assert.match(result, /data-mindspace-page-tag="platform-brand"/); + assert.doesNotMatch(result, /tkmind\.ai/i); }); test('buildEditablePreviewDocument wraps fragment html', () => { diff --git a/mindspace.mjs b/mindspace.mjs index 3caca80..d3e3538 100644 --- a/mindspace.mjs +++ b/mindspace.mjs @@ -181,14 +181,14 @@ export function createMindSpaceService(pool, options = {}) { c.ai_access_policy, c.publish_policy, c.is_system, c.sort_order, CASE WHEN c.category_code = 'draft' THEN COALESCE(pc.item_count, 0) - WHEN c.category_code = 'public' THEN COALESCE(pub.item_count, 0) + WHEN c.category_code IN ('oa', 'public') THEN COALESCE(pc.item_count, 0) + COALESCE(ac.item_count, 0) ELSE COALESCE(ac.item_count, 0) END AS item_count FROM h5_space_categories c LEFT JOIN ( SELECT category_id, user_id, COUNT(*) AS item_count FROM h5_assets - WHERE user_id = ? AND status <> 'deleted' AND source_type = 'upload' + WHERE user_id = ? AND status <> 'deleted' GROUP BY category_id, user_id ) ac ON ac.category_id = c.id AND ac.user_id = c.user_id LEFT JOIN ( @@ -197,15 +197,9 @@ export function createMindSpaceService(pool, options = {}) { WHERE user_id = ? AND status <> 'deleted' GROUP BY category_id, user_id ) pc ON pc.category_id = c.id AND pc.user_id = c.user_id - LEFT JOIN ( - SELECT user_id, COUNT(DISTINCT page_id) AS item_count - FROM h5_publish_records - WHERE user_id = ? AND status = 'online' - GROUP BY user_id - ) pub ON pub.user_id = c.user_id WHERE c.user_id = ? AND c.space_id = ? ORDER BY c.sort_order, c.category_name`, - [userId, userId, userId, userId, row.id], + [userId, userId, userId, row.id], ); const quotaBytes = asNumber(row.quota_bytes); diff --git a/mindspace.test.mjs b/mindspace.test.mjs index f6a7cf0..6f21fde 100644 --- a/mindspace.test.mjs +++ b/mindspace.test.mjs @@ -105,8 +105,9 @@ test('getSpace scopes space and categories to the authenticated user', async () assert.equal(space.quota.availableBytes, 5 * 1024 * 1024 - 3072); assert.equal(space.categories[0].code, 'private'); assert.deepEqual(calls[0].params, ['user-1']); - assert.deepEqual(calls[1].params, ['user-1', 'user-1', 'user-1', 'user-1', 'space-1']); - assert.match(calls[1].sql, /source_type\s*=\s*'upload'/); + assert.deepEqual(calls[1].params, ['user-1', 'user-1', 'user-1', 'space-1']); + assert.match(calls[1].sql, /WHEN c\.category_code IN \('oa', 'public'\) THEN COALESCE\(pc\.item_count, 0\) \+ COALESCE\(ac\.item_count, 0\)/); + assert.doesNotMatch(calls[1].sql, /source_type\s*=\s*'upload'/); }); test('getSpace includes schedule snapshot when schedule service is available', async () => { diff --git a/public/brand/tkmind-icon.png b/public/brand/tkmind-icon.png new file mode 100644 index 0000000..072f9ab Binary files /dev/null and b/public/brand/tkmind-icon.png differ diff --git a/public/dev/wechat-share-demo.html b/public/dev/wechat-share-demo.html new file mode 100644 index 0000000..2b5f0a7 --- /dev/null +++ b/public/dev/wechat-share-demo.html @@ -0,0 +1,50 @@ + + + + + + 微信分享预览 · 连云港三天两夜攻略 🌊 + + + +
+

微信链接卡片预览

+

本地模拟的是「粘贴链接后看到的卡片」,不是 JS-SDK 内部分享弹层。源链接:https://m.tkmind.cn/MindSpace/john/public/lianyungang-guide.html

+

修复后:标题 + 描述 + 缩略图 + 底部「TKMind 智趣」小图标应同时出现。

+
+
+

连云港三天两夜攻略 🌊

+

三天两夜·沙滩酒店·必吃美食

+
+ + TKMind 智趣 +
+
+ +
+
+
og:title 连云港三天两夜攻略 🌊
+
og:description 三天两夜·沙滩酒店·必吃美食
+
og:site_name TKMind 智趣
+
og:image https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=200&q=80
+
icon /brand/tkmind-icon.png
+
+
+ + \ No newline at end of file diff --git a/public/dev/wechat-share-preview.html b/public/dev/wechat-share-preview.html new file mode 100644 index 0000000..761f5ee --- /dev/null +++ b/public/dev/wechat-share-preview.html @@ -0,0 +1,41 @@ + + + + + + 微信分享预览工具 + + + +
+

微信分享预览

+

输入已发布页面路径,本地模拟微信「粘贴链接后」看到的卡片。开发环境走 Portal API:/dev/wechat-share-preview

+
+ + + +
+
+ + + diff --git a/scripts/dedupe-user-pages.mjs b/scripts/dedupe-user-pages.mjs new file mode 100644 index 0000000..f0d3598 --- /dev/null +++ b/scripts/dedupe-user-pages.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +/** + * Remove duplicate MindSpace pages for one user, keeping one page per workspace path/title. + * + * Usage: + * node scripts/dedupe-user-pages.mjs --username=john --dry-run + * node scripts/dedupe-user-pages.mjs --username=john --yes + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; +import { createDbPool } from '../db.mjs'; +import { createPageService } from '../mindspace-pages.mjs'; +import { loadH5Environment } from './load-env.mjs'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(scriptDir, '..'); +loadH5Environment(scriptDir); + +function readArg(name) { + const prefix = `--${name}=`; + const hit = process.argv.find((arg) => arg.startsWith(prefix)); + return hit ? hit.slice(prefix.length).trim() : null; +} + +function loadEnvFromFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +const prodEnv = '/Users/john/Project/Memind/.env'; +if (fs.existsSync(prodEnv)) loadEnvFromFile(prodEnv); + +const dryRun = process.argv.includes('--dry-run'); +const confirmed = process.argv.includes('--yes'); +const removeFromPlaza = process.argv.includes('--remove-from-plaza'); +const username = readArg('username'); +const userIdArg = readArg('user-id'); + +const storageRoot = process.env.MINDSPACE_STORAGE_ROOT + ? path.resolve(process.env.MINDSPACE_STORAGE_ROOT) + : path.join(root, 'data', 'mindspace'); +const h5Root = process.env.MINDSPACE_H5_ROOT + ? path.resolve(process.env.MINDSPACE_H5_ROOT) + : root; + +async function resolveUserId(pool) { + if (userIdArg) return userIdArg; + if (!username) throw new Error('请指定 --username=... 或 --user-id=...'); + const [rows] = await pool.query( + `SELECT id, username, email FROM h5_users WHERE username = ? LIMIT 1`, + [username], + ); + const row = rows[0]; + if (!row) throw new Error(`用户不存在: ${username}`); + return row.id; +} + +function groupKey(row) { + const relativePath = String(row.relative_path ?? '').trim(); + if (relativePath) return `path:${relativePath}`; + const title = String(row.title ?? '').trim().toLowerCase(); + return `title:${title || row.id}`; +} + +function mergeGroupsByTitle(groups) { + const titleToPathKey = new Map(); + for (const [key, items] of groups.entries()) { + if (!key.startsWith('path:')) continue; + const title = String(items[0]?.title ?? '').trim().toLowerCase(); + if (title) titleToPathKey.set(title, key); + } + for (const [key, items] of [...groups.entries()]) { + if (!key.startsWith('title:')) continue; + const title = key.slice('title:'.length); + const pathKey = titleToPathKey.get(title); + if (!pathKey) continue; + groups.get(pathKey).push(...items); + groups.delete(key); + } + return groups; +} + +function pickKeeper(rows) { + return [...rows].sort((left, right) => { + const leftHasPath = Boolean(String(left.relative_path ?? '').trim()); + const rightHasPath = Boolean(String(right.relative_path ?? '').trim()); + if (leftHasPath !== rightHasPath) return rightHasPath ? 1 : -1; + const pubDiff = Number(right.has_online_pub ?? 0) - Number(left.has_online_pub ?? 0); + if (pubDiff !== 0) return pubDiff; + const sessionDiff = Number(Boolean(right.source_session_id)) - Number(Boolean(left.source_session_id)); + if (sessionDiff !== 0) return sessionDiff; + const updatedDiff = Number(right.updated_at ?? 0) - Number(left.updated_at ?? 0); + if (updatedDiff !== 0) return updatedDiff; + return String(left.id).localeCompare(String(right.id)); + })[0]; +} + +const pool = createDbPool(); +const pageService = createPageService(pool, { h5Root, storageRoot }); + +try { + const userId = await resolveUserId(pool); + const [userRows] = await pool.query( + `SELECT username, email FROM h5_users WHERE id = ? LIMIT 1`, + [userId], + ); + const user = userRows[0]; + + const [rows] = await pool.query( + `SELECT p.id, p.title, p.source_session_id, p.created_at, p.updated_at, + JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) AS relative_path, + EXISTS( + SELECT 1 FROM h5_publish_records pr + WHERE pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online' + ) AS has_online_pub + 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' + ORDER BY p.updated_at DESC, p.id DESC`, + [userId], + ); + + const groups = new Map(); + for (const row of rows) { + const key = groupKey(row); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(row); + } + mergeGroupsByTitle(groups); + + const duplicateGroups = [...groups.entries()].filter(([, items]) => items.length > 1); + const toDelete = []; + for (const [key, items] of duplicateGroups) { + const keeper = pickKeeper(items); + for (const item of items) { + if (item.id !== keeper.id) { + toDelete.push({ key, keeperId: keeper.id, page: item }); + } + } + } + + console.log(`用户: ${user?.username ?? userId} (${user?.email ?? 'unknown'})`); + console.log(`总页面: ${rows.length}`); + console.log(`重复组: ${duplicateGroups.length}`); + console.log(`待删除重复页: ${toDelete.length}`); + + for (const [key, items] of duplicateGroups.sort((a, b) => b[1].length - a[1].length).slice(0, 20)) { + const keeper = pickKeeper(items); + console.log(`- ${key}: ${items.length} 份,保留 ${keeper.id},删除 ${items.length - 1} 份`); + } + if (duplicateGroups.length > 20) { + console.log(`... 另有 ${duplicateGroups.length - 20} 组未展开`); + } + + if (toDelete.length === 0) { + console.log('没有需要清理的重复页面。'); + process.exit(0); + } + + if (dryRun) { + console.log('[dry-run] 未执行删除。'); + process.exit(0); + } + + if (!confirmed) { + console.error('请加 --yes 确认删除,或先用 --dry-run 查看。'); + process.exit(1); + } + + let deleted = 0; + let failed = 0; + for (const entry of toDelete) { + try { + await pageService.deletePage(userId, entry.page.id, { removeFromPlaza }); + deleted += 1; + if (deleted % 25 === 0) { + console.log(`已删除 ${deleted}/${toDelete.length} ...`); + } + } catch (error) { + failed += 1; + const message = error instanceof Error ? error.message : String(error); + console.error(`删除失败 ${entry.page.id} (${entry.page.title}): ${message}`); + } + } + + const [remainingRows] = await pool.query( + `SELECT COUNT(*) AS count FROM h5_page_records WHERE user_id = ? AND status <> 'deleted'`, + [userId], + ); + console.log('---'); + console.log(`完成:删除 ${deleted},失败 ${failed},剩余 ${Number(remainingRows[0]?.count ?? 0)} 页`); + process.exit(failed > 0 ? 1 : 0); +} finally { + await pool.end(); +} diff --git a/scripts/fix-john-schedule.mjs b/scripts/fix-john-schedule.mjs new file mode 100644 index 0000000..cd5dc8a --- /dev/null +++ b/scripts/fix-john-schedule.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import crypto from 'node:crypto'; +import mysql from 'mysql2/promise'; +import { zonedTimeToEpochMs } from '../schedule-time.mjs'; + +const pool = mysql.createPool(process.env.DATABASE_URL ?? 'mysql://boot:888888@localhost:3306/memind'); +const uid = '1c99b83b-0454-474f-a5d2-129d34506a32'; +const tz = 'Asia/Shanghai'; +const t = (y, m, d, h, mi) => + zonedTimeToEpochMs({ year: y, month: m, day: d, hour: h, minute: mi, second: 0 }, tz); + +const badItems = [ + 'f058e736-1e6d-4559-8399-36cb91d67634', + 'be571e96-365c-4a0a-806a-491089e02144', + '03ff84f1-3ac0-4fc6-ab7e-9fe2064fbf15', +]; +await pool.query('DELETE FROM h5_schedule_reminders WHERE item_id IN (?)', [badItems]); +await pool.query('DELETE FROM h5_schedule_items WHERE id IN (?)', [badItems]); + +await pool.query( + 'UPDATE h5_schedule_items SET kind=?, start_at=?, end_at=? WHERE id=?', + ['event', t(2026, 7, 1, 6, 0), t(2026, 7, 1, 6, 30), '8b34b44e-84cf-462b-86a4-e895e656d626'], +); +await pool.query( + 'UPDATE h5_schedule_items SET start_at=?, end_at=? WHERE id=?', + [t(2026, 7, 1, 12, 0), t(2026, 7, 1, 13, 0), '6fb372ca-3537-4f63-8a8c-d30615d6e832'], +); +await pool.query('UPDATE h5_schedule_reminders SET remind_at=? WHERE id=?', [ + t(2026, 7, 1, 5, 55), + '633b008a-791b-455c-8f3b-331d5c7b1971', +]); +await pool.query('UPDATE h5_schedule_reminders SET remind_at=? WHERE id=?', [ + t(2026, 7, 1, 11, 50), + 'e423c8be-7463-481a-b881-131cb32b6f99', +]); + +const [existingDinner] = await pool.query( + `SELECT id FROM h5_schedule_items WHERE user_id=? AND title LIKE '%聚餐%' AND deleted_at IS NULL LIMIT 1`, + [uid], +); +if (!existingDinner[0]) { + const dinnerId = crypto.randomUUID(); + const now = Date.now(); + await pool.query( + `INSERT INTO h5_schedule_items + (id, user_id, kind, title, status, start_at, end_at, all_day, timezone, source_channel, created_at, updated_at) + VALUES (?, ?, 'event', ?, 'active', ?, ?, 0, ?, 'agent', ?, ?)`, + [ + dinnerId, + uid, + '同学聚餐 🍽️', + t(2026, 7, 1, 18, 30), + t(2026, 7, 1, 21, 0), + tz, + now, + now, + ], + ); + await pool.query( + `INSERT INTO h5_schedule_reminders + (id, user_id, item_id, remind_at, offset_minutes, channel, status, attempts, created_at, updated_at) + VALUES (?, ?, ?, ?, 30, 'wechat', 'pending', 0, ?, ?)`, + [crypto.randomUUID(), uid, dinnerId, t(2026, 7, 1, 18, 0), now, now], + ); +} + +const [rows] = await pool.query( + `SELECT title, start_at FROM h5_schedule_items WHERE user_id=? AND deleted_at IS NULL ORDER BY start_at`, + [uid], +); +for (const row of rows) { + console.log( + row.title, + new Date(Number(row.start_at)).toLocaleString('zh-CN', { timeZone: tz }), + ); +} +await pool.end(); diff --git a/scripts/verify-chat-finish-sync.mjs b/scripts/verify-chat-finish-sync.mjs index bbb6015..ef21177 100644 --- a/scripts/verify-chat-finish-sync.mjs +++ b/scripts/verify-chat-finish-sync.mjs @@ -34,11 +34,13 @@ assertExcludes( const messageTs = read('src/utils/message.ts'); assertIncludes(messageTs, 'deriveUserFacingText', 'message.ts'); +assertIncludes(messageTs, 'deriveAssistantFacingText', 'message.ts'); assertIncludes(messageTs, 'chat-finish-sync.mjs', 'message.ts'); const conversationDisplay = read('conversation-display.mjs'); assertIncludes(conversationDisplay, 'TASK_ROUTING_HINT_RE', 'conversation-display.mjs'); assertIncludes(conversationDisplay, 'deriveUserFacingText', 'conversation-display.mjs'); +assertIncludes(conversationDisplay, 'deriveAssistantFacingText', 'conversation-display.mjs'); const chatSkills = read('chat-skills.mjs'); assertIncludes(chatSkills, 'stripKnownChatSkillPrompt', 'chat-skills.mjs'); diff --git a/scripts/wechat-share-preview.mjs b/scripts/wechat-share-preview.mjs new file mode 100644 index 0000000..d825de7 --- /dev/null +++ b/scripts/wechat-share-preview.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + extractSharePreviewMeta, + injectOgTags, + renderWechatSharePreviewHtml, +} from '../mindspace-og-tags.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function loadHtmlSource(input) { + if (/^https?:\/\//i.test(input)) { + const res = await fetch(input); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${input}`); + return { html: await res.text(), pageUrl: input.split('#')[0] }; + } + const filePath = path.resolve(process.cwd(), input); + const html = await fs.readFile(filePath, 'utf8'); + const pageUrl = pathToFileURL(filePath).toString(); + return { html, pageUrl }; +} + +async function main() { + const input = process.argv[2]; + const origin = (process.argv[3] || 'https://m.tkmind.cn').replace(/\/$/, ''); + if (!input) { + console.error('用法: node scripts/wechat-share-preview.mjs <页面路径或URL> [origin]'); + process.exit(1); + } + + const { html: rawHtml, pageUrl } = await loadHtmlSource(input); + const pageDirUrl = pageUrl.includes('/') + ? `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}` + : `${origin}/`; + const html = injectOgTags(rawHtml, { origin, pageUrl, pageDirUrl }); + const preview = extractSharePreviewMeta(html, { origin, pageUrl, pageDirUrl }); + const outDir = path.join(root, 'public', 'dev'); + await fs.mkdir(outDir, { recursive: true }); + const outPath = path.join(outDir, 'wechat-share-preview.generated.html'); + await fs.writeFile( + outPath, + renderWechatSharePreviewHtml(preview, { + note: '由 scripts/wechat-share-preview.mjs 生成,可离线查看卡片布局。', + }), + 'utf8', + ); + console.log(`预览已写入 ${outPath}`); + console.log(`title: ${preview.title}`); + console.log(`description: ${preview.description}`); + console.log(`site: ${preview.siteName}`); + console.log(`image: ${preview.imageUrl || '(none)'}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server.mjs b/server.mjs index 6acd126..915c205 100644 --- a/server.mjs +++ b/server.mjs @@ -39,8 +39,9 @@ import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs'; import { createMindSpaceService, DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs'; import { ensureMindSpaceConfig } from './mindspace-config.mjs'; import { createAssetService } from './mindspace-assets.mjs'; -import { createPageService, pageInternals, inlinePrivateAssetsInHtml } from './mindspace-pages.mjs'; +import { createPageService, pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; import { createPageLiveEditService } from './mindspace-page-live-edit.mjs'; +import { createAssetAgentService } from './mindspace-asset-agent.mjs'; import { createPageEditSessionService } from './mindspace-page-edit-session.mjs'; import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs'; import { createPublicationService, rewriteWorkspacePublicAssetReferences } from './mindspace-publications.mjs'; @@ -88,8 +89,13 @@ import { syncPublicHtmlAfterFinish, } from './mindspace-public-finish-sync.mjs'; import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs'; -import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; -import { injectOgTags, injectWechatShareBridge } from './mindspace-og-tags.mjs'; +import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; +import { + extractSharePreviewMeta, + injectOgTags, + injectWechatShareBridge, + renderWechatSharePreviewHtml, +} from './mindspace-og-tags.mjs'; import { ensureThumbnailPng, rasterizeThumbnailSvgToPng, @@ -234,6 +240,7 @@ let mindSpaceAssets = null; let mindSpaceAudit = null; let mindSpacePages = null; let mindSpacePageLiveEdit = null; +let mindSpaceAssetAgent = null; let mindSpacePageEditSession = null; let mindSpacePublications = null; let plazaPosts = null; @@ -289,15 +296,20 @@ async function bootstrapUserAuth() { storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'), }); + const resolveUserIdForAgentSession = async (sessionId) => { + const [rows] = await pool.query( + `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, + [sessionId], + ); + return rows[0]?.user_id ?? null; + }; mindSpacePageLiveEdit = createPageLiveEditService({ pageService: mindSpacePages, - resolveUserIdForAgentSession: async (sessionId) => { - const [rows] = await pool.query( - `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, - [sessionId], - ); - return rows[0]?.user_id ?? null; - }, + resolveUserIdForAgentSession, + }); + mindSpaceAssetAgent = createAssetAgentService({ + assetService: mindSpaceAssets, + resolveUserIdForAgentSession, }); mindSpacePublications = createPublicationService(pool, { h5Root: __dirname, @@ -1706,6 +1718,7 @@ api.use(async (req, res, next) => { if (req.path === '/status') return next(); if (req.path.startsWith('/internal/agent/')) return next(); if (req.path === '/agent/mindspace_page_patch') return next(); + if (req.path === '/agent/mindspace_asset_delete') return next(); if (req.path === '/config/blocked-words') return next(); if (req.method === 'GET' && /^\/mindspace\/v1\/assets\/[^/]+\/download$/.test(req.path)) { return next(); @@ -2615,8 +2628,33 @@ api.post('/mindspace/v1/pages/from-asset', async (req, res) => { req.currentUser.id, assetId, ); + if (asset.mimeType === 'text/html') { + const relativePath = normalizeWorkspaceRelativePath( + String(asset.originalFilename ?? asset.original_filename ?? '').includes('/') + ? asset.originalFilename ?? asset.original_filename + : `public/${asset.originalFilename ?? asset.original_filename ?? ''}`, + ); + const existingByPath = relativePath + ? await mindSpacePages.findPageByRelativePath(req.currentUser.id, relativePath).catch(() => null) + : null; + if (existingByPath) { + return sendData(res, req, { + kind: 'page', + categoryCode: existingByPath.categoryCode ?? 'draft', + page: existingByPath, + }); + } + } const content = await fs.promises.readFile(assetPath, 'utf8'); const contentFormat = asset.mimeType === 'text/html' ? 'html' : 'markdown'; + const htmlRelativePath = + contentFormat === 'html' + ? normalizeWorkspaceRelativePath( + String(asset.originalFilename ?? asset.original_filename ?? '').includes('/') + ? asset.originalFilename ?? asset.original_filename + : `public/${asset.originalFilename ?? asset.original_filename ?? ''}`, + ) + : null; const page = await mindSpacePages.createFromChat( req.currentUser.id, { @@ -2633,6 +2671,7 @@ api.post('/mindspace/v1/pages/from-asset', async (req, res) => { source_asset_id: asset.id, source_category: asset.categoryCode, content_mode: contentFormat, + ...(htmlRelativePath ? { relative_path: htmlRelativePath } : {}), }, }, ); @@ -2696,6 +2735,17 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) { return { session, message, content }; } +async function resolveExistingSavedPage(userId, { sessionId, messageId, relativePath } = {}) { + if (!mindSpacePages || !userId) return null; + const byMessage = await mindSpacePages + .findPageBySourceMessage(userId, sessionId, messageId) + .catch(() => null); + if (byMessage) return byMessage; + const normalizedPath = normalizeWorkspaceRelativePath(relativePath); + if (!normalizedPath) return null; + return mindSpacePages.findPageByRelativePath(userId, normalizedPath).catch(() => null); +} + const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']); const pageSyncInFlight = new Map(); @@ -2911,11 +2961,11 @@ api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => { thumbnailReady = false; } } - const existingPage = await mindSpacePages.findPageBySourceMessage( - req.currentUser.id, + const existingPage = await resolveExistingSavedPage(req.currentUser.id, { sessionId, messageId, - ).catch(() => null); + relativePath: resolvedHtml?.relativePath ?? analysis.relativePath, + }); return sendData(res, req, { contentMode: analysis.contentMode, links: analysis.links, @@ -3009,7 +3059,9 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { role: source.message.role, content_mode: analysis.contentMode, public_url: analysis.previewUrl, - relative_path: analysis.relativePath, + relative_path: analysis.relativePath + ? normalizeWorkspaceRelativePath(analysis.relativePath) + : analysis.relativePath, }; let resolvedHtml = null; @@ -3086,9 +3138,16 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => { }).catch(() => {}); } - const replacePageId = req.body?.replace_page_id + const saveAsNew = Boolean(req.body?.save_as_new); + let replacePageId = req.body?.replace_page_id ? String(req.body.replace_page_id).trim() : null; + if (!replacePageId && !saveAsNew && analysis.contentMode === 'static_html' && analysis.relativePath) { + const existingByPath = await mindSpacePages + .findPageByRelativePath(req.currentUser.id, analysis.relativePath) + .catch(() => null); + if (existingByPath) replacePageId = existingByPath.id; + } let page; if (replacePageId) { const existingPage = await mindSpacePages.getPage(req.currentUser.id, replacePageId); @@ -3363,6 +3422,32 @@ api.post('/agent/mindspace_page_patch', async (req, res) => { } }); +api.post('/agent/mindspace_asset_delete', async (req, res) => { + if (!mindSpaceAssetAgent) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + const result = await mindSpaceAssetAgent.applyAgentDelete(req.body ?? {}); + await mindSpaceAudit?.write({ + userId: result.userId ?? null, + action: 'asset.delete', + objectType: 'asset', + objectId: result.assetId, + ip: req.ip, + metadata: { via: 'agent' }, + }); + return sendData(res, req, result); + } catch (error) { + if (error?.code === 'forbidden') { + return sendError(res, req, 403, error.code, error.message); + } + if (error?.code === 'invalid_request' || error?.code === 'confirmation_required') { + return sendError(res, req, 400, error.code, error.message); + } + return mindSpaceError(res, req, error); + } +}); + api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { @@ -3426,6 +3511,24 @@ api.post('/mindspace/v1/pages/:pageId/thumbnail/regenerate', async (req, res) => } }); +api.post('/mindspace/v1/pages/:pageId/rewrite-download-links', async (req, res) => { + if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); + try { + const content = String(req.body?.content ?? '').trim(); + if (!content) { + throw Object.assign(new Error('缺少页面内容'), { code: 'invalid_page_input' }); + } + const html = await mindSpacePages.rewriteHtmlDownloadLinksForPage( + req.currentUser.id, + req.params.pageId, + content, + ); + return sendData(res, req, { html }); + } catch (error) { + return mindSpaceError(res, req, error); + } +}); + api.get('/mindspace/v1/pages/:pageId/preview', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { @@ -4319,6 +4422,14 @@ function extractOgImageUrl(html) { ); } +function extractShareMetaFromPageHtml(html) { + const signals = extractCoverSignals(String(html ?? '')); + return { + title: detectPublishedPageTitle(html), + subtitle: signals.subtitle, + }; +} + function appendQueryParam(url, key, value) { const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`; @@ -4738,6 +4849,7 @@ function sendPublishedPage(req, res, result, { embed = false, raw = false } = {} pageUrl, pageDirUrl, fallbackImageUrl: extractOgImageUrl(html), + meta: extractShareMetaFromPageHtml(html), }); if (wechatShare) shellHtml = injectWechatShareBridge(shellHtml, { pageUrl }); } catch { @@ -5282,6 +5394,47 @@ app.use( '/plaza-covers', express.static(path.join(__dirname, 'public/plaza-covers'), { maxAge: '7d' }), ); +app.use('/brand', express.static(path.join(__dirname, 'public/brand'), { maxAge: '7d' })); + +app.get('/dev/wechat-share-preview', async (req, res) => { + try { + const rawTarget = String(req.query.url ?? req.query.path ?? '').trim(); + if (!rawTarget) { + return res.status(400).type('text/plain; charset=utf-8').send('缺少 url 参数,例如 ?url=/MindSpace/john/public/demo.html'); + } + const origin = resolveRequestOrigin(req) || `http://${HOST}:${PORT}`; + const targetUrl = rawTarget.startsWith('http') ? new URL(rawTarget) : new URL(rawTarget.startsWith('/') ? rawTarget : `/${rawTarget}`, origin); + const upstream = await fetch(targetUrl.toString(), { + headers: { + 'user-agent': req.get('user-agent') || 'tkmind-wechat-share-preview', + accept: 'text/html,*/*', + }, + redirect: 'follow', + }); + if (!upstream.ok) { + return res.status(upstream.status).type('text/plain; charset=utf-8').send(`页面请求失败:HTTP ${upstream.status}`); + } + let html = await upstream.text(); + const pageUrl = targetUrl.toString().split('#')[0]; + const pageDirUrl = `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}`; + html = injectOgTags(html, { origin: targetUrl.origin, pageUrl, pageDirUrl }); + const preview = extractSharePreviewMeta(html, { + origin: targetUrl.origin, + pageUrl, + pageDirUrl, + }); + res.set('Content-Type', 'text/html; charset=utf-8'); + res.set('Cache-Control', 'no-store'); + return res.send( + renderWechatSharePreviewHtml(preview, { + note: '此预览读取页面最终 HTML 中的 Open Graph 标签,可用来对照微信里粘贴链接后的卡片效果。', + }), + ); + } catch (err) { + return res.status(500).type('text/plain; charset=utf-8').send(String(err?.message ?? err)); + } +}); +app.use('/dev', express.static(path.join(__dirname, 'public/dev'), { maxAge: 0 })); app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => { const fileName = path.basename(req.path); diff --git a/skills/static-page-publish/SKILL.md b/skills/static-page-publish/SKILL.md index 4612f47..a6ba391 100644 --- a/skills/static-page-publish/SKILL.md +++ b/skills/static-page-publish/SKILL.md @@ -78,6 +78,16 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 本地对比示例:`node scripts/thumbnail-preview-demo.mjs` → `/thumbnail-demo/` +## 平台页脚标记(必须) + +页脚平台联系行**必须**使用 `data-mindspace-page-tag="platform-brand"`,且邮箱/域名只用 **tkmind.cn**(如 `contact@tkmind.cn`),**禁止** `tkmind.ai`: + +```html +

📧 contact@tkmind.cn

+``` + +带 `data-mindspace-page-tag` 的区域为平台固定信息:用户在编辑模式中不可见、不可改;预览与发布后正常显示。 + ## 附带文件下载(Word / PDF) - 二进制文件用 `docx-generate` 脚本或平台允许的方式**单独生成**,保存到 `public/`(或 `oa/` 再复制到 `public/`) diff --git a/src/api/client.ts b/src/api/client.ts index a3a5ec8..2ed98cc 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -61,6 +61,7 @@ import { normalizeConversationMessages, normalizeUserMessageForApi } from '../ut const API = '/api'; const DEFAULT_API_TIMEOUT_MS = 20_000; +const AGENT_CONNECT_TIMEOUT_MS = 60_000; const AGENT_RUNS_PATH = '/agent/runs'; export type AgentRun = { @@ -184,10 +185,14 @@ function notifyUnauthorized() { unauthorizedHandler(); } -async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit): Promise { +async function fetchWithTimeout( + input: RequestInfo | URL, + init?: RequestInit, + timeoutMs = DEFAULT_API_TIMEOUT_MS, +): Promise { const controller = new AbortController(); const upstreamSignal = init?.signal; - const timeout = window.setTimeout(() => controller.abort(), DEFAULT_API_TIMEOUT_MS); + const timeout = window.setTimeout(() => controller.abort(), timeoutMs); const abortFromUpstream = () => controller.abort(); if (upstreamSignal) { @@ -267,16 +272,24 @@ function sanitizeSessionEvent(event: SessionEvent): SessionEvent { return { ...event, error: sanitizeUserFacingErrorMessage(event.error) }; } -async function apiFetch(path: string, init?: RequestInit): Promise { +async function apiFetch( + path: string, + init?: RequestInit, + options?: { timeoutMs?: number }, +): Promise { let res: Response; try { - res = await fetchWithTimeout(`${API}${path}`, { - ...init, - headers: { - 'Content-Type': 'application/json', - ...init?.headers, + res = await fetchWithTimeout( + `${API}${path}`, + { + ...init, + headers: { + 'Content-Type': 'application/json', + ...init?.headers, + }, }, - }); + options?.timeoutMs, + ); } catch (err) { throw new ApiError(0, formatNetworkError(err)); } @@ -313,10 +326,14 @@ async function apiFetch(path: string, init?: RequestInit): Promise { } export async function startSession(): Promise { - return apiFetch('/agent/start', { - method: 'POST', - body: JSON.stringify({}), - }); + return apiFetch( + '/agent/start', + { + method: 'POST', + body: JSON.stringify({}), + }, + { timeoutMs: AGENT_CONNECT_TIMEOUT_MS }, + ); } export async function bootstrapProjectMemory( @@ -929,6 +946,7 @@ export async function saveChatMessageAsPage(input: { selectedLinkIndex?: number; acknowledgedFindingIds?: string[]; replacePageId?: string; + saveAsNew?: boolean; }): Promise { const result = await apiFetch<{ data: ChatSaveResult }>( '/mindspace/v1/pages/save-from-chat', @@ -945,6 +963,7 @@ export async function saveChatMessageAsPage(input: { acknowledged_finding_ids: input.acknowledgedFindingIds, page_type: input.templateId === 'report' ? 'report' : 'article', replace_page_id: input.replacePageId, + save_as_new: input.saveAsNew ?? false, }), }, ); @@ -1018,6 +1037,20 @@ export async function updateMindSpacePage( return result.data; } +export async function rewriteMindSpacePageDownloadLinks( + pageId: string, + content: string, +): Promise { + const result = await apiFetch<{ data: { html: string } }>( + `/mindspace/v1/pages/${encodeURIComponent(pageId)}/rewrite-download-links`, + { + method: 'POST', + body: JSON.stringify({ content }), + }, + ); + return result.data.html; +} + export async function fetchMindSpacePageDraftPreview( pageId: string, input: { @@ -2112,14 +2145,18 @@ export async function resumeSession( sessionId: string, options?: { skipReconcile?: boolean }, ): Promise { - const result = await apiFetch<{ session: Session }>('/agent/resume', { - method: 'POST', - body: JSON.stringify({ - session_id: sessionId, - load_model_and_extensions: true, - ...(options?.skipReconcile ? { skip_reconcile: true } : {}), - }), - }); + const result = await apiFetch<{ session: Session }>( + '/agent/resume', + { + method: 'POST', + body: JSON.stringify({ + session_id: sessionId, + load_model_and_extensions: true, + ...(options?.skipReconcile ? { skip_reconcile: true } : {}), + }), + }, + { timeoutMs: AGENT_CONNECT_TIMEOUT_MS }, + ); return result.session; } @@ -2211,14 +2248,18 @@ export async function createAgentRun( requestId: string, userMessage: Message, ): Promise { - const result = await apiFetch<{ run: AgentRun }>(AGENT_RUNS_PATH, { - method: 'POST', - body: JSON.stringify({ - session_id: sessionId, - request_id: requestId, - user_message: normalizeUserMessageForApi(userMessage), - }), - }); + const result = await apiFetch<{ run: AgentRun }>( + AGENT_RUNS_PATH, + { + method: 'POST', + body: JSON.stringify({ + session_id: sessionId, + request_id: requestId, + user_message: normalizeUserMessageForApi(userMessage), + }), + }, + { timeoutMs: AGENT_CONNECT_TIMEOUT_MS }, + ); return result.run; } diff --git a/src/components/ChatLoadingSpinner.tsx b/src/components/ChatLoadingSpinner.tsx new file mode 100644 index 0000000..3b5e2f0 --- /dev/null +++ b/src/components/ChatLoadingSpinner.tsx @@ -0,0 +1,7 @@ +export function ChatLoadingSpinner({ className = '' }: { className?: string }) { + return ( +