Add smart ACK provider for WeChat MP replies

Replace fixed ackText with a rule-based AckProvider that picks
response templates by message type and intent (translate, summary,
rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync,
zero I/O, auto-falls back to config.ackText on any error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
john
2026-06-26 15:19:03 +08:00
parent 9ed4fd48d7
commit 9b4a25799f
162 changed files with 17276 additions and 2054 deletions
+549 -28
View File
@@ -29,7 +29,8 @@ import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
import { attachRequestId, sendData, sendError } from './api-response.mjs';
import { createMindSpaceAuditWriter } from './mindspace-audit.mjs';
import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
import { createMindSpaceService } from './mindspace.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 } from './mindspace-pages.mjs';
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
@@ -49,7 +50,9 @@ import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs';
import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs';
import { createPlazaSeoService } from './plaza-seo.mjs';
import { createPlazaOpsService } from './plaza-ops.mjs';
import { createWordFilterService } from './word-filter.mjs';
import {
allowPlazaEmbedFrame,
preparePublicationHtmlForEmbed,
isPlazaEmbedRequest,
publishedPageCspForEmbed,
@@ -66,15 +69,21 @@ import {
buildWorkspaceBaseHref,
buildWorkspaceThumbnailUrl,
injectHtmlBaseHref,
resolveClosestHtmlRelativePath,
resolveChatSaveAnalysis,
resolveStaticHtmlContent,
} from './mindspace-chat-save.mjs';
import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
import { injectOgTags } from './mindspace-og-tags.mjs';
import { ensureThumbnailPng, thumbnailPngPathForSvg } from './mindspace-thumbnail-png.mjs';
import {
ensureThumbnailPng,
rasterizeThumbnailSvgToPng,
thumbnailPngPathForSvg,
} from './mindspace-thumbnail-png.mjs';
import { scanContent } from './mindspace-content-scan.mjs';
import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs';
import { createRechargeService } from './billing-recharge.mjs';
import { createSubscriptionService, createPlanCatalogService, ensurePlanCatalogSchema, PLAN_CATALOG } from './billing-subscription.mjs';
import {
createWechatPayClient,
loadWechatPayConfig,
@@ -85,6 +94,7 @@ import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs';
import { createScheduleService } from './schedule-service.mjs';
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
import { createSessionSnapshotService } from './session-snapshot.mjs';
import { attachAsrRoutes } from './asr-proxy.mjs';
import { isNativeH5ApiPath } from './policies.mjs';
@@ -174,7 +184,7 @@ const jsonUnlessMultipart = (req, res, next) => {
};
const rawUploadBody = express.raw({
type: 'application/octet-stream',
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
});
const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db'));
@@ -186,6 +196,7 @@ if (ACCESS_PASSWORD) {
let userAuth = null;
let tkmindProxy = null;
let sessionSnapshotService = null;
let mindSpace = null;
let mindSpaceAssets = null;
let mindSpaceAudit = null;
@@ -204,12 +215,14 @@ let mindSpaceCleanup = null;
let mindSpaceAgentJobs = null;
let mindSpaceAgentRunner = null;
let rechargeService = null;
let subscriptionService = null;
let wechatPayClient = null;
let wechatOAuthService = null;
let wechatMpService = null;
let scheduleService = null;
let scheduleReminderWorker = null;
let llmProviderService = null;
let wordFilterService = null;
let authPool = null;
async function bootstrapUserAuth() {
@@ -218,17 +231,24 @@ async function bootstrapUserAuth() {
const pool = createDbPool();
authPool = pool;
await initSchema(pool);
await ensureMindSpaceConfig(pool, {
env: process.env,
});
scheduleService = createScheduleService(pool, {
defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
});
mindSpace = createMindSpaceService(pool, {
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
aiDailyLimit: Number(process.env.MINDSPACE_FREE_AI_DAILY_LIMIT ?? 10),
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
monthlyViewLimit: Number(process.env.MINDSPACE_FREE_MONTHLY_VIEW_LIMIT ?? 1000),
scheduleService,
});
mindSpaceAssets = createAssetService(pool, {
h5Root: __dirname,
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
});
mindSpacePages = createPageService(pool, {
h5Root: __dirname,
@@ -297,13 +317,17 @@ async function bootstrapUserAuth() {
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
h5Root: __dirname,
});
await ensurePlanCatalogSchema(pool);
const planCatalogService = createPlanCatalogService(pool);
subscriptionService = createSubscriptionService(pool, {
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
});
subscriptionService._planCatalogService = planCatalogService;
userAuth = createUserAuth(pool, {
usersRoot: USERS_ROOT,
h5Root: __dirname,
defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500),
});
scheduleService = createScheduleService(pool, {
defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
subscriptionService,
});
wechatPayClient = createWechatPayClient(loadWechatPayConfig());
wechatOAuthService = createWechatOAuthService(pool, loadWechatOAuthConfig(), { userAuth });
@@ -321,7 +345,7 @@ async function bootstrapUserAuth() {
pageService: mindSpacePages,
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
});
mindSpaceAgentRunner = createMindSpaceAgentRunner({
apiTarget: API_TARGET,
@@ -358,6 +382,7 @@ async function bootstrapUserAuth() {
apiTarget: API_TARGET,
apiSecret: API_SECRET,
});
wordFilterService = createWordFilterService(pool);
void llmProviderService
.ensureBootstrapRelay()
.then((result) => {
@@ -371,12 +396,21 @@ async function bootstrapUserAuth() {
void llmProviderService.syncSelectedToGoosed().catch((err) => {
console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err);
});
sessionSnapshotService = createSessionSnapshotService(pool);
tkmindProxy = createTkmindProxy({
apiTarget: API_TARGET,
apiTargets: API_TARGETS,
apiSecret: API_SECRET,
userAuth,
llmProviderService,
subscriptionService,
localFetchAsset: mindSpaceAssets
? async (userId, assetId) => {
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId);
const buffer = await fs.promises.readFile(assetPath);
return { buffer, mimeType: asset.mimeType };
}
: null,
});
wechatMpService = createWechatMpService({
config: WECHAT_MP_CONFIG,
@@ -387,6 +421,11 @@ async function bootstrapUserAuth() {
return tkmindProxy.apiFetchTo(target, pathname, init);
},
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId),
});
userAuth.setRechargeNotifier(async ({ userId, title, body }) => {
if (!wechatMpService?.enabled) return;
await wechatMpService.sendTextToUser(userId, `${title}\n${body}`.trim());
});
if (
process.env.H5_REMINDER_WORKER_ENABLED === '1' &&
@@ -399,6 +438,20 @@ async function bootstrapUserAuth() {
});
console.log('Schedule reminder worker enabled');
}
if (subscriptionService) {
const subExpiryTimer = setInterval(async () => {
try {
const { renewed, failed } = await subscriptionService.processAutoRenewals();
if (renewed > 0) console.log(`Auto-renewed ${renewed} subscription(s)`);
if (failed > 0) console.log(`Auto-renew failed for ${failed} subscription(s) (balance insufficient)`);
const n = await subscriptionService.expireStaleSubscriptions();
if (n > 0) console.log(`Expired ${n} stale subscription(s)`);
} catch (err) {
console.warn('Subscription expiry check failed:', err);
}
}, 60 * 60 * 1000); // hourly
subExpiryTimer.unref?.();
}
mindSpacePageEditSession = createPageEditSessionService({
apiTarget: API_TARGET,
apiSecret: API_SECRET,
@@ -830,12 +883,13 @@ app.get('/auth/me', async (req, res) => {
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const paths = await userAuth.listPathGrants(me.id);
const capabilityState = await userAuth.resolveUserCapabilities(
await userAuth.getUserById(me.id),
);
const [paths, capabilityState, subscription] = await Promise.all([
userAuth.listPathGrants(me.id),
userAuth.resolveUserCapabilities(await userAuth.getUserById(me.id)),
subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null,
]);
return res.json({
user: me,
user: { ...me, subscription },
paths,
capabilities: capabilityState.capabilities,
grantedSkills: capabilityState.grantedSkills ?? [],
@@ -867,6 +921,139 @@ app.get('/auth/usage', async (req, res) => {
res.json({ records });
});
app.get('/auth/notifications', async (req, res) => {
await userAuthReady;
if (!userAuth || !scheduleService) {
return res.status(503).json({ message: '通知服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const rawStatus = typeof req.query?.status === 'string' ? req.query.status : 'unread';
const status = ['all', 'unread', 'read'].includes(rawStatus) ? rawStatus : 'all';
const limit = Math.min(Math.max(Number(req.query?.limit) || 20, 1), 100);
try {
const notifications = await scheduleService.listUserNotifications({ userId: me.id, status, limit });
res.json({ notifications });
} catch (err) {
console.warn('List user notifications failed:', err instanceof Error ? err.message : err);
res.status(500).json({ message: '通知列表加载失败' });
}
});
app.get('/auth/notifications/events', async (req, res) => {
await userAuthReady;
if (!userAuth || !scheduleService) {
return res.status(503).json({ message: '通知服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
res.status(200);
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();
let closed = false;
let lastNotificationId = null;
const sendEvent = (event, data) => {
if (closed || res.destroyed) return;
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
const checkUnread = async () => {
if (closed) return;
try {
const notifications = await scheduleService.listUserNotifications({
userId: me.id,
status: 'unread',
limit: 1,
});
const latest = notifications[0] ?? null;
const nextId = latest?.id ?? null;
if (nextId && nextId !== lastNotificationId) {
lastNotificationId = nextId;
sendEvent('notification', { notification: latest });
} else if (!nextId) {
lastNotificationId = null;
}
} catch {
sendEvent('sync', { reason: 'check_failed' });
}
};
sendEvent('ready', { ok: true });
await checkUnread();
const checkTimer = setInterval(() => {
void checkUnread();
}, 2500);
const keepaliveTimer = setInterval(() => {
sendEvent('ping', { at: Date.now() });
}, 25000);
req.on('close', () => {
closed = true;
clearInterval(checkTimer);
clearInterval(keepaliveTimer);
});
});
app.post('/auth/notifications/:id/read', async (req, res) => {
await userAuthReady;
if (!userAuth || !scheduleService) {
return res.status(503).json({ message: '通知服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const ok = await scheduleService.markUserNotificationRead({
userId: me.id,
notificationId: req.params.id,
});
if (!ok) return res.status(404).json({ message: '通知不存在或已读' });
res.json({ ok: true });
});
app.post('/auth/notifications/read-all', async (req, res) => {
await userAuthReady;
if (!userAuth || !scheduleService) {
return res.status(503).json({ message: '通知服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const updated = await scheduleService.markAllUserNotificationsRead({ userId: me.id });
res.json({ ok: true, updated });
});
app.delete('/auth/notifications/:id', async (req, res) => {
await userAuthReady;
if (!userAuth || !scheduleService) {
return res.status(503).json({ message: '通知服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const ok = await scheduleService.deleteUserNotification({
userId: me.id,
notificationId: req.params.id,
});
if (!ok) return res.status(404).json({ message: '通知不存在' });
res.status(204).end();
});
app.delete('/auth/notifications', async (req, res) => {
await userAuthReady;
if (!userAuth || !scheduleService) {
return res.status(503).json({ message: '通知服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const status = typeof req.query?.status === 'string' ? req.query.status : 'all';
const deleted = await scheduleService.clearUserNotifications({ userId: me.id, status });
res.json({ ok: true, deleted });
});
app.get('/auth/billing/ledger', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
@@ -888,8 +1075,133 @@ app.get('/auth/billing/config', async (req, res) => {
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const config = await rechargeService.getBillingConfig(me.id);
return res.json(config);
const [config, sub] = await Promise.all([
rechargeService.getBillingConfig(me.id),
subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null,
]);
return res.json({ ...config, subscription: sub });
});
app.get('/auth/billing/subscription', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const sub = subscriptionService
? await subscriptionService.getActiveSubscription(me.id)
: null;
const planCatalog = subscriptionService?._planCatalogService;
const plans = (!sub && planCatalog)
? await planCatalog.listPlans({ includeInactive: false })
: sub ? undefined : PLAN_CATALOG;
return res.json({ subscription: sub, plans });
});
app.get('/auth/billing/plans', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const planCatalog = subscriptionService?._planCatalogService;
const plans = planCatalog
? (await planCatalog.listPlans({ includeInactive: false }))
.filter((p) => p.priceCents > 0)
.map((p) => ({ key: p.planType, ...p }))
: Object.entries(PLAN_CATALOG)
.filter(([, plan]) => plan.priceCents > 0)
.map(([key, plan]) => ({ key, ...plan }));
const [sub, wallet] = await Promise.all([
subscriptionService ? subscriptionService.getActiveSubscription(me.id) : null,
userAuth.getUserById(me.id),
]);
return res.json({
plans,
subscription: sub,
balanceCents: wallet ? Number(wallet.balance_cents ?? 0) : 0,
});
});
app.post('/auth/billing/subscribe', jsonBody, async (req, res) => {
await userAuthReady;
if (!userAuth || !subscriptionService) {
return res.status(503).json({ message: '未启用订阅系统' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
if (me.status === 'disabled') return res.status(403).json({ message: '账户已禁用' });
const { planType, autoRenew = false } = req.body ?? {};
if (!planType || typeof planType !== 'string') {
return res.status(400).json({ message: '请选择套餐' });
}
const result = await subscriptionService.purchaseSubscription(me.id, planType, Boolean(autoRenew));
if (!result.ok) {
if (result.code === 'INSUFFICIENT_BALANCE') {
return res.status(402).json({
message: result.message,
code: result.code,
balanceCents: result.balanceCents,
requiredCents: result.requiredCents,
shortfallCents: result.shortfallCents,
});
}
if (result.code === 'DOWNGRADE_NOT_ALLOWED') {
return res.status(409).json({
message: result.message,
code: result.code,
currentPlanType: result.currentPlanType,
});
}
return res.status(400).json({ message: result.message });
}
return res.json({ subscription: result.subscription, balanceCents: result.balanceCents });
});
app.post('/auth/billing/space-purchase', jsonBody, async (req, res) => {
await userAuthReady;
if (!userAuth || !mindSpace) {
return res.status(503).json({ message: '未启用空间系统' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const sizeMb = Number(req.body?.sizeMb);
const result = await userAuth.purchaseSpaceQuota(me.id, sizeMb);
if (!result.ok) {
if (result.code === 'INSUFFICIENT_BALANCE') {
return res.status(402).json({
message: result.message,
code: result.code,
details: {
code: 'INSUFFICIENT_BALANCE',
balanceCents: result.balanceCents,
minRechargeCents: result.minRechargeCents,
suggestedTiers: result.suggestedTiers,
},
});
}
return res.status(400).json({ message: result.message });
}
const quota = await mindSpace.getQuota(me.id);
return res.json({
quota: quota ?? result.quota,
balanceCents: result.balanceCents,
purchasedMb: sizeMb,
costCents: sizeMb * 200,
});
});
app.post('/auth/billing/auto-renew', jsonBody, async (req, res) => {
await userAuthReady;
if (!userAuth || !subscriptionService) {
return res.status(503).json({ message: '未启用订阅系统' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const { enabled } = req.body ?? {};
if (typeof enabled !== 'boolean') {
return res.status(400).json({ message: '请传入 enabled: true/false' });
}
const result = await subscriptionService.setAutoRenew(me.id, enabled);
return res.json(result);
});
app.post('/auth/billing/recharge-orders', jsonBody, async (req, res) => {
@@ -980,18 +1292,21 @@ if (WECHAT_MP_CONFIG.enabled) {
if (!wechatMpService?.enabled) {
return res.status(503).send('wechat mp disabled');
}
if (!wechatMpService.verifyRequest(req.query)) {
const result = wechatMpService.verifyUrlChallenge(req.query);
if (!result.ok) {
console.warn('WeChat MP verify failed:', {
encryptType: req.query.encrypt_type ?? null,
timestamp: req.query.timestamp ?? null,
nonce: req.query.nonce ?? null,
});
return res.status(403).send('invalid signature');
return res.status(result.status ?? 403).send(result.body ?? 'invalid signature');
}
console.log('WeChat MP verify ok:', {
encryptType: req.query.encrypt_type ?? null,
timestamp: req.query.timestamp ?? null,
nonce: req.query.nonce ?? null,
});
return res.type('text/plain').send(String(req.query.echostr ?? ''));
return res.type('text/plain').send(String(result.body ?? ''));
});
app.post('/webhooks/wechat-mp/messages', wechatMpBody, async (req, res) => {
@@ -1125,6 +1440,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 === '/config/blocked-words') return next();
const plazaPublic = isPlazaPublicRead(req.path, req.method);
@@ -1149,6 +1465,13 @@ api.use(async (req, res, next) => {
attachAsrRoutes(api, { sendError, sendData });
api.get('/config/blocked-words', async (_req, res) => {
await userAuthReady;
if (!wordFilterService) return res.json({ words: [] });
const words = await wordFilterService.listAllForFrontend();
res.json({ words });
});
api.get('/status', async (_req, res, next) => {
await userAuthReady;
if (userAuth && tkmindProxy) {
@@ -2466,6 +2789,30 @@ api.post('/mindspace/v1/pages/:pageId/redact', async (req, res) => {
}
});
api.post('/mindspace/v1/pages/:pageId/publish-fix', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePages.localizePrivateResources(req.currentUser.id, req.params.pageId, {
pageVersionId: req.body?.page_version_id,
expectedVersion: req.body?.expected_version,
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
});
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'page.publish_fix',
objectType: 'page',
objectId: result.page.id,
ip: req.ip,
riskLevel: result.originalScan.riskLevel,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/redacted-copy', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
@@ -2892,6 +3239,67 @@ api.get('/sessions', async (req, res, next) => {
return runHandlerChain(tkmindProxy.handlers['GET /sessions'], req, res, next);
});
// Session detail — serve from DB snapshot cache when fresh, fall through to Goose on miss.
api.get('/sessions/:sessionId', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
const sessionId = req.params.sessionId;
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
// Hints from the client (session list already has these values).
const hintMc = req.query.hint_mc ? Number(req.query.hint_mc) : null;
const hintUa = req.query.hint_ua ? String(req.query.hint_ua) : null;
try {
if (sessionSnapshotService?.isEnabled()) {
const snapshot = await sessionSnapshotService.get(sessionId);
if (snapshot) {
const mcMatch = hintMc == null || snapshot.meta.synced_msg_count === hintMc;
const uaMatch = hintUa == null || snapshot.meta.source_updated_at === hintUa;
if (mcMatch && uaMatch) {
// Cache hit — reconstruct a Goose-compatible session response.
const cachedGooseSession = {
...snapshot.session,
// Embed only userVisible messages so getSession callers still work.
conversation: snapshot.messages,
};
return res.json(cachedGooseSession);
}
}
}
} catch {
// Snapshot read error: fall through silently to Goose.
}
// Cache miss — proxy to Goose and write-through on success.
try {
const target = await tkmindProxy.resolveTarget(sessionId);
const upstream = await tkmindProxy.apiFetchTo(
target,
`/sessions/${encodeURIComponent(sessionId)}`,
{ method: 'GET' },
);
if (!upstream.ok) {
const text = await upstream.text().catch(() => '');
return res.status(upstream.status).send(text);
}
const gooseSession = await upstream.json();
// Write-through: persist snapshot async, don't block the response.
if (sessionSnapshotService?.isEnabled()) {
const messages = (gooseSession.conversation ?? [])
.filter((m) => m.metadata?.userVisible);
void sessionSnapshotService
.save(sessionId, req.currentUser.id, gooseSession, messages)
.catch(() => {});
}
return res.json(gooseSession);
} catch (err) {
return res.status(502).json({ message: err instanceof Error ? err.message : '读取会话失败' });
}
});
api.delete('/sessions/:sessionId', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
@@ -2910,6 +3318,8 @@ api.delete('/sessions/:sessionId', async (req, res, next) => {
return res.status(upstream.status).send(text || '删除会话失败');
}
await userAuth.unregisterAgentSession(req.currentUser.id, sessionId);
// Remove snapshot so it doesn't linger after deletion.
void sessionSnapshotService?.remove(sessionId).catch(() => {});
return res.status(204).end();
} catch (err) {
return res.status(500).json({ message: err instanceof Error ? err.message : '删除会话失败' });
@@ -2924,7 +3334,15 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
return tkmindProxy.proxySessionEvents(req, res, sessionId);
// After Finish, async-refresh snapshot so next open is a cache hit.
const onAfterFinish = sessionSnapshotService?.isEnabled()
? (sid, uid) =>
sessionSnapshotService.refresh(sid, uid, async (pathname, init) => {
const target = await tkmindProxy.resolveTarget(sid);
return tkmindProxy.apiFetchTo(target, pathname, init);
})
: null;
return tkmindProxy.proxySessionEvents(req, res, sessionId, { onAfterFinish });
});
api.use(async (req, res, next) => {
@@ -2956,6 +3374,20 @@ api.use(async (req, res, next) => {
suggestedTiers: gate.suggestedTiers,
});
}
try {
await tkmindProxy.reconcileSessionPolicyForUser(req.currentUser.id, sessionId);
} catch (err) {
console.warn(
'Session policy sync before reply failed:',
err instanceof Error ? err.message : err,
);
return res.status(500).json({
message:
err instanceof Error
? `会话策略同步失败:${err.message}`
: '会话策略同步失败',
});
}
if (llmProviderService) {
try {
await tkmindProxy.applySessionLlmProvider(sessionId);
@@ -3201,8 +3633,8 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
</head>
<body>
<div class="publication-shell">
<!-- Game-like publications need script execution inside the shell iframe. -->
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}" sandbox="allow-same-origin allow-scripts"></iframe>
<!-- Game-like publications need script execution, but not same-origin sandbox escape. -->
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}" sandbox="allow-scripts"></iframe>
</div>
<button type="button" class="publication-share-fab" id="publication-share-fab">分享</button>
<div class="publication-share-sheet" id="publication-share-sheet" hidden>
@@ -3367,12 +3799,17 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
});
}
function syncFrameHeight() {
if (!frame || !frame.contentDocument || !frame.contentDocument.body) return;
if (!frame) return;
var doc = frame.contentDocument;
var body = doc && doc.body;
var root = doc && doc.documentElement;
var height = Math.max(
doc.documentElement ? doc.documentElement.scrollHeight : 0,
doc.body.scrollHeight,
window.innerHeight
body ? body.scrollHeight : 0,
body ? body.offsetHeight : 0,
root ? root.scrollHeight : 0,
root ? root.offsetHeight : 0,
window.innerHeight,
320
);
frame.style.height = height + 'px';
}
@@ -3384,7 +3821,9 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
}
window.addEventListener('message', function (event) {
if (!event || !event.data || event.data.type !== 'plaza:embed-section' || !frame) return;
if (event.data.height) frame.style.height = Math.max(Number(event.data.height) || 0, window.innerHeight) + 'px';
if (event.data.height) {
frame.style.height = Math.max(Number(event.data.height) || 0, window.innerHeight, 320) + 'px';
}
});
window.addEventListener('resize', syncFrameHeight);
})();
@@ -3397,6 +3836,7 @@ function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}
let html = result.html;
if (embed) {
html = preparePublicationHtmlForEmbed(html);
allowPlazaEmbedFrame(res);
} else if (raw) {
html = stripPublicationHtmlCspMeta(html);
}
@@ -3578,6 +4018,36 @@ async function resolvePublishedRoute(req, res, password = null) {
}
}
app.use(async (req, res, next) => {
const thumbnailMatch = /^\/u\/([^/]+)\/pages\/([^/]+)\.thumbnail\.png$/.exec(req.path);
if (!thumbnailMatch) return next();
const ownerSlug = thumbnailMatch[1];
const urlSlug = thumbnailMatch[2];
await userAuthReady;
if (!authPool || !mindSpacePages) return res.status(503).send('MindSpace 未启用');
try {
const [rows] = await authPool.query(
`SELECT pr.user_id, pr.page_id
FROM h5_publish_records pr
JOIN h5_users u ON u.id = pr.user_id
WHERE COALESCE(u.slug, u.username) = ?
AND pr.url_slug = ?
AND pr.status = 'online'
ORDER BY pr.published_at DESC
LIMIT 1`,
[ownerSlug, urlSlug],
);
const row = rows[0];
if (!row) return res.status(404).send('缩略图不存在');
const svg = await mindSpacePages.renderThumbnail(row.user_id, row.page_id);
res.set('Content-Type', 'image/png');
res.set('Cache-Control', 'public, max-age=300');
return res.send(rasterizeThumbnailSvgToPng(svg));
} catch (error) {
return res.status(500).send('缩略图加载失败');
}
});
app.get('/u/:ownerSlug/pages/:urlSlug', async (req, res) => {
return resolvePublishedRoute(req, res);
});
@@ -3663,6 +4133,12 @@ function sendPublishFile(req, res, filePath) {
res.status(404).json({ message: '文件不存在' });
return;
}
const embed = isPlazaEmbedRequest(req.query);
if (embed) {
html = preparePublicationHtmlForEmbed(html);
allowPlazaEmbedFrame(res);
res.set('Content-Security-Policy', publishedPageCsp(html, { embed }));
}
const host = (req.headers['x-forwarded-host'] || req.headers.host || '').toString().split(',')[0].trim();
// Share cards (esp. WeChat) require https og:image. The edge only serves public domains
// over https, but the proxy chain forwards X-Forwarded-Proto: http to the node — so for a
@@ -3701,6 +4177,34 @@ function sendPublishFile(req, res, filePath) {
res.send(html);
}
const MISPLACED_PUBLIC_HTML_NAME = /^[a-z0-9][a-z0-9._-]{0,127}\.html$/i;
async function recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest) {
if (rest.length !== 2 || rest[0] !== PUBLIC_ZONE_DIR) return null;
const filename = rest[1];
if (!MISPLACED_PUBLIC_HTML_NAME.test(filename)) return null;
const destination = path.resolve(targetDir, PUBLIC_ZONE_DIR, filename);
if (!destination.startsWith(`${resolvedRoot}${path.sep}`)) return null;
const candidates = [
path.resolve(__dirname, filename),
path.resolve(__dirname, PUBLIC_ZONE_DIR, filename),
];
for (const candidate of candidates) {
if (candidate === destination) continue;
if (!candidate.startsWith(`${__dirname}${path.sep}`)) continue;
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) continue;
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.copyFileSync(candidate, destination);
await ensureWorkspaceHtmlThumbnail(targetDir, `${PUBLIC_ZONE_DIR}/${filename}`).catch(() => {});
console.warn(
`[MindSpace] recovered misplaced public HTML ${path.relative(__dirname, candidate)} -> ${path.relative(__dirname, destination)}`,
);
return destination;
}
return null;
}
async function serveUserPublishFile(req, res, next) {
const parts = req.path.split('/').filter(Boolean);
if (parts.length < 1) {
@@ -3759,10 +4263,27 @@ async function serveUserPublishFile(req, res, next) {
fs.existsSync(publicFallback) &&
fs.statSync(publicFallback).isFile()
) {
sendPublishFile(req, res, publicFallback);
const canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${PUBLIC_ZONE_DIR}/${encodeURIComponent(rest[0])}`;
res.redirect(301, canonical);
return;
}
}
if (rest.length === 2 && rest[0] === PUBLIC_ZONE_DIR && rest[1].toLowerCase().endsWith('.html')) {
const similarRelativePath = await resolveClosestHtmlRelativePath(targetDir, `${PUBLIC_ZONE_DIR}/${rest[1]}`);
if (similarRelativePath) {
const canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${similarRelativePath
.split('/')
.map((part) => encodeURIComponent(part))
.join('/')}`;
res.redirect(301, canonical);
return;
}
}
const recoveredPublicHtml = await recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest);
if (recoveredPublicHtml) {
sendPublishFile(req, res, recoveredPublicHtml);
return;
}
res.status(404).json({ message: '文件不存在' });
return;
}