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}${tag}>`;
+ });
+}
+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(/`;
- if (/<\/head>/i.test(source)) {
- return source.replace(/<\/head>/i, `${script}\n`);
- }
- if (/]*>/i.test(source)) {
- return source.replace(/(]*>)/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 ? `${safe(note)}
` : '';
+
+ return `
+
+
+
+
+ 微信分享预览 · ${title}
+
+
+
+
+
微信链接卡片预览
+
本地模拟的是「粘贴链接后看到的卡片」,不是 JS-SDK 内部分享弹层。${pageUrl ? `源链接:${pageUrl}` : ''}
+ ${noteHtml}
+
+
+
${title}
+ ${description ? `
${description}
` : ''}
+
+
+ ${
+ imageUrl
+ ? `
`
+ : `无图
`
+ }
+
+
+
+
+`;
}
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}${tag}>`;
+ });
+}
+
+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 = '';
+ const result = ensurePlatformBrandPageTags(html);
+ assert.match(result, new RegExp(`${MINDSPACE_PAGE_TAG_ATTR}="${MINDSPACE_PAGE_TAG_PLATFORM_BRAND}"`));
+ assert.match(result, /contact@tkmind\.cn/);
+});
+
+test('prepareHtmlPageBrandMarkers keeps existing marker', () => {
+ const html = `📧 contact@tkmind.cn
`;
+ const result = prepareHtmlPageBrandMarkers(html);
+ assert.equal(result.match(new RegExp(MINDSPACE_PAGE_TAG_ATTR, 'g'))?.length, 1);
+});
diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs
index 411671c..9b661b8 100644
--- a/mindspace-pages.mjs
+++ b/mindspace-pages.mjs
@@ -12,7 +12,8 @@ import {
bufferToImageDataUri,
} from './mindspace-thumbnails.mjs';
import { resolvePublishDir } from './user-publish.mjs';
-import { prepareHtmlDownloadLinks } from './mindspace-html-download-links.mjs';
+import { prepareHtmlDownloadLinks, inferWorkspaceHtmlRelativePath } from './mindspace-html-download-links.mjs';
+import { prepareHtmlPageBrandMarkers } from './mindspace-page-tag.mjs';
import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.mjs';
import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs';
@@ -43,6 +44,18 @@ function pageError(message, code, details) {
return Object.assign(new Error(message), { code, details });
}
+/** Normalize workspace HTML paths so `demo.html` and `public/demo.html` match. */
+export function normalizeWorkspaceRelativePath(relativePath) {
+ const parts = String(relativePath ?? '')
+ .replace(/^\/+/, '')
+ .split('/')
+ .filter((part) => part && part !== '.' && part !== '..');
+ if (parts.length === 0) return '';
+ if (parts[0].toLowerCase() === 'public') return parts.join('/');
+ if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`;
+ return parts.join('/');
+}
+
function normalizeText(value, maxLength, fieldName) {
const text = String(value ?? '').normalize('NFKC').trim();
if (!text) throw pageError(`${fieldName}不能为空`, 'invalid_page_input');
@@ -54,13 +67,16 @@ function normalizeText(value, maxLength, fieldName) {
function normalizePageInput(input) {
const title = normalizeText(input.title, MAX_TITLE_LENGTH, '标题');
- const content = normalizeText(input.content, MAX_CONTENT_BYTES, '页面内容');
+ let content = normalizeText(input.content, MAX_CONTENT_BYTES, '页面内容');
+ const contentFormat = input.contentFormat === 'html' ? 'html' : 'markdown';
+ if (contentFormat === 'html') {
+ content = prepareHtmlPageBrandMarkers(content);
+ }
const contentBytes = Buffer.byteLength(content, 'utf8');
if (contentBytes > MAX_CONTENT_BYTES) {
throw pageError('页面内容超过 1 MB 限制', 'page_content_too_large');
}
const summary = String(input.summary ?? '').normalize('NFKC').trim().slice(0, MAX_SUMMARY_LENGTH);
- const contentFormat = input.contentFormat === 'html' ? 'html' : 'markdown';
const templateId =
contentFormat === 'html'
? 'static-html'
@@ -579,6 +595,25 @@ export function createPageService(pool, options = {}) {
return rows[0] ? pageResponse(rows[0]) : null;
};
+ const findPageByRelativePath = async (userId, relativePath) => {
+ const normalized = normalizeWorkspaceRelativePath(relativePath);
+ if (!normalized) return null;
+ const [rows] = await pool.query(
+ `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
+ FROM h5_page_records p
+ JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
+ JOIN h5_page_versions pv ON pv.id = p.current_version_id
+ LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
+ WHERE p.user_id = ?
+ AND p.status <> 'deleted'
+ AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
+ ORDER BY p.updated_at DESC, p.id DESC
+ LIMIT 1`,
+ [userId, normalized],
+ );
+ return rows[0] ? pageResponse(rows[0]) : null;
+ };
+
const listPages = async (userId, filters = {}) => {
const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`];
const params = [userId];
@@ -729,9 +764,9 @@ export function createPageService(pool, options = {}) {
};
};
- const loadPageWorkspaceContext = async (userId, pageId) => {
+ const loadPageWorkspaceContext = async (userId, pageId, htmlContent = null) => {
const [rows] = await pool.query(
- `SELECT pv.source_snapshot_json
+ `SELECT pv.source_snapshot_json, p.title
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
@@ -744,8 +779,13 @@ export function createPageService(pool, options = {}) {
} catch {
snapshot = {};
}
- const workspaceHtmlRelativePath = snapshot.relative_path ?? 'public/index.html';
const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
+ const workspaceHtmlRelativePath = inferWorkspaceHtmlRelativePath({
+ snapshotRelativePath: snapshot.relative_path,
+ pageTitle: rows[0]?.title,
+ htmlContent,
+ publishDir: workspacePublishDir,
+ });
return {
workspaceHtmlRelativePath,
workspacePublishDir,
@@ -753,10 +793,22 @@ export function createPageService(pool, options = {}) {
};
};
+ const rewriteHtmlDownloadLinksForPage = async (userId, pageId, html) => {
+ const ctx = await loadPageWorkspaceContext(userId, pageId, html);
+ const { html: rewritten } = await prepareHtmlDownloadLinks(pool, userId, html, {
+ htmlRelativePath: ctx.workspaceHtmlRelativePath,
+ publishDir: ctx.workspacePublishDir,
+ publishKey: ctx.publishKey,
+ publicBaseUrl: '',
+ preferAssetDownload: true,
+ });
+ return rewritten;
+ };
+
const finalizeHtmlPreview = async (userId, pageId, html) => {
const shell = renderHtmlPreview(html);
try {
- const ctx = await loadPageWorkspaceContext(userId, pageId);
+ const ctx = await loadPageWorkspaceContext(userId, pageId, html);
const { html: rewritten } = await prepareHtmlDownloadLinks(pool, userId, shell, {
htmlRelativePath: ctx.workspaceHtmlRelativePath,
publishDir: ctx.workspacePublishDir,
@@ -1334,10 +1386,12 @@ export function createPageService(pool, options = {}) {
listPages,
findPageBySourceAsset,
findPageBySourceMessage,
+ findPageByRelativePath,
getPage,
getDeletePreview,
deletePage,
listVersions,
+ rewriteHtmlDownloadLinksForPage,
renderPreview: async (userId, pageId) => {
const page = await getPage(userId, pageId);
const html =
diff --git a/mindspace-pages.test.mjs b/mindspace-pages.test.mjs
index 6801a80..aeec147 100644
--- a/mindspace-pages.test.mjs
+++ b/mindspace-pages.test.mjs
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
-import { pageInternals } from './mindspace-pages.mjs';
+import { pageInternals, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
test('parseJsonColumn accepts mysql json objects and strings', () => {
const objectValue = { relative_path: 'public/demo.html', auto_synced: true };
@@ -126,3 +126,9 @@ test('buildPageDeleteSummary lists cascade delete impact', () => {
assert.ok(lines.some((line) => line.includes('广场')));
assert.ok(lines.some((line) => line.includes('原始上传资料不会被删除')));
});
+
+test('normalizeWorkspaceRelativePath unifies bare html filenames under public/', () => {
+ assert.equal(normalizeWorkspaceRelativePath('ai-robot-report.html'), 'public/ai-robot-report.html');
+ assert.equal(normalizeWorkspaceRelativePath('public/ai-robot-report.html'), 'public/ai-robot-report.html');
+ assert.equal(normalizeWorkspaceRelativePath('/public/demo.html'), 'public/demo.html');
+});
diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs
index 985793c..2844732 100644
--- a/mindspace-publications.mjs
+++ b/mindspace-publications.mjs
@@ -9,6 +9,7 @@ import { buildPublicUrl, resolvePublicBaseUrl, resolvePublishDir } from './user-
import {
loadWorkspaceDownloadLinkIndex,
rewriteRelativeDownloadLinks,
+ inferWorkspaceHtmlRelativePath,
} from './mindspace-html-download-links.mjs';
import { createImgproxySigner } from './imgproxy-signer.mjs';
@@ -248,7 +249,8 @@ async function prepareHtmlPublishContent({
html,
ownerSlug,
urlSlug,
- htmlRelativePath = `public/${urlSlug}.html`,
+ htmlRelativePath = null,
+ pageTitle = null,
absoluteStoragePath,
imgproxySigner = null,
h5Root = null,
@@ -265,11 +267,18 @@ async function prepareHtmlPublishContent({
absoluteStoragePath,
imgproxySigner,
});
- publishContent = rewriteWorkspacePublicAssetReferences(publishContent, htmlRelativePath);
const publishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
+ const resolvedHtmlRelativePath =
+ htmlRelativePath
+ ?? inferWorkspaceHtmlRelativePath({
+ pageTitle,
+ htmlContent: publishContent,
+ publishDir,
+ });
+ publishContent = rewriteWorkspacePublicAssetReferences(publishContent, resolvedHtmlRelativePath);
const linkIndex = await loadWorkspaceDownloadLinkIndex(pool, userId);
publishContent = rewriteRelativeDownloadLinks(publishContent, {
- htmlRelativePath,
+ htmlRelativePath: resolvedHtmlRelativePath,
publishDir,
linkIndex,
publishKey: userId,
@@ -354,7 +363,8 @@ export function createPublicationService(pool, options = {}) {
const [rows] = await pool.query(
`SELECT p.id AS page_id, p.title, p.summary, p.page_type, p.template_id, p.current_version_id,
p.user_id, p.space_id, pv.id AS page_version_id, pv.version_no,
- pv.bundle_asset_id, av.storage_key
+ pv.bundle_asset_id, av.storage_key,
+ JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) AS source_relative_path
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.page_id = p.id
JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1
@@ -387,12 +397,20 @@ export function createPublicationService(pool, options = {}) {
const preparePublishContent = async (page, ownerSlug, urlSlug) => {
let publishContent = page.content;
if (page.page_type !== 'html') return publishContent;
+ const publishDir = h5Root ? resolvePublishDir(h5Root, { id: page.user_id }) : null;
return prepareHtmlPublishContent({
pool,
userId: page.user_id,
html: publishContent,
ownerSlug,
urlSlug,
+ pageTitle: page.title,
+ htmlRelativePath: inferWorkspaceHtmlRelativePath({
+ snapshotRelativePath: page.source_relative_path,
+ pageTitle: page.title,
+ htmlContent: publishContent,
+ publishDir,
+ }),
absoluteStoragePath,
h5Root,
imgproxySigner: imgproxySigner ? { buildUrl: (path, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path, preset) } : null,
@@ -875,17 +893,39 @@ export function createPublicationService(pool, options = {}) {
now,
],
);
+ const html = await fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8');
return {
- html: await fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8'),
+ html: await refreshPublishedHtmlDownloadLinks(row, html),
publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }),
};
};
+ const refreshPublishedHtmlDownloadLinks = async (row, html) => {
+ if (!h5Root || !row?.owner_id) return html;
+ const source = String(html ?? '');
+ if (!source.trim()) return html;
+ const publishDir = resolvePublishDir(h5Root, { id: row.owner_id });
+ const linkIndex = await loadWorkspaceDownloadLinkIndex(pool, row.owner_id);
+ return rewriteRelativeDownloadLinks(html, {
+ htmlRelativePath: inferWorkspaceHtmlRelativePath({
+ pageTitle: row.title,
+ htmlContent: html,
+ publishDir,
+ }),
+ publishDir,
+ linkIndex,
+ publishKey: row.owner_id,
+ publicBaseUrl: resolvePublicBaseUrl(),
+ preferAssetDownload: false,
+ }).html;
+ };
+
const resolvePublic = async (ownerSlug, urlSlug, viewerId, password, requestMeta) => {
const [rows] = await pool.query(
- `SELECT pr.*, u.id AS owner_id, av.storage_key
+ `SELECT pr.*, u.id AS owner_id, p.title, av.storage_key
FROM h5_publish_records pr
JOIN h5_users u ON u.id = pr.user_id
+ JOIN h5_page_records p ON p.id = pr.page_id
JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1
JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1
WHERE COALESCE(u.slug, u.username) = ? AND pr.url_slug = ? AND pr.status = 'online'
@@ -899,9 +939,10 @@ export function createPublicationService(pool, options = {}) {
const resolvePrivateLink = async (token, viewerId, requestMeta) => {
const tokenHash = crypto.createHash('sha256').update(String(token)).digest('hex');
const [rows] = await pool.query(
- `SELECT pr.*, u.id AS owner_id, av.storage_key
+ `SELECT pr.*, u.id AS owner_id, p.title, av.storage_key
FROM h5_publish_records pr
JOIN h5_users u ON u.id = pr.user_id
+ JOIN h5_page_records p ON p.id = pr.page_id
JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1
JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1
WHERE pr.token_hash = ? AND pr.status = 'online' AND pr.access_mode = 'private_link'
diff --git a/mindspace-visual-editor.test.mjs b/mindspace-visual-editor.test.mjs
index 5fb7ab2..eb3d52d 100644
--- a/mindspace-visual-editor.test.mjs
+++ b/mindspace-visual-editor.test.mjs
@@ -14,6 +14,18 @@ test('buildEditablePreviewDocument injects visual editor into body', () => {
assert.match(result, /data-mindspace-editing/);
assert.match(result, /Hi<\/p>/);
assert.match(result, new RegExp(MINDSPACE_PAGE_CONTENT_MESSAGE.replace(':', '\\:')));
+ assert.match(result, /data-collapsed/);
+ assert.match(result, /5000/);
+ assert.match(result, /点击展开/);
+ assert.match(result, /isProtectedNode/);
+});
+
+test('buildEditablePreviewDocument normalizes platform brand contact line', () => {
+ const html = '
📧 contact@tkmind.ai
';
+ const result = buildEditablePreviewDocument(html);
+ assert.match(result, /contact@tkmind\.cn/);
+ assert.match(result, /data-mindspace-page-tag="platform-brand"/);
+ assert.doesNotMatch(result, /tkmind\.ai/i);
});
test('buildEditablePreviewDocument wraps fragment html', () => {
diff --git a/mindspace.mjs b/mindspace.mjs
index 3caca80..d3e3538 100644
--- a/mindspace.mjs
+++ b/mindspace.mjs
@@ -181,14 +181,14 @@ export function createMindSpaceService(pool, options = {}) {
c.ai_access_policy, c.publish_policy, c.is_system, c.sort_order,
CASE
WHEN c.category_code = 'draft' THEN COALESCE(pc.item_count, 0)
- WHEN c.category_code = 'public' THEN COALESCE(pub.item_count, 0)
+ WHEN c.category_code IN ('oa', 'public') THEN COALESCE(pc.item_count, 0) + COALESCE(ac.item_count, 0)
ELSE COALESCE(ac.item_count, 0)
END AS item_count
FROM h5_space_categories c
LEFT JOIN (
SELECT category_id, user_id, COUNT(*) AS item_count
FROM h5_assets
- WHERE user_id = ? AND status <> 'deleted' AND source_type = 'upload'
+ WHERE user_id = ? AND status <> 'deleted'
GROUP BY category_id, user_id
) ac ON ac.category_id = c.id AND ac.user_id = c.user_id
LEFT JOIN (
@@ -197,15 +197,9 @@ export function createMindSpaceService(pool, options = {}) {
WHERE user_id = ? AND status <> 'deleted'
GROUP BY category_id, user_id
) pc ON pc.category_id = c.id AND pc.user_id = c.user_id
- LEFT JOIN (
- SELECT user_id, COUNT(DISTINCT page_id) AS item_count
- FROM h5_publish_records
- WHERE user_id = ? AND status = 'online'
- GROUP BY user_id
- ) pub ON pub.user_id = c.user_id
WHERE c.user_id = ? AND c.space_id = ?
ORDER BY c.sort_order, c.category_name`,
- [userId, userId, userId, userId, row.id],
+ [userId, userId, userId, row.id],
);
const quotaBytes = asNumber(row.quota_bytes);
diff --git a/mindspace.test.mjs b/mindspace.test.mjs
index f6a7cf0..6f21fde 100644
--- a/mindspace.test.mjs
+++ b/mindspace.test.mjs
@@ -105,8 +105,9 @@ test('getSpace scopes space and categories to the authenticated user', async ()
assert.equal(space.quota.availableBytes, 5 * 1024 * 1024 - 3072);
assert.equal(space.categories[0].code, 'private');
assert.deepEqual(calls[0].params, ['user-1']);
- assert.deepEqual(calls[1].params, ['user-1', 'user-1', 'user-1', 'user-1', 'space-1']);
- assert.match(calls[1].sql, /source_type\s*=\s*'upload'/);
+ assert.deepEqual(calls[1].params, ['user-1', 'user-1', 'user-1', 'space-1']);
+ assert.match(calls[1].sql, /WHEN c\.category_code IN \('oa', 'public'\) THEN COALESCE\(pc\.item_count, 0\) \+ COALESCE\(ac\.item_count, 0\)/);
+ assert.doesNotMatch(calls[1].sql, /source_type\s*=\s*'upload'/);
});
test('getSpace includes schedule snapshot when schedule service is available', async () => {
diff --git a/public/brand/tkmind-icon.png b/public/brand/tkmind-icon.png
new file mode 100644
index 0000000..072f9ab
Binary files /dev/null and b/public/brand/tkmind-icon.png differ
diff --git a/public/dev/wechat-share-demo.html b/public/dev/wechat-share-demo.html
new file mode 100644
index 0000000..2b5f0a7
--- /dev/null
+++ b/public/dev/wechat-share-demo.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+ 微信分享预览 · 连云港三天两夜攻略 🌊
+
+
+
+
+
微信链接卡片预览
+
本地模拟的是「粘贴链接后看到的卡片」,不是 JS-SDK 内部分享弹层。源链接:https://m.tkmind.cn/MindSpace/john/public/lianyungang-guide.html
+
修复后:标题 + 描述 + 缩略图 + 底部「TKMind 智趣」小图标应同时出现。
+
+
+
连云港三天两夜攻略 🌊
+
三天两夜·沙滩酒店·必吃美食
+
+
+
+
+
+
+
+
\ 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 (
+
+
+
+ );
+}
diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx
index 1ca3883..f2ce29b 100644
--- a/src/components/ChatPanel.tsx
+++ b/src/components/ChatPanel.tsx
@@ -9,6 +9,7 @@ import {
} from '../utils/imageUpload';
import { AvatarPicker } from './AvatarPicker';
import { ChatSkillPicker } from './ChatSkillPicker';
+import { ChatLoadingSpinner } from './ChatLoadingSpinner';
import { DesignSkillPanel } from './DesignSkillPanel';
import { MessageList } from './MessageList';
import { PageSaveDialog } from './PageSaveDialog';
@@ -292,13 +293,15 @@ export function ChatPanel({
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
- : chatState === 'connecting'
+ : compact
+ ? '随时召唤你的小助手吧'
+ : randomPrompt;
+ const connectStatusText =
+ chatState === 'connecting'
? '正在连接会话…'
: chatState === 'waiting'
? '消息已收到,正在连接后台…'
- : compact
- ? '随时召唤你的小助手吧'
- : randomPrompt;
+ : null;
const mergeVoiceText = (spoken: string) => {
const base = voiceBaseRef.current.trimEnd();
@@ -609,6 +612,12 @@ export function ChatPanel({
)}