feat(mindspace): 0630004 空间 UI、聊天连接、微信分享与 Agent 能力
含 MindSpace 三列布局与统计修复、聊天加载态与连接降级、平台页脚标记与 og:site_name 微信卡片、勾选资料删除 Agent 接口及内部话术过滤。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user