feat(mindspace): 0630004 空间 UI、聊天连接、微信分享与 Agent 能力

含 MindSpace 三列布局与统计修复、聊天加载态与连接降级、平台页脚标记与 og:site_name 微信卡片、勾选资料删除 Agent 接口及内部话术过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-30 09:30:51 +08:00
parent b1b8d3afc6
commit 722b18326f
53 changed files with 2450 additions and 313 deletions
+204
View File
@@ -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();
}
+77
View File
@@ -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();
+2
View File
@@ -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');
+59
View File
@@ -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);
});