From 722b18326f938c29e829470952bed8cbc7e68411 Mon Sep 17 00:00:00 2001 From: john Date: Tue, 30 Jun 2026 09:30:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(mindspace):=200630004=20=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=20UI=E3=80=81=E8=81=8A=E5=A4=A9=E8=BF=9E=E6=8E=A5=E3=80=81?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=88=86=E4=BA=AB=E4=B8=8E=20Agent=20?= =?UTF-8?q?=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 含 MindSpace 三列布局与统计修复、聊天加载态与连接降级、平台页脚标记与 og:site_name 微信卡片、勾选资料删除 Agent 接口及内部话术过滤。 Co-authored-by: Cursor --- .runtime/portal/mindspace-sandbox-mcp.mjs | 66 ++- .runtime/portal/server.mjs | 505 ++++++++++++++++-- .../skills/static-page-publish/SKILL.md | 10 + conversation-display.mjs | 30 ++ conversation-display.test.mjs | 14 +- mindspace-asset-agent.mjs | 58 ++ mindspace-asset-agent.test.mjs | 49 ++ mindspace-chat-context.mjs | 6 +- mindspace-chat-context.test.mjs | 26 +- mindspace-html-download-links.mjs | 92 +++- mindspace-html-download-links.test.mjs | 54 +- mindspace-og-tags.mjs | 256 +++++++-- mindspace-og-tags.test.mjs | 61 ++- mindspace-page-sync.mjs | 10 +- mindspace-page-tag.mjs | 35 ++ mindspace-page-tag.test.mjs | 29 + mindspace-pages.mjs | 68 ++- mindspace-pages.test.mjs | 8 +- mindspace-publications.mjs | 55 +- mindspace-visual-editor.test.mjs | 12 + mindspace.mjs | 12 +- mindspace.test.mjs | 5 +- public/brand/tkmind-icon.png | Bin 0 -> 62745 bytes public/dev/wechat-share-demo.html | 50 ++ public/dev/wechat-share-preview.html | 41 ++ scripts/dedupe-user-pages.mjs | 204 +++++++ scripts/fix-john-schedule.mjs | 77 +++ scripts/verify-chat-finish-sync.mjs | 2 + scripts/wechat-share-preview.mjs | 59 ++ server.mjs | 183 ++++++- skills/static-page-publish/SKILL.md | 10 + src/api/client.ts | 99 +++- src/components/ChatLoadingSpinner.tsx | 7 + src/components/ChatPanel.tsx | 17 +- src/components/ChatView.tsx | 7 +- src/components/MessageList.tsx | 6 +- src/components/MindSpacePageDetail.tsx | 30 +- .../MindSpacePageEditablePreviewFrame.tsx | 33 +- .../MindSpacePageFullscreenPreview.tsx | 3 + src/components/MindSpacePagePreviewPanel.tsx | 1 + src/components/MindSpaceView.tsx | 63 ++- src/components/PageSaveDialog.tsx | 3 +- src/components/SpaceChatPanel.tsx | 3 + src/hooks/useTKMindChat.ts | 106 +++- src/index.css | 157 +++++- src/utils/message.ts | 16 +- src/utils/mindspaceVisualEditor.ts | 53 +- tkmind-proxy.mjs | 2 +- user-memory-profile.mjs | 33 +- user-memory-profile.test.mjs | 23 + user-publish.mjs | 10 +- user-space.mjs | 2 +- vite.config.ts | 2 + 53 files changed, 2450 insertions(+), 313 deletions(-) create mode 100644 mindspace-asset-agent.mjs create mode 100644 mindspace-asset-agent.test.mjs create mode 100644 mindspace-page-tag.mjs create mode 100644 mindspace-page-tag.test.mjs create mode 100644 public/brand/tkmind-icon.png create mode 100644 public/dev/wechat-share-demo.html create mode 100644 public/dev/wechat-share-preview.html create mode 100644 scripts/dedupe-user-pages.mjs create mode 100644 scripts/fix-john-schedule.mjs create mode 100644 scripts/wechat-share-preview.mjs create mode 100644 src/components/ChatLoadingSpinner.tsx 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 0000000000000000000000000000000000000000..072f9ab363d9cb1498255a27ba173d207f87f2be GIT binary patch literal 62745 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 literal 0 HcmV?d00001 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 ( +