import express from 'express';
import crypto from 'node:crypto';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import { createProxyMiddleware } from 'http-proxy-middleware';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
AUTH_COOKIE,
clearSessionCookie,
createAuthManager,
parseCookies,
sessionCookie,
} from './auth.mjs';
import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs';
import { createAgentRunGateway } from './agent-run-gateway.mjs';
import { createToolGateway } from './tool-gateway.mjs';
import {
createAgentRunEventsHandler,
createGetAgentRunHandler,
createPostAgentRunsHandler,
} from './agent-run-routes.mjs';
import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs';
import {
clearUserSessionCookie,
createUserAuth,
resolveCookieDomainForRequest,
USER_COOKIE,
userLoginCookies,
userSessionCookie,
} from './user-auth.mjs';
import { createWikiAuth } from './wiki-auth.mjs';
import { isLocalDevHostname } from './scripts/local-test-config.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir, resolvePublicBaseUrl } from './user-publish.mjs';
import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
import { attachRequestId, sendData, sendError } from './api-response.mjs';
import { createNotificationDispatcher } from './notification-dispatcher.mjs';
import { createMindSpaceAuditWriter } from './mindspace-audit.mjs';
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, 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';
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
import { createPlazaEventService } from './plaza-events.mjs';
import { createPlazaRecommendService } from './plaza-recommend.mjs';
import { createPlazaInteractionService } from './plaza-interactions.mjs';
import {
ensureAlgorithmConfig,
loadAlgorithmConfig,
recalculateHotScores,
} from './plaza-algorithm.mjs';
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,
stripPublicationHtmlCspMeta,
} from './plaza-embed.mjs';
import { createCleanupService } from './mindspace-cleanup.mjs';
import { createAgentJobService } from './mindspace-agent-jobs.mjs';
import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs';
import {
analyzeChatMessageForSave,
buildChatSavePreviewFrameUrl,
buildChatSaveThumbnailUrl,
buildWorkspaceAssetUrl,
buildWorkspaceBaseHref,
buildWorkspaceThumbnailUrl,
injectHtmlBaseHref,
resolveClosestHtmlRelativePath,
createAssetDataUriResolver,
materializePrivateAssetsInWorkspaceHtml,
repairMissingHtmlAssetReferences,
resolveChatSaveAnalysis,
resolveStaticHtmlContent,
} from './mindspace-chat-save.mjs';
import {
materializePublicHtmlWritesFromSessionEvent,
syncPublicHtmlAfterFinish,
} from './mindspace-public-finish-sync.mjs';
import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs';
import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
import {
extractSharePreviewMeta,
injectOgTags,
injectWechatShareBridge,
renderWechatSharePreviewHtml,
} from './mindspace-og-tags.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,
WECHAT_NOTIFY_SUCCESS_V2,
} from './wechat-pay.mjs';
import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs';
import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs';
import { validateWechatShareSignatureUrl } from './wechat-share.mjs';
import { createScheduleService } from './schedule-service.mjs';
import { createFeedbackService } from './user-feedback.mjs';
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
import { createSessionSnapshotService } from './session-snapshot.mjs';
import { createConversationMemoryService } from './conversation-memory.mjs';
import { createExperienceService } from './experience-service.mjs';
import { attachAsrRoutes } from './asr-proxy.mjs';
import { isNativeH5ApiPath } from './policies.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
function loadEnvFile(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;
}
}
loadEnvFile(path.join(__dirname, '../../.env.local'));
loadEnvFile(path.join(__dirname, '.env'));
function parseApiTargets() {
const csvTargets = (process.env.TKMIND_API_TARGETS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const legacyTargets = [
process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006',
process.env.TKMIND_API_TARGET_1,
].filter(Boolean);
return [...new Set([...csvTargets, ...legacyTargets])];
}
const PORT = Number(process.env.H5_PORT ?? 8081);
const HOST = String(process.env.H5_HOST ?? '127.0.0.1').trim() || '127.0.0.1';
const API_TARGETS = parseApiTargets();
const API_TARGET = API_TARGETS[0] ?? 'https://127.0.0.1:18006';
const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET;
const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD;
const WECHAT_MP_CONFIG = loadWechatMpConfig();
// 无状态前端节点(如 105,MindSpace 经 rclone 挂载)需设 MEMIND_WORKSPACE_MAINTENANCE=0,
// 否则启动时对挂载树做 readdir/递归 fs.watch 会占满 libuv 线程池导致 boot 卡死。
const WORKSPACE_MAINTENANCE_ENABLED = process.env.MEMIND_WORKSPACE_MAINTENANCE !== '0';
const USERS_ROOT = process.env.H5_USERS_ROOT ?? path.join(__dirname, 'users');
const app = express();
app.set('trust proxy', 1);
const isSecureRequest = (req) =>
req.secure || req.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https';
const msFlags = mindspaceFlags();
app.use(attachRequestId);
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(self), geolocation=()');
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
if (isSecureRequest(req)) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
next();
});
function csrfOriginCheck(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const host = req.get('host');
const origin = req.get('origin');
const referer = req.get('referer');
if (!origin && !referer) return next();
const requestHostname = (host ?? '').split(':')[0];
const allowed = [origin, referer].some((value) => {
if (!value) return false;
try {
const source = new URL(value);
if (source.host === host) return true;
return (
isLocalDevHostname(requestHostname) && isLocalDevHostname(source.hostname)
);
} catch {
return false;
}
});
if (!allowed) {
return sendError(res, req, 403, 'csrf_failed', '来源校验失败');
}
return next();
}
app.use('/api', csrfOriginCheck);
app.use('/auth', csrfOriginCheck);
const jsonBody = express.json({ limit: '1mb' });
const jsonUnlessMultipart = (req, res, next) => {
if ((req.headers['content-type'] ?? '').includes('multipart/form-data')) return next();
return jsonBody(req, res, next);
};
const rawUploadBody = express.raw({
type: 'application/octet-stream',
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
});
const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db'));
let legacyAuth = null;
if (ACCESS_PASSWORD && !isDatabaseConfigured()) {
legacyAuth = createAuthManager({ password: ACCESS_PASSWORD });
} else if (ACCESS_PASSWORD && isDatabaseConfigured()) {
console.log('H5_ACCESS_PASSWORD ignored: multi-user database auth is configured');
}
let userAuth = null;
let tkmindProxy = null;
let agentRunGateway = null;
let toolGateway = null;
let sessionSnapshotService = null;
let conversationMemoryService = null;
let mindSpace = null;
let mindSpaceAssets = null;
let mindSpaceAudit = null;
let mindSpacePages = null;
let mindSpacePageLiveEdit = null;
let mindSpaceAssetAgent = null;
let mindSpacePageEditSession = null;
let mindSpacePublications = null;
let plazaPosts = null;
let plazaEvents = null;
let plazaRecommend = null;
let plazaInteractions = null;
let plazaSeo = null;
let plazaOps = null;
let plazaRedis = createNoopPlazaRedis();
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 notificationDispatcher = null;
let scheduleService = null;
let feedbackService = null;
let scheduleReminderWorker = null;
let llmProviderService = null;
let wordFilterService = null;
let authPool = null;
async function bootstrapUserAuth() {
try {
if (!isDatabaseConfigured()) return false;
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',
});
feedbackService = createFeedbackService(pool);
mindSpace = createMindSpaceService(pool, {
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 ?? DEFAULT_MAX_FILE_BYTES),
});
mindSpacePages = createPageService(pool, {
h5Root: __dirname,
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,
});
mindSpaceAssetAgent = createAssetAgentService({
assetService: mindSpaceAssets,
resolveUserIdForAgentSession,
});
mindSpacePublications = createPublicationService(pool, {
h5Root: __dirname,
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
});
setInterval(async () => {
try {
const result = await mindSpacePublications.cleanupExpiredUnconfirmedPublications();
if (result.cleaned > 0) {
console.log(`[Publication Cleanup] Auto-privatized ${result.cleaned} expired unconfirmed publications`);
}
} catch (err) {
console.error('[Publication Cleanup Error]', err instanceof Error ? err.message : err);
}
}, 60 * 1000);
await ensureAlgorithmConfig(pool);
const plazaAlgorithmConfig = await loadAlgorithmConfig(pool);
plazaRedis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool);
if (plazaRedis.enabled) {
console.log('Plaza Redis enabled');
}
plazaSeo = createPlazaSeoService(pool);
plazaInteractions = createPlazaInteractionService(pool, {
formatPostRow,
plazaRedis,
});
plazaEvents = createPlazaEventService(pool);
plazaRecommend = createPlazaRecommendService(pool, {
eventService: plazaEvents,
formatPostRow,
loadViewerReactions: (viewerId, postIds) =>
plazaInteractions.loadViewerReactions(viewerId, postIds),
algorithmConfig: plazaAlgorithmConfig,
});
plazaPosts = createPlazaPostService(pool, {
loadViewerReactions: (viewerId, postIds) =>
plazaInteractions.loadViewerReactions(viewerId, postIds),
plazaRedis,
algorithmConfig: plazaAlgorithmConfig,
recommendService: plazaRecommend,
onPostPublished: (postId) => plazaSeo?.notifyPostPublished(postId),
loadFeaturedPosts: async (viewerId) => {
if (!plazaOps) return { homepage_banner: [], trending: [], category_top: {} };
return plazaOps.loadActiveFeaturedPosts(viewerId);
},
});
plazaOps = createPlazaOpsService(pool, {
formatPostRow,
reviewPost: (...args) => plazaPosts.reviewPost(...args),
invalidateFeedCaches: () => plazaRedis?.invalidateFeedCaches?.(),
});
startPlazaTasks({
pool,
plazaRedis,
recalculateHotScores,
writebackPublications,
});
mindSpaceCleanup = createCleanupService(pool, {
storageRoot:
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),
subscriptionService,
});
wechatPayClient = createWechatPayClient(loadWechatPayConfig());
wechatOAuthService = createWechatOAuthService(pool, loadWechatOAuthConfig(), { userAuth });
rechargeService = createRechargeService(pool, {
userAuth,
wechatPay: wechatPayClient,
});
if (wechatPayClient.enabled) {
console.log(`WeChat Pay recharge enabled (${wechatPayClient.apiVersion ?? 'unknown'})`);
}
if (wechatOAuthService.enabled) {
console.log('WeChat OAuth login enabled');
}
mindSpaceAgentJobs = createAgentJobService(pool, {
pageService: mindSpacePages,
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
});
// Shared experience store (etat C): retrieval before / recording after each
// agent job, so all instances learn from one another. Gated so it can be
// disabled without touching the runner. Polyglot: when EXPERIENCE_PG_URL is
// set we use PostgreSQL + pgvector (semantic search) for this workload only;
// the MySQL business DB is untouched. Falls back to MySQL keyword store if PG
// init fails (e.g. driver missing) so a misconfig never blocks startup.
let experienceService = null;
if (process.env.MINDSPACE_EXPERIENCE_ENABLED !== 'false') {
if (process.env.EXPERIENCE_PG_URL) {
try {
const { createPgExperienceService } = await import('./experience-service-pg.mjs');
experienceService = await createPgExperienceService({
connectionString: process.env.EXPERIENCE_PG_URL,
});
console.log('Experience store: PostgreSQL + pgvector');
} catch (error) {
console.error(
'Experience PG init failed, falling back to MySQL store:',
error instanceof Error ? error.message : error,
);
experienceService = createExperienceService(pool);
}
} else {
experienceService = createExperienceService(pool);
}
}
mindSpaceAgentRunner = createMindSpaceAgentRunner({
apiTarget: API_TARGET,
apiSecret: API_SECRET,
userAuth,
agentJobService: mindSpaceAgentJobs,
experienceService,
});
mindSpaceAudit = createMindSpaceAuditWriter(pool);
// Agent job consumer: a DB-polling worker that atomically claims queued jobs
// (claimNextJob uses SELECT ... FOR UPDATE SKIP LOCKED, so multiple instances
// can run this loop without double-processing) and runs them via the runner.
// Opt-in per instance: must NOT run on the 105 stateless front (see
// docs/g2-load-balancing.md) — gate with MINDSPACE_AGENT_WORKER_ENABLED.
if (process.env.MINDSPACE_AGENT_WORKER_ENABLED === 'true') {
const workerConcurrency = Math.max(
1,
Number(process.env.MINDSPACE_AGENT_WORKER_CONCURRENCY ?? 2),
);
const workerPollMs = Math.max(
200,
Number(process.env.MINDSPACE_AGENT_WORKER_POLL_MS ?? 1000),
);
const workerStaleMs = Math.max(
10_000,
Number(process.env.MINDSPACE_AGENT_WORKER_STALE_MS ?? 5 * 60 * 1000),
);
let inFlight = 0;
let draining = false;
const drainQueue = async () => {
if (draining) return;
draining = true;
try {
while (inFlight < workerConcurrency) {
const claim = await mindSpaceAgentJobs.claimNextJob();
if (!claim) break;
inFlight += 1;
void mindSpaceAgentRunner
.runJob(claim.jobId, claim)
.catch((error) => {
console.error('Agent worker job failed:', error);
})
.finally(() => {
inFlight -= 1;
});
}
} catch (error) {
console.error('Agent worker drain failed:', error);
} finally {
draining = false;
}
};
const workerTimer = setInterval(() => {
void drainQueue();
}, workerPollMs);
const reaperTimer = setInterval(() => {
void mindSpaceAgentJobs
.reapStaleJobs(workerStaleMs)
.then((reaped) => {
if (reaped > 0) {
console.warn(`Agent worker reaped ${reaped} stale running job(s)`);
}
})
.catch((error) => {
console.error('Agent worker reaper failed:', error);
});
}, Math.min(workerStaleMs, 60_000));
workerTimer.unref?.();
reaperTimer.unref?.();
console.log(
`Agent job worker enabled (concurrency=${workerConcurrency}, poll=${workerPollMs}ms)`,
);
}
if (WORKSPACE_MAINTENANCE_ENABLED) {
startWorkspaceThumbnailWatcher(path.join(__dirname, PUBLISH_ROOT_DIR));
startWorkspaceAssetSyncWatcher({
publishRoot: path.join(__dirname, PUBLISH_ROOT_DIR),
syncUserWorkspaceByDirKey: async (dirKey, options) => {
let userId = dirKey;
if (!PUBLISH_KEY_UUID.test(dirKey)) {
const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
dirKey,
]);
userId = rows[0]?.id;
}
if (!userId) return;
await mindSpaceAssets.syncWorkspaceAssets(userId, options);
},
});
void mindSpaceAssets.expireStaleUploads().catch(() => {});
setInterval(() => {
void mindSpaceAssets?.expireStaleUploads().catch(() => {});
}, 5 * 60 * 1000).unref?.();
} else {
console.log('Workspace maintenance daemons disabled (MEMIND_WORKSPACE_MAINTENANCE=0)');
}
await userAuth.ensureAdminUser();
llmProviderService = createLlmProviderService(pool, {
apiTarget: API_TARGET,
apiTargets: API_TARGETS,
apiSecret: API_SECRET,
});
wordFilterService = createWordFilterService(pool);
void llmProviderService
.ensureBootstrapRelay()
.then((result) => {
if (result.created) {
console.log(`LLM relay bootstrap created: ${RELAY_BOOTSTRAP.name}`);
}
})
.catch((err) => {
console.warn('LLM relay bootstrap skipped:', err instanceof Error ? err.message : err);
});
void llmProviderService.syncSelectedToGoosed().catch((err) => {
console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err);
});
conversationMemoryService = createConversationMemoryService(pool);
sessionSnapshotService = createSessionSnapshotService(pool, {
conversationMemoryService,
});
tkmindProxy = createTkmindProxy({
apiTarget: API_TARGET,
apiTargets: API_TARGETS,
apiSecret: API_SECRET,
userAuth,
llmProviderService,
subscriptionService,
sessionSnapshotService,
conversationMemoryService,
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,
});
toolGateway = createToolGateway({ llmProviderService });
agentRunGateway = createAgentRunGateway({
pool,
userAuth,
tkmindProxy,
toolGateway,
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(),
),
maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1),
runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000),
});
wechatMpService = createWechatMpService({
config: WECHAT_MP_CONFIG,
userAuth,
apiFetch: tkmindProxy.apiFetch,
startAgentSession: ({ userId, workingDir, sessionPolicy }) =>
tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }),
sessionApiFetch: async (sessionId, pathname, init) => {
const target = await tkmindProxy.resolveTarget(sessionId);
return tkmindProxy.apiFetchTo(target, pathname, init);
},
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId),
refreshSessionSnapshot:
sessionSnapshotService?.isEnabled()
? (sessionId, userId) =>
sessionSnapshotService.refresh(sessionId, userId, async (pathname, init) => {
const target = await tkmindProxy.resolveTarget(sessionId);
return tkmindProxy.apiFetchTo(target, pathname, init);
})
: null,
});
notificationDispatcher = createNotificationDispatcher({
sendWechatTextToUser: wechatMpService?.enabled
? (userId, text) => wechatMpService.sendTextToUser(userId, text)
: null,
});
userAuth.setRechargeNotifier(async ({ userId, title, body, dedupeKey }) => {
await notificationDispatcher.sendRechargeSuccess({ userId, title, body, dedupeKey });
});
if (
process.env.H5_REMINDER_WORKER_ENABLED === '1' &&
wechatMpService?.enabled &&
scheduleService
) {
scheduleReminderWorker = startScheduleReminderWorker({
scheduleService,
notificationDispatcher,
});
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,
userAuth,
pageService: mindSpacePages,
pageLiveEdit: mindSpacePageLiveEdit,
llmProviderService,
});
if (wechatMpService?.enabled) {
console.log('WeChat MP webhook enabled');
}
console.log(`User auth enabled (MySQL), workspace root: ${USERS_ROOT}`);
return true;
} catch (err) {
console.error('User auth bootstrap failed:', err);
if (isDatabaseConfigured() && process.env.NODE_ENV === 'production') {
console.error(
'Fatal: database is configured but user auth bootstrap failed; exiting so launchd can retry',
);
process.exit(1);
}
return false;
}
}
const userAuthReady = bootstrapUserAuth();
function legacySessionToken(req) {
return parseCookies(req.get('cookie'))[AUTH_COOKIE];
}
function userToken(req) {
return parseCookies(req.get('cookie'))[USER_COOKIE];
}
function setUserLoginCookies(res, req, token) {
res.set(
'Set-Cookie',
userLoginCookies(token, isSecureRequest(req), resolveCookieDomainForRequest(req)),
);
}
function clearUserLoginCookies(res, req) {
res.set(
'Set-Cookie',
clearUserSessionCookie(isSecureRequest(req), resolveCookieDomainForRequest(req)),
);
}
async function attachUserSession(req, _res, next) {
if (!userAuth) return next();
const token = userToken(req);
req.userToken = token;
try {
req.userSession = token ? await userAuth.verify(token) : null;
req.userSessionError = null;
} catch (err) {
req.userSession = null;
req.userSessionError = err;
console.error('[Auth] session verify failed:', err instanceof Error ? err.message : err);
}
next();
}
app.use(attachUserSession);
// ============ Legacy password auth ============
app.get('/auth/status', async (req, res) => {
await userAuthReady;
if (userAuth) {
try {
const me = await userAuth.getMe(userToken(req));
if (!me) return res.json({ authenticated: false, mode: 'user' });
const row = await userAuth.getUserById(me.id);
const capabilityState = await userAuth.resolveUserCapabilities(row);
return res.json({
authenticated: true,
user: me,
mode: 'user',
capabilities: capabilityState.capabilities,
grantedSkills: capabilityState.grantedSkills ?? [],
unrestricted: capabilityState.unrestricted,
});
} catch (err) {
console.error('[Auth] status failed:', err instanceof Error ? err.message : err);
return res.status(503).json({
authenticated: false,
mode: 'unavailable',
message: '用户认证服务不可用,请稍后重试',
});
}
}
if (isDatabaseConfigured()) {
return res.status(503).json({
authenticated: false,
mode: 'unavailable',
message: '用户认证服务不可用,请稍后重试',
});
}
if (legacyAuth) {
return res.json({
authenticated: legacyAuth.verify(legacySessionToken(req)),
mode: 'legacy',
});
}
return res.json({ authenticated: false, mode: 'none' });
});
app.post('/auth/login', jsonBody, async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (userAuth) {
const { username, password } = req.body ?? {};
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
const result = await userAuth.login({ username, password, ip: req.ip });
if (!result.ok) {
if (result.retryAfterMs > 0) {
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
return res.status(429).json({ message: result.message });
}
return res.status(401).json({ message: result.message });
}
setUserLoginCookies(res, req, result.token);
return res.json({ authenticated: true, user: result.user, mode: 'user' });
}
if (!legacyAuth) {
return res.status(503).json({ message: '未配置用户数据库或访问密码' });
}
const password = typeof req.body?.password === 'string' ? req.body.password : '';
const result = legacyAuth.login(password, req.ip);
if (!result.ok) {
if (result.retryAfterMs > 0) {
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
return res.status(429).json({ message: '尝试次数过多,请稍后再试' });
}
return res.status(401).json({ message: '密码错误,请重试' });
}
res.set('Set-Cookie', sessionCookie(result.token, secure));
return res.json({ authenticated: true, mode: 'legacy' });
});
app.post('/auth/register', jsonBody, async (req, res) => {
await userAuthReady;
if (!userAuth) {
return res.status(503).json({ message: '未启用用户注册' });
}
const { username, password, displayName, email } = req.body ?? {};
const result = await userAuth.register({ username, password, displayName, email });
if (!result.ok) {
const status = result.message.includes('已存在') ? 409 : 400;
return res.status(status).json({ message: result.message });
}
if (plazaSeo && req.body?.utm_source) {
void plazaSeo
.recordAttribution(
{
event_type: 'signup',
utm_source: req.body.utm_source,
utm_medium: req.body.utm_medium,
utm_campaign: req.body.utm_campaign,
ref_id: req.body.ref ?? req.body.ref_id,
user_id: result.user?.id ?? null,
},
plazaClientIp(req),
)
.catch(() => {});
}
return res.json({ ok: true, user: result.user });
});
app.post('/auth/reset-password', jsonBody, async (req, res) => {
await userAuthReady;
if (!userAuth) {
return res.status(503).json({ message: '未启用用户系统' });
}
const { username, email, password } = req.body ?? {};
const result = await userAuth.resetPassword({ username, email, password });
if (!result.ok) {
return res.status(400).json({ message: result.message });
}
return res.json({ ok: true });
});
app.get('/auth/wechat/config', async (req, res) => {
await userAuthReady;
if (!wechatOAuthService?.enabled) {
return res.json({
enabled: false,
inWechat: isWechatUserAgent(req.get('user-agent') || ''),
scanEnabled: false,
});
}
return res.json(wechatOAuthService.publicConfig(req));
});
app.get('/auth/wechat/js-sdk-signature', async (req, res) => {
await userAuthReady;
if (!userAuth || !wechatMpService?.enabled) {
return res.status(503).json({ message: '微信 JS-SDK 未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const pageUrl = String(req.query?.url ?? '').split('#')[0];
if (!pageUrl) {
return res.status(400).json({ message: '缺少 url' });
}
try {
const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, {
publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl,
requestHost: req.get('x-forwarded-host') || req.get('host') || '',
});
const payload = await wechatMpService.createJsSdkSignature(normalizedUrl);
return res.json(payload);
} catch (err) {
const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败';
console.warn('WeChat JS-SDK signature failed:', message);
return res.status(502).json({ message });
}
});
app.get('/auth/wechat/public-js-sdk-signature', async (req, res) => {
await userAuthReady;
if (!wechatMpService?.enabled) {
return res.status(503).json({ message: '微信 JS-SDK 未启用' });
}
const pageUrl = String(req.query?.url ?? '').split('#')[0];
if (!pageUrl) {
return res.status(400).json({ message: '缺少 url' });
}
try {
const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, {
publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl,
requestHost: req.get('x-forwarded-host') || req.get('host') || '',
});
const payload = await wechatMpService.createJsSdkSignature(normalizedUrl);
return res.json(payload);
} catch (err) {
const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败';
console.warn('WeChat public JS-SDK signature failed:', message);
return res.status(502).json({ message });
}
});
app.get('/auth/wechat/status', async (req, res) => {
await userAuthReady;
if (!userAuth || !wechatOAuthService?.enabled) {
return res.json({ enabled: false, bound: false });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const config = loadWechatOAuthConfig();
const status = await userAuth.getWechatBindingStatus(me.id, config.appId);
return res.json({ enabled: true, ...status });
});
if (WECHAT_MP_CONFIG.enabled) {
app.get('/auth/wechat/agent-route', async (req, res) => {
await userAuthReady;
if (!userAuth || !wechatMpService?.enabled) {
return res.status(503).json({ message: '公众号 Agent 未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
return res.json(await wechatMpService.getRouteStatusForUser(me.id));
});
app.post('/auth/wechat/agent-route/reset', async (req, res) => {
await userAuthReady;
if (!userAuth || !wechatMpService?.enabled) {
return res.status(503).json({ message: '公众号 Agent 未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
try {
const result = await wechatMpService.recreateRouteForUser(me.id);
if (!result.ok) return res.status(400).json({ message: result.message });
return res.json(result);
} catch (err) {
return res.status(500).json({
message: err instanceof Error ? err.message : '重建公众号 Agent 路由失败',
});
}
});
}
app.get('/auth/wechat/pending/:token', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const pending = await userAuth.getWechatPendingBind(req.params.token);
if (!pending) return res.status(404).json({ message: '绑定会话已过期,请重新微信登录' });
return res.json({
nickname: pending.nickname,
avatarUrl: pending.avatar_url,
returnTo: pending.return_to || '/',
});
});
app.post('/auth/wechat/register', jsonBody, async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : '';
if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' });
const result = await userAuth.completeWechatRegister({ pendingToken });
if (!result.ok) return res.status(400).json({ message: result.message });
if (result.isNewUser && plazaSeo) {
void plazaSeo
.recordAttribution(
{
event_type: 'signup',
utm_source: result.utmSource || 'wechat',
utm_medium: result.utmMedium,
utm_campaign: result.utmCampaign,
user_id: result.user?.id ?? null,
},
plazaClientIp(req),
)
.catch(() => {});
}
setUserLoginCookies(res, req, result.token);
return res.json({
authenticated: true,
user: result.user,
returnTo: result.returnTo || '/',
});
});
app.post('/auth/wechat/bind', jsonBody, async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const pendingToken = typeof req.body?.pendingToken === 'string' ? req.body.pendingToken : '';
const username = typeof req.body?.username === 'string' ? req.body.username : '';
const password = typeof req.body?.password === 'string' ? req.body.password : '';
if (!pendingToken) return res.status(400).json({ message: '缺少绑定会话' });
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
const result = await userAuth.completeWechatBindAccount({
pendingToken,
username,
password,
ip: req.ip,
});
if (!result.ok) {
const status = result.retryAfterMs > 0 ? 429 : 401;
if (result.retryAfterMs > 0) {
res.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)));
}
return res.status(status).json({ message: result.message });
}
setUserLoginCookies(res, req, result.token);
return res.json({
authenticated: true,
user: result.user,
bound: true,
returnTo: result.returnTo || '/',
});
});
app.post('/auth/wechat/scan/start', async (req, res) => {
await userAuthReady;
if (!wechatOAuthService?.enabled) {
return res.status(503).json({ message: '微信登录未启用' });
}
try {
const payload = await wechatOAuthService.startScanLogin(req);
return res.json(payload);
} catch (err) {
return res.status(503).json({
message: err instanceof Error ? err.message : '微信扫码登录不可用',
});
}
});
app.get('/auth/wechat/scan/poll', async (req, res) => {
await userAuthReady;
if (!wechatOAuthService?.enabled) {
return res.status(503).json({ message: '微信登录未启用' });
}
const state = typeof req.query?.state === 'string' ? req.query.state : '';
if (!state) return res.status(400).json({ message: '缺少扫码状态' });
const result = await wechatOAuthService.pollScanLogin(state);
if (result.status === 'complete' && result.token) {
setUserLoginCookies(res, req, result.token);
}
return res.json(result);
});
app.get('/auth/wechat/authorize', async (req, res) => {
await userAuthReady;
if (!wechatOAuthService?.enabled) {
return res.status(503).json({ message: '微信登录未启用' });
}
try {
let bindUserId = null;
const intent = typeof req.query?.intent === 'string' ? req.query.intent.trim().toLowerCase() : 'login';
if (intent === 'bind' && userAuth) {
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '请先登录后再绑定微信' });
bindUserId = me.id;
}
const redirectUrl = await wechatOAuthService.buildAuthorizeRedirect(req, { bindUserId });
return res.redirect(302, redirectUrl);
} catch (err) {
console.error('WeChat authorize failed:', err);
return res.status(500).json({ message: err instanceof Error ? err.message : '微信授权失败' });
}
});
app.get('/auth/wechat/callback', async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (!wechatOAuthService?.enabled || !userAuth) {
return res.redirect(302, '/?wechat_error=unavailable');
}
try {
const result = await wechatOAuthService.handleCallback({
code: typeof req.query?.code === 'string' ? req.query.code : '',
state: typeof req.query?.state === 'string' ? req.query.state : '',
ip: req.ip,
});
if (result.action === 'binding_gate') {
const params = new URLSearchParams();
params.set('wechat_pending', result.pendingToken);
if (result.returnTo && result.returnTo !== '/') {
params.set('return_to', result.returnTo);
}
return res.redirect(302, `/?${params.toString()}`);
}
if (result.action === 'poll_error') {
return res.redirect(
302,
`/?wechat_error=${encodeURIComponent(result.message || '微信登录失败')}`,
);
}
if (result.isNewUser && plazaSeo) {
void plazaSeo
.recordAttribution(
{
event_type: 'signup',
utm_source: result.utmSource || 'wechat',
utm_medium: result.utmMedium,
utm_campaign: result.utmCampaign,
user_id: result.user?.id ?? null,
},
plazaClientIp(req),
)
.catch(() => {});
}
if (result.authMode === 'open' || result.authMode === 'scan') {
return res.send(`
微信登录扫码登录成功,请返回电脑继续操作。
`);
}
setUserLoginCookies(res, req, result.token);
return res.redirect(302, result.returnTo || '/');
} catch (err) {
console.error('WeChat callback failed:', err);
const message = encodeURIComponent(err instanceof Error ? err.message : '微信登录失败');
return res.redirect(302, `/?wechat_error=${message}`);
}
});
app.get('/auth/me', 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 [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, subscription },
paths,
capabilities: capabilityState.capabilities,
grantedSkills: capabilityState.grantedSkills ?? [],
unrestricted: capabilityState.unrestricted,
});
});
app.post('/auth/logout', async (req, res) => {
await userAuthReady;
const secure = isSecureRequest(req);
if (userAuth) {
await userAuth.revoke(userToken(req));
clearUserLoginCookies(res, req);
}
if (legacyAuth) {
legacyAuth.revoke(legacySessionToken(req));
res.set('Set-Cookie', clearSessionCookie(secure));
}
res.status(204).end();
});
app.get('/auth/usage', 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 records = await userAuth.listUsageRecords({ userId: me.id, limit: 30 });
res.json({ records });
});
app.post('/auth/feedback', jsonBody, async (req, res) => {
await userAuthReady;
if (!userAuth || !feedbackService) {
return res.status(503).json({ message: '反馈服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
try {
const result = await feedbackService.submit(me.id, {
type: req.body?.type,
title: req.body?.title,
description: req.body?.description,
contact: req.body?.contact,
images: req.body?.images,
context: req.body?.context,
});
res.status(201).json({ feedback: result });
} catch (err) {
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : '';
if (code === 'invalid_input') {
return res.status(400).json({ message: err instanceof Error ? err.message : '提交内容无效' });
}
console.warn('Submit feedback failed:', err instanceof Error ? err.message : err);
return res.status(500).json({ message: '反馈提交失败,请稍后重试' });
}
});
app.get('/auth/feedback', async (req, res) => {
await userAuthReady;
if (!userAuth || !feedbackService) {
return res.status(503).json({ message: '反馈服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const limit = Math.min(Math.max(Number(req.query?.limit) || 20, 1), 50);
const items = await feedbackService.listForUser(me.id, { limit });
res.json({ items });
});
app.get('/auth/feedback/board', async (req, res) => {
await userAuthReady;
if (!userAuth || !feedbackService) {
return res.status(503).json({ message: '反馈服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const page = Math.max(Number(req.query?.page) || 1, 1);
const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 10);
try {
const result = await feedbackService.listAll({ page, limit });
res.json(result);
} catch (err) {
console.warn('List feedback board failed:', err instanceof Error ? err.message : err);
return res.status(500).json({ message: '反馈列表加载失败' });
}
});
app.get('/auth/feedback/:feedbackId', async (req, res) => {
await userAuthReady;
if (!userAuth || !feedbackService) {
return res.status(503).json({ message: '反馈服务未启用' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
try {
const item = await feedbackService.getById(req.params.feedbackId, me.id);
res.json({ item, isMine: item.userId === me.id });
} catch (err) {
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : '';
if (code === 'feedback_not_found') {
return res.status(404).json({ message: err instanceof Error ? err.message : '反馈不存在' });
}
console.warn('Get feedback detail failed:', err instanceof Error ? err.message : err);
return res.status(500).json({ message: '反馈详情加载失败' });
}
});
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: '未启用用户系统' });
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const limit = Math.min(Math.max(Number(req.query?.limit) || 30, 1), 100);
const entries = await userAuth.listBillingLedger({
userId: me.id,
limit,
types: ['recharge', 'adjust', 'refund'],
});
res.json({ entries });
});
app.get('/auth/billing/config', async (req, res) => {
await userAuthReady;
if (!userAuth || !rechargeService) {
return res.status(503).json({ message: '未启用计费系统' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
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) => {
await userAuthReady;
if (!userAuth || !rechargeService) {
return res.status(503).json({ message: '未启用计费系统' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const amountCents = Number(req.body?.amountCents);
const payScene = ['native', 'h5', 'jsapi'].includes(req.body?.payScene)
? req.body.payScene
: 'native';
const result = await rechargeService.createOrder({
userId: me.id,
amountCents,
payScene,
clientIp: req.ip,
});
if (!result.ok) return res.status(400).json({ message: result.message });
return res.status(201).json({ order: result.order });
});
app.get('/auth/billing/recharge-orders/:orderId', async (req, res) => {
await userAuthReady;
if (!userAuth || !rechargeService) {
return res.status(503).json({ message: '未启用计费系统' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const order = await rechargeService.getOrderForUser(me.id, req.params.orderId);
if (!order) return res.status(404).json({ message: '订单不存在' });
let balanceCents = null;
if (order.status === 'paid') {
const user = await userAuth.getUserById(me.id);
balanceCents = user ? Number(user.balance_cents ?? 0) : null;
}
return res.json({ order, balanceCents });
});
const wechatNotifyBody = express.raw({
type: ['application/json', 'text/xml', 'application/xml'],
limit: '64kb',
});
const wechatMpBody = express.text({
type: ['text/xml', 'application/xml'],
limit: '128kb',
});
app.post('/webhooks/wechat-pay/notify', wechatNotifyBody, async (req, res) => {
await userAuthReady;
const isV2 = wechatPayClient?.apiVersion === 'v2';
if (!rechargeService || !wechatPayClient?.enabled) {
if (isV2) {
return res
.status(503)
.type('text/xml')
.send(
'',
);
}
return res.status(503).json({ code: 'FAIL', message: '支付未启用' });
}
try {
const bodyText = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : String(req.body ?? '');
await rechargeService.handleWechatNotify({ headers: req.headers, body: bodyText });
if (isV2) {
return res.type('text/xml').send(WECHAT_NOTIFY_SUCCESS_V2);
}
return res.json({ code: 'SUCCESS', message: '成功' });
} catch (err) {
console.error('WeChat notify failed:', err);
const message = err instanceof Error ? err.message : '处理失败';
if (isV2) {
return res
.status(500)
.type('text/xml')
.send(
``,
);
}
return res.status(500).json({ code: 'FAIL', message });
}
});
if (WECHAT_MP_CONFIG.enabled) {
app.get('/webhooks/wechat-mp/messages', async (req, res) => {
await userAuthReady;
if (!wechatMpService?.enabled) {
return res.status(503).send('wechat mp disabled');
}
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(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(result.body ?? ''));
});
app.post('/webhooks/wechat-mp/messages', wechatMpBody, async (req, res) => {
await userAuthReady;
if (!wechatMpService?.enabled) {
return res.status(503).send('wechat mp disabled');
}
try {
const bodyText = String(req.body ?? '');
const fromUser = bodyText.match(/<\/FromUserName>/)?.[1] ?? null;
const msgType = bodyText.match(/<\/MsgType>/)?.[1] ?? null;
const content = bodyText.match(/<\/Content>/)?.[1] ?? null;
console.log('WeChat MP message received:', {
at: new Date().toISOString(),
fromUser: fromUser ? `${fromUser.slice(0, 8)}...` : null,
msgType,
contentPreview: content ? `${String(content).slice(0, 24)}` : null,
});
const result = await wechatMpService.handleInboundMessage(req.body, req.query);
if (result.task) void result.task;
if (result.contentType) res.type(result.contentType);
return res.status(result.status ?? 200).send(result.body ?? 'success');
} catch (err) {
console.error('WeChat MP message failed:', err);
return res.status(500).send('internal error');
}
});
}
// ============ Wiki API ============
function wikiCookie(token, secure) {
const parts = [
`${wikiAuth.COOKIE_NAME}=${encodeURIComponent(token)}`,
'Path=/',
'HttpOnly',
'SameSite=Lax',
'Max-Age=604800',
];
if (secure) parts.push('Secure');
return parts.join('; ');
}
function clearWikiCookie(secure) {
const parts = [`${wikiAuth.COOKIE_NAME}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
if (secure) parts.push('Secure');
return parts.join('; ');
}
const wikiApi = express.Router();
wikiApi.use(jsonBody);
wikiApi.post('/auth/register', (req, res) => {
const { username, password, displayName } = req.body || {};
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
const result = wikiAuth.register(username, password, displayName);
if (!result.ok) return res.status(409).json({ message: result.message });
return res.json({ ok: true, user: result.user });
});
wikiApi.post('/auth/login', (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ message: '用户名和密码不能为空' });
}
const result = wikiAuth.login(username, password);
if (!result.ok) return res.status(401).json({ message: result.message });
const secure = isSecureRequest(req);
res.set('Set-Cookie', wikiCookie(result.token, secure));
return res.json({ ok: true, user: result.user });
});
wikiApi.post('/auth/logout', (req, res) => {
const cookies = parseCookies(req.get('cookie'));
wikiAuth.revoke(cookies[wikiAuth.COOKIE_NAME]);
res.set('Set-Cookie', clearWikiCookie(isSecureRequest(req)));
return res.json({ ok: true });
});
wikiApi.get('/auth/me', (req, res) => {
const cookies = parseCookies(req.get('cookie'));
const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]);
if (!session) return res.json({ authenticated: false, user: null });
const user = wikiAuth.getUser(session.username);
return res.json({ authenticated: true, user });
});
function requireWikiAuth(req, res, next) {
const cookies = parseCookies(req.get('cookie'));
const session = wikiAuth.verify(cookies[wikiAuth.COOKIE_NAME]);
if (!session) return res.status(401).json({ message: '未登录' });
req.wikiUser = session;
next();
}
wikiApi.get('/pages', requireWikiAuth, (req, res) => {
const { q } = req.query;
if (q) return res.json(wikiAuth.searchPages(req.wikiUser.username, q));
res.json(wikiAuth.listPages(req.wikiUser.username));
});
wikiApi.get('/pages/:slug', requireWikiAuth, (req, res) => {
const page = wikiAuth.getPage(req.wikiUser.username, req.params.slug);
if (!page) return res.status(404).json({ message: '页面不存在' });
res.json(page);
});
wikiApi.post('/pages/:slug', requireWikiAuth, (req, res) => {
const { title, content, tags } = req.body || {};
const page = wikiAuth.savePage(req.wikiUser.username, req.params.slug, title, content, tags);
res.json(page);
});
wikiApi.delete('/pages/:slug', requireWikiAuth, (req, res) => {
wikiAuth.deletePage(req.wikiUser.username, req.params.slug);
res.json({ ok: true });
});
app.use('/wiki-api', wikiApi);
// ============ TKMind API proxy ============
const api = express.Router();
api.use(jsonUnlessMultipart);
api.use(async (req, res, next) => {
await userAuthReady;
if (req.path === '/status' || req.path === '/runtime/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();
}
const plazaPublic = isPlazaPublicRead(req.path, req.method);
if (userAuth && tkmindProxy) {
if (req.userSessionError) {
if (plazaPublic) return next();
return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' });
}
try {
if (req.userSession) {
const me = await userAuth.getMe(req.userToken);
if (me) req.currentUser = me;
}
if (plazaPublic) return next();
if (!req.userSession) {
return res.status(401).json({ message: '未授权,请重新登录' });
}
const me = await userAuth.getMe(req.userToken);
if (!me) return res.status(401).json({ message: '登录已过期' });
req.currentUser = me;
return next();
} catch (err) {
console.error('[Auth] API auth failed:', err instanceof Error ? err.message : err);
return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' });
}
}
if (legacyAuth?.verify(legacySessionToken(req))) return next();
return res.status(401).json({ message: '未授权,请重新登录' });
});
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) {
try {
const upstream = await tkmindProxy.apiFetch('/status', { method: 'GET' });
const text = await upstream.text();
return res.status(upstream.status).send(text);
} catch (err) {
return res.status(502).json({ message: err instanceof Error ? err.message : '代理失败' });
}
}
return next();
});
function runtimeEnvFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
function runtimeCsvList(value) {
return String(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function runtimeCodeRunPolicyStatus() {
return {
enabled: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED),
userAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS),
taskTypeAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES),
requireValidation: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION),
};
}
api.get('/runtime/status', async (_req, res) => {
await userAuthReady;
if (!tkmindProxy?.getRuntimeStatus) {
return res.status(503).json({ ok: false, message: 'runtime router unavailable' });
}
try {
const status = await tkmindProxy.getRuntimeStatus();
const toolQueue = agentRunGateway?.getQueueStatus
? await agentRunGateway.getQueueStatus().catch((err) => ({
error: err instanceof Error ? err.message : String(err),
}))
: null;
if (toolQueue) {
status.toolRuntime = {
...(status.toolRuntime ?? {}),
codeRunPolicy: runtimeCodeRunPolicyStatus(),
queue: toolQueue,
};
}
return res.json({
ok: true,
timestamp: new Date().toISOString(),
...status,
});
} catch (err) {
return res.status(502).json({
ok: false,
message: err instanceof Error ? err.message : 'runtime status failed',
});
}
});
async function ensureUserMemoryCapability(req, res) {
if (!userAuth) {
res.status(503).json({ message: '未启用用户系统' });
return null;
}
const userRow = await userAuth.getUserById(req.currentUser.id);
if (!userRow) {
res.status(404).json({ message: '用户不存在' });
return null;
}
const capabilityState = await userAuth.resolveUserCapabilities(userRow);
if (!capabilityState.unrestricted && !capabilityState.capabilities.memory_store) {
res.status(403).json({ message: '当前账户未开通长期记忆,无法访问该 API' });
return null;
}
return capabilityState;
}
async function loadUserVisibleConversation(sessionId) {
const target = await tkmindProxy.resolveTarget(sessionId);
const upstream = await tkmindProxy.apiFetchTo(target, `/sessions/${encodeURIComponent(sessionId)}`, {
method: 'GET',
});
if (!upstream.ok) {
const message = await upstream.text().catch(() => '');
throw new Error(message || '读取会话失败');
}
const session = await upstream.json();
return (session?.conversation ?? []).filter((message) => message?.metadata?.userVisible);
}
async function syncUserMemoriesIntoSession(userId, sessionId) {
if (!tkmindProxy || !sessionId) return false;
await tkmindProxy.reconcileSessionPolicyForUser(userId, sessionId);
return true;
}
api.post('/user-memory/v1/remember-recent', async (req, res) => {
if (!conversationMemoryService?.isEnabled?.()) {
return res.status(503).json({ message: '长期记忆功能未启用' });
}
if (!tkmindProxy) {
return res.status(503).json({ message: '会话代理尚未就绪' });
}
const capabilityState = await ensureUserMemoryCapability(req, res);
if (!capabilityState) return;
const sessionId = String(req.body?.sessionId ?? '').trim();
if (!sessionId) {
return res.status(400).json({ message: '缺少 sessionId' });
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const messages = await loadUserVisibleConversation(sessionId);
const result = await conversationMemoryService.saveAndAnalyze(
sessionId,
req.currentUser.id,
messages,
);
const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId);
const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 });
return res.json({
ok: true,
analyzed: result.analyzed ?? 0,
memories: result.memories ?? 0,
totalMemories: memories.length,
syncedToSession,
});
} catch (err) {
return res.status(500).json({ message: err instanceof Error ? err.message : '保存长期记忆失败' });
}
});
api.post('/user-memory/v1/sync', async (req, res) => {
if (!conversationMemoryService?.isEnabled?.()) {
return res.status(503).json({ message: '长期记忆功能未启用' });
}
const capabilityState = await ensureUserMemoryCapability(req, res);
if (!capabilityState) return;
const sessionId = String(req.body?.sessionId ?? '').trim();
if (!sessionId) {
return res.status(400).json({ message: '缺少 sessionId' });
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const result = await conversationMemoryService.analyzeUser(req.currentUser.id);
const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId);
const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 });
return res.json({
ok: true,
analyzed: result.analyzed ?? 0,
memories: result.memories ?? 0,
totalMemories: memories.length,
syncedToSession,
});
} catch (err) {
return res.status(500).json({ message: err instanceof Error ? err.message : '刷新长期记忆失败' });
}
});
api.get('/mindspace/v1/space', async (req, res) => {
if (!mindSpace || !ensureMindSpaceEnabled(res, req)) return;
const space = await mindSpace.getSpace(req.currentUser.id);
if (!space) return sendError(res, req, 404, 'resource_not_found', '用户空间不存在');
return sendData(res, req, space);
});
api.post('/mindspace/v1/schedule/reminders/:reminderId/ignore', async (req, res) => {
if (!ensureMindSpaceEnabled(res, req) || !scheduleService) {
return sendError(res, req, 503, 'feature_disabled', '日程服务未启用');
}
try {
const reminder = await scheduleService.cancelReminder({
userId: req.currentUser.id,
reminderId: req.params.reminderId,
});
return sendData(res, req, reminder);
} catch (err) {
const message = err instanceof Error ? err.message : '忽略提醒失败';
return sendError(res, req, 400, 'invalid_schedule_input', message);
}
});
api.post('/mindspace/v1/schedule/reminders/bulk-delete', async (req, res) => {
if (!ensureMindSpaceEnabled(res, req) || !scheduleService) {
return sendError(res, req, 503, 'feature_disabled', '日程服务未启用');
}
try {
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(String) : [];
const deleted = await scheduleService.deleteReminders({
userId: req.currentUser.id,
reminderIds: ids,
});
return sendData(res, req, { deleted });
} catch (err) {
const message = err instanceof Error ? err.message : '删除提醒失败';
return sendError(res, req, 400, 'invalid_schedule_input', message);
}
});
api.get('/mindspace/v1/space/quota', async (req, res) => {
if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' });
const quota = await mindSpace.getQuota(req.currentUser.id);
if (!quota) return res.status(404).json({ message: '用户空间不存在' });
return res.json({ data: quota });
});
api.get('/mindspace/v1/space/categories', async (req, res) => {
if (!mindSpace) return res.status(503).json({ message: 'MindSpace 未启用' });
const categories = await mindSpace.listCategories(req.currentUser.id);
if (!categories) return res.status(404).json({ message: '用户空间不存在' });
return res.json({ data: categories });
});
api.get('/mindspace/v1/space/cleanup', async (req, res) => {
if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return;
try {
const username = req.currentUser.username ?? req.currentUser.slug;
const items = await mindSpaceCleanup.listCandidates(req.currentUser.id, username);
const totalBytes = items.reduce((sum, item) => sum + item.sizeBytes, 0);
return sendData(res, req, { items, totalBytes });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/space/cleanup', async (req, res) => {
if (!mindSpaceCleanup || !ensureMindSpaceEnabled(res, req)) return;
try {
const username = req.currentUser.username ?? req.currentUser.slug;
const itemIds = Array.isArray(req.body?.item_ids) ? req.body.item_ids : [];
const result = await mindSpaceCleanup.runCleanup(req.currentUser.id, username, itemIds);
const quota = await mindSpace.getQuota(req.currentUser.id);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'space.cleanup',
objectType: 'space',
objectId: req.currentUser.id,
ip: req.ip,
detail: { removedCount: result.removedCount, freedBytes: result.freedBytes },
});
return sendData(res, req, { ...result, quota });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
function isPlazaPublicRead(path, method) {
if (!path.startsWith('/plaza/v1/')) return false;
if (method === 'POST' && path === '/plaza/v1/events') return true;
if (method !== 'GET') return false;
return (
path === '/plaza/v1/feed' ||
path === '/plaza/v1/categories' ||
path === '/plaza/v1/seo/sitemap' ||
/^\/plaza\/v1\/posts\/[^/]+$/.test(path) ||
/^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path) ||
/^\/plaza\/v1\/users\/[^/]+$/.test(path) ||
/^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path)
);
}
const PLAZA_SID_COOKIE = 'plaza_sid';
function resolvePlazaSessionId(req, res) {
const cookies = parseCookies(req.get('cookie'));
let sessionId = cookies[PLAZA_SID_COOKIE];
if (!sessionId) {
sessionId = crypto.randomUUID();
res.append(
'Set-Cookie',
`${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`,
);
}
return sessionId;
}
function recordPlazaEventsAsync(req, res, events) {
if (!plazaEvents || !Array.isArray(events) || events.length === 0) return;
const sessionId = resolvePlazaSessionId(req, res);
void plazaEvents
.recordEvents({
userId: req.currentUser?.id ?? null,
sessionId,
events,
})
.catch(() => {});
}
function reactionEventType(type) {
if (type === 'like' || type === 'collect' || type === 'share') return type;
return null;
}
function ensurePlazaInteractions(res, req) {
if (!plazaInteractions) {
sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
return false;
}
return true;
}
function plazaRouteError(res, req, error) {
const status = mapPlazaError(error);
const code = error?.code ?? 'internal_error';
const message = error instanceof Error ? error.message : 'Plaza 请求失败';
return sendError(res, req, status, code, message, error?.details);
}
function ensurePlazaEnabled(res, req) {
if (!plazaPosts) {
sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
return false;
}
return true;
}
function plazaClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.length > 0) {
return forwarded.split(',')[0].trim();
}
return req.ip;
}
function mindSpaceError(res, req, error) {
const statusByCode = {
invalid_filename: 400,
invalid_file_size: 400,
category_not_uploadable: 400,
file_size_mismatch: 409,
invalid_upload_state: 409,
file_too_large: 413,
unsupported_file_type: 415,
category_not_found: 404,
upload_not_found: 404,
asset_not_found: 404,
asset_in_use: 409,
page_not_found: 404,
upload_expired: 410,
quota_exceeded: 429,
public_page_limit_exceeded: 429,
space_unavailable: 423,
invalid_page_input: 400,
page_content_too_large: 413,
source_message_not_found: 404,
invalid_source_message: 409,
version_conflict: 409,
slug_conflict: 409,
invalid_state_transition: 409,
invalid_publish_input: 400,
publication_not_found: 404,
security_scan_required: 422,
security_ack_required: 422,
security_risk_blocked: 422,
invalid_agent_job_input: 400,
invalid_agent_job_output: 400,
agent_job_not_found: 404,
agent_job_token_invalid: 401,
agent_job_expired: 410,
feature_disabled: 503,
cover_ai_unavailable: 503,
llm_not_configured: 503,
cover_ai_failed: 502,
cover_ai_invalid_output: 422,
thumbnail_not_supported: 422,
thumbnail_image_required: 400,
thumbnail_image_invalid: 400,
category_not_pageable: 400,
redaction_not_needed: 409,
invalid_category_code: 400,
invalid_page_path: 400,
empty_page_content: 400,
static_page_not_found: 404,
preview_not_supported: 422,
};
const code = error?.code ?? 'internal_error';
const status = statusByCode[code] ?? 500;
const message =
status === 500 && (!error?.code || code === 'internal_error')
? 'MindSpace 服务异常'
: error?.message || 'MindSpace 服务异常';
return sendError(res, req, status, code, message, error?.details);
}
function ensureMindSpaceEnabled(res, req, { upload = false, agent = false } = {}) {
try {
assertMindSpaceRoute(msFlags, upload ? 'upload' : agent ? 'agent' : undefined);
return true;
} catch (error) {
mindSpaceError(res, req, error);
return false;
}
}
function bearerToken(req) {
const header = req.get('authorization') ?? '';
const [scheme, value] = header.split(/\s+/, 2);
if (scheme?.toLowerCase() !== 'bearer' || !value) return null;
return value.trim();
}
function requireInternalAgentSecret(req, res) {
if (bearerToken(req) === INTERNAL_AGENT_SECRET) return true;
sendError(res, req, 401, 'agent_job_token_invalid', '内部 Agent 凭据无效');
return false;
}
api.post('/mindspace/v1/agent/jobs', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
const job = await mindSpaceAgentJobs.createJob(req.currentUser.id, {
jobType: req.body?.job_type,
instruction: req.body?.instruction,
allowedAssetIds: req.body?.allowed_asset_ids,
outputCategoryId: req.body?.output_category_id,
outputType: req.body?.output_type,
idempotencyKey: req.body?.idempotency_key,
locale: req.body?.locale,
timezone: req.body?.timezone,
capabilities: req.body?.capabilities,
});
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'agent_access',
objectType: 'agent_job',
objectId: job.id,
ip: req.ip,
detail: {
jobType: job.jobType,
assetIds: job.assets.map((asset) => asset.assetId),
},
});
return sendData(res, req, job, 201);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/agent/jobs/:jobId', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
return sendData(res, req, await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId));
} catch (error) {
return mindSpaceError(res, req, error);
}
});
// Server-Sent Events stream of a job's progress, so long-running agent tasks can
// be dispatched async (enqueue → 202 → subscribe here) instead of holding a
// synchronous streaming connection. Polls the job (ownership enforced by getJob)
// and pushes on change; closes on terminal status or client disconnect.
api.get('/mindspace/v1/agent/jobs/:jobId/stream', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'timed_out']);
let job;
try {
job = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId);
} catch (error) {
return mindSpaceError(res, req, error);
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
const send = (event, payload) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
let lastSignature = '';
const emitIfChanged = (current) => {
const signature = `${current.status}:${JSON.stringify(current.progress ?? {})}`;
if (signature !== lastSignature) {
lastSignature = signature;
send('progress', current);
}
return signature;
};
emitIfChanged(job);
if (TERMINAL.has(job.status)) {
send('done', job);
return res.end();
}
let closed = false;
const cleanup = () => {
if (closed) return;
closed = true;
clearInterval(pollTimer);
clearInterval(keepAliveTimer);
};
const pollTimer = setInterval(async () => {
if (closed) return;
try {
const current = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId);
emitIfChanged(current);
if (TERMINAL.has(current.status)) {
send('done', current);
cleanup();
res.end();
}
} catch {
// Job vanished or transient read error: end the stream rather than leak it.
cleanup();
res.end();
}
}, Math.max(500, Number(process.env.MINDSPACE_AGENT_SSE_POLL_MS ?? 1000)));
// Comment line keeps proxies from closing an idle connection.
const keepAliveTimer = setInterval(() => {
if (!closed) res.write(': keep-alive\n\n');
}, 15_000);
pollTimer.unref?.();
keepAliveTimer.unref?.();
req.on('close', cleanup);
});
api.get('/mindspace/v1/agent/jobs', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
const result = await mindSpaceAgentJobs.listJobs(req.currentUser.id, {
limit: Number(req.query.limit ?? 10),
offset: Number(req.query.offset ?? 0),
});
return res.json({
data: result.items,
page: {
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.hasMore,
},
request_id: req.requestId,
});
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/agent/jobs/:jobId/cancel', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
return sendData(
res,
req,
await mindSpaceAgentJobs.cancelJob(req.currentUser.id, req.params.jobId),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/agent/jobs/:jobId/retry', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
return sendData(
res,
req,
await mindSpaceAgentJobs.retryJob(req.currentUser.id, req.params.jobId),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/agent/jobs/:jobId/run', async (req, res) => {
if (
!mindSpaceAgentJobs ||
!mindSpaceAgentRunner ||
!ensureMindSpaceEnabled(res, req, { agent: true })
) {
return;
}
try {
const job = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId);
if (job.status !== 'queued') {
return sendData(res, req, job);
}
void mindSpaceAgentRunner.runJob(req.params.jobId).catch((error) => {
console.error('MindSpace agent job run failed:', error);
});
return sendData(res, req, { started: true, jobId: req.params.jobId }, 202);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/internal/agent/jobs/:jobId/claim', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
if (!requireInternalAgentSecret(req, res)) return;
try {
return sendData(res, req, await mindSpaceAgentJobs.claimJob(req.params.jobId));
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/internal/agent/jobs/:jobId/assets/:assetId', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
const asset = await mindSpaceAgentJobs.getAssetForJob(
req.params.jobId,
bearerToken(req),
req.params.assetId,
);
res.set('Content-Type', asset.mimeType);
res.set('Content-Disposition', `inline; filename="${encodeURIComponent(asset.displayName)}"`);
res.set('Cache-Control', 'private, no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(await fs.promises.readFile(asset.path));
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/internal/agent/jobs/:jobId/heartbeat', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
return sendData(
res,
req,
await mindSpaceAgentJobs.heartbeat(req.params.jobId, bearerToken(req), {
stage: req.body?.stage,
message: req.body?.message,
}),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/internal/agent/jobs/:jobId/complete', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
const job = await mindSpaceAgentJobs.completeJob(req.params.jobId, bearerToken(req), {
status: req.body?.status,
errorCode: req.body?.error_code,
errorMessage: req.body?.error_message,
outputType: req.body?.output_type,
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
contentFormat: req.body?.content_format,
pageType: req.body?.page_type,
templateId: req.body?.template_id,
sourceAssetIds: req.body?.source_asset_ids,
});
return sendData(res, req, job);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/uploads', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return;
try {
const upload = await mindSpaceAssets.createUpload(req.currentUser.id, {
categoryId: req.body?.category_id,
filename: req.body?.filename,
sizeBytes: req.body?.size_bytes,
declaredMimeType: req.body?.declared_mime_type,
});
return sendData(res, req, upload, 201);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBody, async (req, res) => {
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpaceAssets.writeUploadContent(
req.currentUser.id,
req.params.uploadId,
req.body,
);
return res.json({ data: result });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/uploads/:uploadId/complete', async (req, res) => {
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const asset = await mindSpaceAssets.completeUpload(
req.currentUser.id,
req.params.uploadId,
);
return res.status(201).json({ data: asset });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.delete('/mindspace/v1/uploads/:uploadId', async (req, res) => {
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpaceAssets.cancelUpload(
req.currentUser.id,
req.params.uploadId,
);
return res.json({ data: result });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/assets', async (req, res) => {
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const assets = await mindSpaceAssets.listAssets(req.currentUser.id, {
categoryId: typeof req.query.category_id === 'string' ? req.query.category_id : undefined,
categoryCode:
typeof req.query.category_code === 'string' ? req.query.category_code : undefined,
});
return res.json({ data: assets, page: { next_cursor: null, has_more: false } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/authorize-image', async (req, res) => {
if (!mindSpacePublications) {
return res.status(503).json({ error: 'Service unavailable' });
}
const assetId = String(req.query.asset_id ?? '');
if (!assetId) {
return res.status(400).json({ error: 'Missing asset_id parameter' });
}
try {
const [refs] = await pool.query(
`SELECT pr.access_mode, pr.expires_at
FROM h5_publication_asset_refs refs
JOIN h5_publish_records pr ON refs.publication_id = pr.id
WHERE refs.asset_id = ? AND pr.status = 'online'
ORDER BY CASE
WHEN pr.access_mode = 'public' THEN 0
WHEN pr.access_mode = 'time_limited' THEN 1
ELSE 2
END,
pr.expires_at DESC
LIMIT 1`,
[assetId],
);
if (!refs[0]) {
return res.status(403).json({ error: 'Forbidden' });
}
const publication = refs[0];
const now = Date.now();
if (publication.access_mode === 'public') {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
return res.status(200).json({ ok: true });
}
if (
publication.access_mode === 'time_limited' &&
publication.expires_at &&
Number(publication.expires_at) > now
) {
res.set('Cache-Control', 'public, max-age=60');
return res.status(200).json({ ok: true });
}
return res.status(403).json({ error: 'Forbidden' });
} catch (error) {
console.error('[authorize-image]', error instanceof Error ? error.message : error);
return res.status(500).json({ error: 'Internal server error' });
}
});
api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
try {
const assetId = req.params.assetId;
const currentUserId = req.currentUser?.id ?? null;
let allowedByPublication = false;
let publicationAccessMode = null;
if (!currentUserId) {
const referrer = String(req.get('referer') ?? req.get('referrer') ?? '');
const isPublicPageReferrer = (() => {
if (!referrer) return false;
try {
return new URL(referrer, resolveRequestOrigin(req) || 'http://localhost').pathname.includes('/public/');
} catch {
return referrer.includes('/public/');
}
})();
const [refs] = await authPool.query(
`SELECT pr.access_mode, pr.expires_at
FROM h5_publication_asset_refs refs
JOIN h5_publish_records pr ON refs.publication_id = pr.id
WHERE refs.asset_id = ? AND pr.status = 'online'
ORDER BY CASE
WHEN pr.access_mode = 'public' THEN 0
WHEN pr.access_mode = 'time_limited' THEN 1
ELSE 2
END,
pr.expires_at DESC
LIMIT 1`,
[assetId],
);
const publication = refs[0] ?? null;
if (publication?.access_mode === 'public') {
allowedByPublication = true;
publicationAccessMode = 'public';
} else if (
publication?.access_mode === 'time_limited' &&
publication.expires_at &&
Number(publication.expires_at) > Date.now()
) {
allowedByPublication = true;
publicationAccessMode = 'time_limited';
}
if (!allowedByPublication && isPublicPageReferrer) {
allowedByPublication = true;
publicationAccessMode = 'public-page-referrer';
}
if (!allowedByPublication) {
return res.status(401).json({ message: '未授权,请重新登录' });
}
}
const { asset, path: assetPath } = currentUserId
? await mindSpaceAssets.readAsset(currentUserId, assetId)
: await mindSpaceAssets.readPublicAsset(assetId);
await mindSpaceAudit?.write({
userId: req.currentUser?.id ?? null,
action: 'asset.download',
objectType: 'asset',
objectId: assetId,
ip: req.ip,
riskLevel: asset.riskLevel,
detail: allowedByPublication ? { accessMode: publicationAccessMode } : undefined,
});
res.type(asset.mimeType);
const inline =
req.query.inline === '1' ||
req.query.disposition === 'inline' ||
req.get('sec-fetch-dest') === 'iframe';
if (inline && asset.mimeType.startsWith('image/') && wantsInlineImageViewer(req)) {
const downloadUrl = `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download?inline=1`;
const html = renderImageAssetViewerHtml({ asset, downloadUrl });
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', 'private, no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(html);
}
res.set(
'Content-Disposition',
inline
? `inline; filename*=UTF-8''${encodeURIComponent(asset.filename)}`
: `attachment; filename*=UTF-8''${encodeURIComponent(asset.filename)}`,
);
res.setHeader('X-Request-Id', req.requestId);
return res.sendFile(assetPath);
} catch (error) {
await mindSpaceAudit?.write({
userId: req.currentUser?.id ?? null,
action: 'asset.download',
objectType: 'asset',
objectId: req.params.assetId,
ip: req.ip,
result: 'denied',
riskLevel: error?.code === 'security_risk_blocked' ? 'high' : null,
});
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/assets/:assetId/thumbnail', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
try {
const svg = await mindSpaceAssets.renderAssetThumbnail(req.currentUser.id, req.params.assetId);
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'private, max-age=300');
res.setHeader('X-Request-Id', req.requestId);
return res.send(svg);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/assets/:assetId/preview', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
try {
const html = await mindSpaceAssets.renderAssetPreview(req.currentUser.id, req.params.assetId);
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', 'private, no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(html);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/from-asset', async (req, res) => {
if (!mindSpacePages || !mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const assetId = req.body?.asset_id;
const existingPage = await mindSpacePages.findPageBySourceAsset(req.currentUser.id, assetId);
if (existingPage) {
return sendData(res, req, { kind: 'page', categoryCode: existingPage.categoryCode ?? 'draft', page: existingPage });
}
const { asset, path: assetPath } = await mindSpaceAssets.readAsset(
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,
{
title: req.body?.title || asset.displayName,
summary: req.body?.summary,
content,
contentFormat,
templateId: req.body?.template_id,
categoryCode: 'draft',
},
{
assetId: asset.id,
snapshot: {
source_asset_id: asset.id,
source_category: asset.categoryCode,
content_mode: contentFormat,
...(htmlRelativePath ? { relative_path: htmlRelativePath } : {}),
},
},
);
return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.delete('/mindspace/v1/assets/:assetId', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
try {
const result = await mindSpaceAssets.deleteAsset(req.currentUser.id, req.params.assetId);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'asset.delete',
objectType: 'asset',
objectId: req.params.assetId,
ip: req.ip,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
function messageText(message) {
return (message?.content ?? [])
.filter((item) => item?.type === 'text' && typeof item.text === 'string')
.map((item) => item.text)
.join('')
.trim();
}
async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
if (!sessionId || !messageId) {
throw Object.assign(new Error('缺少来源会话或消息'), {
code: 'invalid_page_input',
});
}
if (!(await userAuth.ownsSession(userId, sessionId))) {
throw Object.assign(new Error('来源会话不存在'), { code: 'source_message_not_found' });
}
const upstream = await tkmindProxy.apiFetch(`/sessions/${encodeURIComponent(sessionId)}`, {
method: 'GET',
});
if (!upstream.ok) {
throw Object.assign(new Error('无法读取来源会话'), { code: 'source_message_not_found' });
}
const session = await upstream.json();
const message = (session.conversation ?? []).find((item) => item.id === messageId);
if (!message) {
throw Object.assign(new Error('来源消息不存在'), { code: 'source_message_not_found' });
}
const content = messageText(message);
if (message.role !== 'assistant' || !message.metadata?.userVisible || !content) {
throw Object.assign(new Error('只有可见的 AI 文本消息可以保存为页面'), {
code: 'invalid_source_message',
});
}
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();
async function syncUserGeneratedPages(userId) {
if (!mindSpacePages || !authPool || !userId) return;
let inFlight = pageSyncInFlight.get(userId);
if (!inFlight) {
inFlight = syncGeneratedPagesFromPublicAssets({
pool: authPool,
pageService: mindSpacePages,
assetService: mindSpaceAssets,
userId,
publishDir: resolvePublishDir(__dirname, { id: userId }),
syncWorkspaceAssets:
mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED
? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options)
: null,
})
.catch((error) => {
console.warn('[MindSpace] page sync failed:', error?.message ?? error);
})
.finally(() => {
if (pageSyncInFlight.get(userId) === inFlight) {
pageSyncInFlight.delete(userId);
}
});
pageSyncInFlight.set(userId, inFlight);
}
await inFlight;
}
async function resolveChatSaveBundle(user, h5Root, input = {}) {
const sessionId = input.sessionId ?? input.session_id;
const messageId = input.messageId ?? input.message_id;
const selectedLinkIndex = Number(input.selectedLinkIndex ?? input.selected_link_index ?? 0);
const previewTitle = String(input.previewTitle ?? input.preview_title ?? '').trim();
const previewSummary = String(input.previewSummary ?? input.preview_summary ?? '').trim();
const source = await resolveOwnedAssistantMessage(user.id, sessionId, messageId);
let { analysis, resolvedHtml } = await resolveChatSaveAnalysis({
content: source.content,
userId: user.id,
username: user.username,
h5Root,
selectedLinkIndex,
});
if (resolvedHtml?.content && resolvedHtml.relativePath && authPool) {
const storageRoot =
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
let htmlContent = resolvedHtml.content;
const repaired = await repairMissingHtmlAssetReferences({
pool: authPool,
userId: user.id,
sourceContent: source.content,
html: htmlContent,
}).catch(() => ({ html: htmlContent, changed: false }));
if (repaired.changed) {
htmlContent = repaired.html;
}
const materialized = await materializePrivateAssetsInWorkspaceHtml({
pool: authPool,
storageRoot,
h5Root,
userId: user.id,
html: htmlContent,
htmlRelativePath: resolvedHtml.relativePath,
writeBack: false,
}).catch(() => ({ html: htmlContent, count: 0, changed: false }));
if (materialized.changed) {
htmlContent = materialized.html;
}
if (htmlContent !== resolvedHtml.content) {
const publishDir = resolvePublishDir(h5Root, { id: user.id });
await fsPromises.writeFile(
path.join(publishDir, resolvedHtml.relativePath),
htmlContent,
'utf8',
);
resolvedHtml = { ...resolvedHtml, content: htmlContent };
}
}
return {
source,
analysis,
resolvedHtml,
selectedLinkIndex,
previewTitle,
previewSummary,
previewFrameUrl:
resolvedHtml != null
? buildChatSavePreviewFrameUrl({ sessionId, messageId, selectedLinkIndex })
: null,
thumbnailUrl:
resolvedHtml != null
? buildChatSaveThumbnailUrl({
sessionId,
messageId,
selectedLinkIndex,
previewTitle,
previewSummary,
})
: null,
localPreviewUrl:
resolvedHtml?.relativePath != null
? buildWorkspaceAssetUrl(user.id, resolvedHtml.relativePath)
: null,
localThumbnailUrl:
resolvedHtml?.relativePath != null
? buildWorkspaceThumbnailUrl(user.id, resolvedHtml.relativePath)
: null,
};
}
api.get('/mindspace/v1/pages/chat-save-preview', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.query);
if (!bundle.resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
const baseHref = buildWorkspaceBaseHref(req.currentUser.id, bundle.resolvedHtml.relativePath);
const html = injectHtmlBaseHref(bundle.resolvedHtml.content, baseHref);
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', 'private, no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(html);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/chat-save-thumbnail', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.query);
if (!bundle.resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
const publishDir = resolvePublishDir(__dirname, req.currentUser);
const thumbRel = workspaceThumbnailRelativePath(bundle.resolvedHtml.relativePath);
const title =
bundle.previewTitle ||
bundle.resolvedHtml.suggestedTitle ||
bundle.analysis.suggestedTitle;
const subtitle =
bundle.previewSummary ||
bundle.resolvedHtml.suggestedSummary ||
bundle.analysis.suggestedSummary;
await ensureWorkspaceHtmlThumbnail(
publishDir,
bundle.resolvedHtml.relativePath,
bundle.resolvedHtml.content,
{ title, subtitle, force: true },
).catch(() => {});
const storageRoot =
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
const resolveAssetDataUri = createAssetDataUriResolver(
authPool,
storageRoot,
req.currentUser.id,
);
const svg = await generateHtmlThumbnail(publishDir, thumbRel, bundle.resolvedHtml.content, {
title,
subtitle,
contentBaseDir: path.dirname(bundle.resolvedHtml.absolute),
resolveAssetDataUri,
force: true,
});
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'private, no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(svg);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/analyze-chat-save', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const sessionId = req.body?.session_id;
const messageId = req.body?.message_id;
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
const {
source,
analysis,
resolvedHtml,
previewTitle,
previewSummary,
previewFrameUrl,
thumbnailUrl,
localPreviewUrl,
localThumbnailUrl,
} = bundle;
let thumbnailReady = false;
if (resolvedHtml?.content && resolvedHtml.relativePath) {
const publishDir = resolvePublishDir(__dirname, req.currentUser);
try {
await ensureWorkspaceHtmlThumbnail(publishDir, resolvedHtml.relativePath, resolvedHtml.content, {
title: previewTitle || resolvedHtml.suggestedTitle,
subtitle: previewSummary || resolvedHtml.suggestedSummary,
force: Boolean(previewTitle || previewSummary),
});
thumbnailReady = true;
} catch {
thumbnailReady = false;
}
}
const existingPage = await resolveExistingSavedPage(req.currentUser.id, {
sessionId,
messageId,
relativePath: resolvedHtml?.relativePath ?? analysis.relativePath,
});
return sendData(res, req, {
contentMode: analysis.contentMode,
links: analysis.links,
selectedLinkIndex: analysis.selectedLink
? analysis.links.findIndex((link) => link.publicUrl === analysis.selectedLink.publicUrl)
: -1,
suggestedTitle: resolvedHtml?.suggestedTitle ?? analysis.suggestedTitle,
suggestedSummary: resolvedHtml?.suggestedSummary ?? analysis.suggestedSummary,
previewUrl: analysis.previewUrl,
previewFrameUrl,
localPreviewUrl,
localThumbnailUrl,
thumbnailUrl: resolvedHtml ? thumbnailUrl : null,
thumbnailReady,
relativePath: resolvedHtml?.relativePath ?? analysis.relativePath,
filename: resolvedHtml?.filename ?? analysis.filename,
hasHtmlContent: Boolean(resolvedHtml),
privacyScan: scanContent(resolvedHtml?.content ?? source.content),
existingPage: existingPage
? { id: existingPage.id, title: existingPage.title, categoryCode: existingPage.categoryCode }
: null,
});
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
console.log('[quick-share] bundle resolved, hasHtml:', Boolean(bundle.resolvedHtml));
if (!bundle.resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
const storageRoot =
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace');
const { html: localizedHtml } = await inlinePrivateAssetsInHtml(
authPool,
storageRoot,
req.currentUser.id,
bundle.resolvedHtml.content,
);
const publishDir = resolvePublishDir(__dirname, req.currentUser);
const sharedDir = path.join(publishDir, PUBLIC_ZONE_DIR, 'shared');
await fsPromises.mkdir(sharedDir, { recursive: true });
const basename = bundle.resolvedHtml.filename.replace(/\.html$/i, '');
const filename = `${basename}-${crypto.randomUUID().slice(0, 8)}.html`;
const sharedRelativePath = `${PUBLIC_ZONE_DIR}/shared/${filename}`;
const sharedHtml = rewriteWorkspacePublicAssetReferences(localizedHtml, sharedRelativePath);
const destPath = path.join(sharedDir, filename);
await fsPromises.writeFile(destPath, sharedHtml, 'utf8');
const publishKey = req.currentUser.id;
const publicUrl = `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/${sharedRelativePath.split('/').map((part) => encodeURIComponent(part)).join('/')}`;
return res.status(201).json({ data: { publicUrl, filename } });
} catch (error) {
console.error('[quick-share] error:', error);
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
if (!mindSpacePages || !mindSpaceAssets) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const categoryCode = String(req.body?.category_code ?? 'draft');
if (!SAVE_TARGET_CATEGORIES.has(categoryCode)) {
throw Object.assign(new Error('无效的保存目标'), { code: 'invalid_category_code' });
}
const source = await resolveOwnedAssistantMessage(
req.currentUser.id,
req.body?.session_id,
req.body?.message_id,
);
const analysis = analyzeChatMessageForSave({
content: source.content,
userId: req.currentUser.id,
username: req.currentUser.username,
h5Root: __dirname,
selectedLinkIndex: Number(req.body?.selected_link_index ?? 0),
});
const snapshot = {
session_name: source.session.name,
message_created: source.message.created,
role: source.message.role,
content_mode: analysis.contentMode,
public_url: analysis.previewUrl,
relative_path: analysis.relativePath
? normalizeWorkspaceRelativePath(analysis.relativePath)
: analysis.relativePath,
};
let resolvedHtml = null;
if (analysis.contentMode === 'static_html') {
resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null);
}
const privacyScan = scanContent(resolvedHtml?.content ?? source.content);
if (categoryCode !== 'draft') {
let buffer;
let filename;
let displayName = req.body?.title;
if (analysis.contentMode === 'static_html') {
if (!resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
buffer = Buffer.from(resolvedHtml.content, 'utf8');
filename = resolvedHtml.filename;
displayName = displayName || resolvedHtml.suggestedTitle;
} else {
buffer = Buffer.from(source.content, 'utf8');
filename = `${String(displayName || 'chat-export')
.replace(/[^\w\u4e00-\u9fff-]+/g, '-')
.slice(0, 48) || 'chat-export'}.md`;
}
const asset = await mindSpaceAssets.createChatAsset(req.currentUser.id, {
categoryCode,
buffer,
filename,
displayName,
sourceType: 'chat',
});
return res.status(201).json({
data: {
kind: 'asset',
categoryCode,
asset,
},
});
}
let pageInput = {
title: req.body?.title,
summary: req.body?.summary,
templateId: req.body?.template_id,
pageType: req.body?.page_type,
categoryCode: 'draft',
};
if (analysis.contentMode === 'static_html') {
if (!resolvedHtml) {
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
}
pageInput = {
...pageInput,
title: req.body?.title || resolvedHtml.suggestedTitle,
summary: req.body?.summary || resolvedHtml.suggestedSummary,
content: resolvedHtml.content,
contentFormat: 'html',
pageType: 'html',
};
} else {
pageInput = {
...pageInput,
content: source.content,
contentFormat: 'markdown',
};
}
if (analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath) {
const publishDir = resolvePublishDir(__dirname, req.currentUser);
await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, {
title: pageInput.title,
subtitle: pageInput.summary,
}).catch(() => {});
}
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);
page = await mindSpacePages.updatePage(req.currentUser.id, replacePageId, {
...pageInput,
expectedVersion: existingPage.versionNo,
});
} else {
page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, {
sessionId: req.body.session_id,
messageId: req.body.message_id,
snapshot,
});
}
return res.status(201).json({ data: { kind: 'page', categoryCode: 'draft', page } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const page = await mindSpacePages.createPage(req.currentUser.id, {
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
contentFormat: req.body?.content_format === 'html' ? 'html' : undefined,
templateId: req.body?.template_id,
pageType: req.body?.page_type,
categoryCode: req.body?.category_code,
});
return res.status(201).json({ data: page });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
await syncUserGeneratedPages(req.currentUser.id);
const limit = Number.parseInt(String(req.query.limit ?? ''), 10);
const offset = Number.parseInt(String(req.query.offset ?? ''), 10);
const result = await mindSpacePages.listPages(req.currentUser.id, {
status: typeof req.query.status === 'string' ? req.query.status : undefined,
categoryCode: typeof req.query.category_code === 'string' ? req.query.category_code : undefined,
...(Number.isFinite(limit) ? { limit } : {}),
...(Number.isFinite(offset) ? { offset } : {}),
});
return res.json({
data: result.items,
page: {
total: result.total,
limit: result.limit,
offset: result.offset,
has_more: result.hasMore,
next_cursor: null,
},
});
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const [page, versions, publication] = await Promise.all([
mindSpacePages.getPage(req.currentUser.id, req.params.pageId),
mindSpacePages.listVersions(req.currentUser.id, req.params.pageId),
mindSpacePublications?.getCurrent(req.currentUser.id, req.params.pageId) ?? null,
]);
return res.json({ data: { ...page, versions, publication } });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.delete('/mindspace/v1/pages/:pageId', async (req, res) => {
if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return;
try {
const removeFromPlaza =
String(req.query?.remove_from_plaza ?? req.body?.remove_from_plaza ?? '').toLowerCase() ===
'true' ||
req.query?.remove_from_plaza === '1' ||
req.body?.remove_from_plaza === true;
const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId, {
removeFromPlaza,
});
const quota = await mindSpace.getQuota(req.currentUser.id);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'page.delete',
objectType: 'page',
objectId: req.params.pageId,
ip: req.ip,
detail: {
offlinedPublicationCount: result.offlinedPublicationCount,
hiddenPlazaPostCount: result.hiddenPlazaPostCount,
deletedAssetCount: result.deletedAssetCount,
freedBytes: result.freedBytes,
},
});
return sendData(res, req, { ...result, quota });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/delete-preview', async (req, res) => {
if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return;
try {
return sendData(res, req, await mindSpacePages.getDeletePreview(req.currentUser.id, req.params.pageId));
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.put('/mindspace/v1/pages/:pageId', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const page = await mindSpacePages.updatePage(req.currentUser.id, req.params.pageId, {
expectedVersion: req.body?.expected_version,
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
templateId: req.body?.template_id,
pageType: req.body?.page_type,
changeNote: req.body?.change_note,
});
return res.json({ data: page });
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/live-edit/bind', async (req, res) => {
if (!mindSpacePageLiveEdit || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const sessionId = String(req.body?.session_id ?? '').trim();
if (!sessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 session_id');
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return sendError(res, req, 403, 'forbidden', '无权绑定该 Agent 会话');
}
await mindSpacePages.getPage(req.currentUser.id, req.params.pageId);
const parentSessionId = String(req.body?.parent_session_id ?? '').trim() || null;
return sendData(
res,
req,
mindSpacePageLiveEdit.bindSession({
userId: req.currentUser.id,
sessionId,
pageId: req.params.pageId,
parentSessionId,
}),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/live-edit/fork-session', async (req, res) => {
if (!mindSpacePageEditSession || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const parentSessionId = String(req.body?.parent_session_id ?? '').trim();
if (!parentSessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 parent_session_id');
}
const gate = await userAuth.canUseChat(req.currentUser.id);
if (!gate.ok) {
return sendError(res, req, 402, gate.code ?? 'insufficient_balance', gate.message);
}
return sendData(
res,
req,
await mindSpacePageEditSession.forkSession({
userId: req.currentUser.id,
pageId: req.params.pageId,
parentSessionId,
h5ApiBase: String(req.body?.h5_api_base ?? req.body?.h5ApiBase ?? '').trim() || null,
}),
);
} catch (error) {
if (error?.code === 'forbidden') {
return sendError(res, req, 403, error.code, error.message);
}
if (error?.code === 'invalid_request') {
return sendError(res, req, 400, error.code, error.message);
}
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/live-edit/close-session', async (req, res) => {
if (!mindSpacePageEditSession || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const sessionId = String(req.body?.session_id ?? '').trim();
if (!sessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 session_id');
}
return sendData(
res,
req,
await mindSpacePageEditSession.closeSession({
userId: req.currentUser.id,
pageId: req.params.pageId,
sessionId,
parentSessionId: String(req.body?.parent_session_id ?? '').trim() || null,
summary: String(req.body?.summary ?? ''),
}),
);
} catch (error) {
if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') {
return sendError(res, req, 403, error.code, error.message);
}
if (error?.code === 'invalid_request') {
return sendError(res, req, 400, error.code, error.message);
}
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/live-edit/revision', async (req, res) => {
if (!mindSpacePageLiveEdit || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
return sendData(
res,
req,
await mindSpacePageLiveEdit.getRevisionSnapshot(req.currentUser.id, req.params.pageId),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/agent/mindspace_page_patch', async (req, res) => {
if (!mindSpacePageLiveEdit) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const result = await mindSpacePageLiveEdit.applyAgentPatch(req.body ?? {});
return res.json({
data: {
pageId: result.page.id,
title: result.page.title,
summary: result.page.summary,
versionNo: result.page.versionNo,
updatedAt: result.page.updatedAt,
liveRevision: result.liveRevision,
},
});
} catch (error) {
if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') {
return sendError(res, req, 403, error.code, error.message);
}
if (error?.code === 'invalid_request') {
return sendError(res, req, 400, error.code, error.message);
}
return mindSpaceError(res, req, error);
}
});
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 {
const svg = await mindSpacePages.renderThumbnail(req.currentUser.id, req.params.pageId);
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'private, max-age=300');
res.setHeader('X-Request-Id', req.requestId);
return res.send(svg);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/thumbnail/upload', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePages.uploadThumbnail(req.currentUser.id, req.params.pageId, {
imageBase64: req.body?.image_base64,
mimeType: req.body?.mime_type,
title: req.body?.title,
summary: req.body?.summary,
html: req.body?.html,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/thumbnail/regenerate', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePages.regenerateThumbnail(
req.currentUser.id,
req.params.pageId,
{
html: req.body?.html,
title: req.body?.title,
summary: req.body?.summary,
useAi: Boolean(req.body?.use_ai),
instruction: req.body?.instruction,
},
{
suggestCoverMeta: req.body?.use_ai
? (input) => {
if (!authPool) {
throw Object.assign(new Error('LLM 服务未就绪'), { code: 'llm_not_configured' });
}
return suggestCoverMetaWithAi(authPool, {
...input,
encryptionKey:
process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY,
});
}
: null,
},
);
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/rewrite-download-links', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
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 {
const { html, contentFormat } = await mindSpacePages.renderPreview(
req.currentUser.id,
req.params.pageId,
);
res.set('Content-Type', 'text/html; charset=utf-8');
res.set(
'Content-Security-Policy',
pageInternals.previewContentSecurityPolicy(contentFormat),
);
res.set('Cache-Control', 'private, no-store');
res.set('X-Content-Type-Options', 'nosniff');
return res.send(html);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/preview-draft', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const { html, contentFormat } = await mindSpacePages.renderDraftPreview(
req.currentUser.id,
req.params.pageId,
{
title: req.body?.title,
summary: req.body?.summary,
content: req.body?.content,
templateId: req.body?.template_id,
},
);
res.set('Content-Type', 'text/html; charset=utf-8');
res.set(
'Content-Security-Policy',
pageInternals.previewContentSecurityPolicy(contentFormat),
);
res.set('Cache-Control', 'private, no-store');
res.set('X-Content-Type-Options', 'nosniff');
return res.send(html);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/publish-check', async (req, res) => {
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePublications.check(req.currentUser.id, req.params.pageId, {
pageVersionId: req.body?.page_version_id,
accessMode: req.body?.access_mode,
urlSlug: req.body?.url_slug,
password: req.body?.password,
expiresAt: req.body?.expires_at,
});
return sendData(res, req, result);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/redact', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpacePages.redactPage(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.redact',
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/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 {
const result = await mindSpacePages.redactPage(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.redact',
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/publish', async (req, res) => {
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const publication = await mindSpacePublications.publish(
req.currentUser.id,
req.params.pageId,
{
pageVersionId: req.body?.page_version_id,
accessMode: req.body?.access_mode,
urlSlug: req.body?.url_slug,
password: req.body?.password,
expiresAt: req.body?.expires_at,
acknowledgedFindingIds: req.body?.acknowledged_finding_ids,
},
);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'page.publish',
objectType: 'publication',
objectId: publication.id,
ip: req.ip,
});
return sendData(res, req, publication, 201);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/publications/:publicationId/update-status', async (req, res) => {
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const publication = await mindSpacePublications.updatePublicationStatus(
req.currentUser.id,
req.params.publicationId,
{
accessMode: req.body?.access_mode,
expiresAt: req.body?.expires_at,
},
);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'publication.status_updated',
objectType: 'publication',
objectId: req.params.publicationId,
ip: req.ip,
});
return sendData(res, req, publication);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/publications/:publicationId/offline', async (req, res) => {
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const publication = await mindSpacePublications.offline(
req.currentUser.id,
req.params.publicationId,
);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'page.offline',
objectType: 'publication',
objectId: req.params.publicationId,
ip: req.ip,
});
return sendData(res, req, publication);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/publications/:publicationId/stats', async (req, res) => {
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
return sendData(
res,
req,
await mindSpacePublications.getStats(
req.currentUser.id,
req.params.publicationId,
),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.get('/plaza/v1/categories', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
try {
return sendData(res, req, { categories: await plazaPosts.listCategories() });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.get('/plaza/v1/seo/sitemap', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
if (!plazaSeo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza SEO 未启用');
try {
const data = await plazaSeo.listSitemapData({
postLimit: req.query.post_limit,
userLimit: req.query.user_limit,
});
return sendData(res, req, data);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/attribution/events', async (req, res) => {
if (!plazaSeo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
try {
const result = await plazaSeo.recordAttribution(req.body ?? {}, plazaClientIp(req));
return sendData(res, req, result, 201);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/reports', async (req, res) => {
if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const report = await plazaOps.createReport(req.currentUser.id, {
target_type: 'post',
target_id: req.params.id,
reason: req.body?.reason,
detail: req.body?.detail,
});
return sendData(res, req, { report }, 201);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/comments/:id/reports', async (req, res) => {
if (!plazaOps) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const report = await plazaOps.createReport(req.currentUser.id, {
target_type: 'comment',
target_id: req.params.id,
reason: req.body?.reason,
detail: req.body?.detail,
});
return sendData(res, req, { report }, 201);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.get('/plaza/v1/feed', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
try {
const sessionId = resolvePlazaSessionId(req, res);
const feed = await plazaPosts.listFeed({
sort: req.query.sort,
categorySlug: req.query.category ?? null,
cursor: req.query.cursor ?? null,
limit: req.query.limit,
viewerId: req.currentUser?.id ?? null,
sessionId,
});
return sendData(res, req, feed);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/events', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
if (!plazaEvents) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
try {
const sessionId =
String(req.body?.session_id ?? '').trim() || resolvePlazaSessionId(req, res);
const result = await plazaEvents.recordEvents({
userId: req.currentUser?.id ?? null,
sessionId,
events: req.body?.events ?? [],
});
return sendData(res, req, { ...result, session_id: sessionId }, 201);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/reactions', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await plazaInteractions.addReaction(
req.currentUser.id,
req.params.id,
req.body?.type,
);
const eventType = reactionEventType(result.type);
if (eventType) {
recordPlazaEventsAsync(req, res, [{ event_type: eventType, post_id: req.params.id }]);
}
return sendData(res, req, result);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await plazaInteractions.removeReaction(
req.currentUser.id,
req.params.id,
req.params.type,
);
return sendData(res, req, result);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.get('/plaza/v1/posts/:id/comments', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
try {
const comments = await plazaInteractions.listComments(req.params.id, {
cursor: req.query.cursor ?? null,
limit: req.query.limit,
parentId: req.query.parent_id ?? null,
viewerId: req.currentUser?.id ?? null,
});
return sendData(res, req, comments);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/comments', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const comment = await plazaInteractions.createComment(req.currentUser.id, req.params.id, req.body ?? {});
recordPlazaEventsAsync(req, res, [{ event_type: 'comment', post_id: req.params.id }]);
return sendData(res, req, { comment }, 201);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.delete('/plaza/v1/comments/:id', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const comment = await plazaInteractions.deleteComment(req.currentUser.id, req.params.id);
return sendData(res, req, { comment });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/comments/:id/reactions', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const liked = req.body?.liked !== false;
const result = await plazaInteractions.toggleCommentLike(req.currentUser.id, req.params.id, liked);
return sendData(res, req, result);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.get('/plaza/v1/users/:slug', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
try {
const profile = await plazaInteractions.getUserProfile(
req.params.slug,
req.currentUser?.id ?? null,
);
return sendData(res, req, profile);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.get('/plaza/v1/users/:slug/posts', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
try {
const feed = await plazaInteractions.listUserPosts(req.params.slug, {
cursor: req.query.cursor ?? null,
limit: req.query.limit,
viewerId: req.currentUser?.id ?? null,
});
return sendData(res, req, feed);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/users/:slug/follow', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await plazaInteractions.followUser(req.currentUser.id, req.params.slug);
return sendData(res, req, result);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.delete('/plaza/v1/users/:slug/follow', async (req, res) => {
if (!ensurePlazaInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await plazaInteractions.unfollowUser(req.currentUser.id, req.params.slug);
return sendData(res, req, result);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.get('/plaza/v1/posts/:id', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
try {
const post = await plazaPosts.getPostById(req.params.id, {
viewerId: req.currentUser?.id ?? null,
});
void plazaRedis.recordView(req.params.id, plazaClientIp(req)).catch(() => {});
recordPlazaEventsAsync(req, res, [{ event_type: 'view', post_id: req.params.id }]);
return sendData(res, req, { post });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.post('/plaza/v1/posts', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
if (!req.currentUser) {
return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
}
try {
const post = await plazaPosts.createPost(req.currentUser.id, req.body ?? {});
return sendData(res, req, { post }, 201);
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.patch('/plaza/v1/posts/:id', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
if (!req.currentUser) {
return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
}
try {
const post = await plazaPosts.updatePost(req.currentUser.id, req.params.id, req.body ?? {});
return sendData(res, req, { post });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
api.delete('/plaza/v1/posts/:id', async (req, res) => {
if (!ensurePlazaEnabled(res, req)) return;
if (!req.currentUser) {
return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
}
try {
const post = await plazaPosts.hidePost(req.currentUser.id, req.params.id);
return sendData(res, req, { post });
} catch (error) {
return plazaRouteError(res, req, error);
}
});
function runHandlerChain(chain, req, res, next) {
let index = 0;
const run = (err) => {
if (err) return next(err);
const layer = chain[index++];
if (!layer) return;
layer(req, res, (error) => run(error));
};
run();
}
api.post('/llm/apply-local-fallback', async (req, res) => {
// Client calls after creditsExhausted or relay 500 (see useTKMindChat).
await userAuthReady;
if (!userAuth || !llmProviderService || !tkmindProxy) {
return res.status(503).json({ message: '未启用 LLM 配置' });
}
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const sessionId = String(req.body?.session_id ?? '').trim();
if (!sessionId) return res.status(400).json({ message: '缺少 session_id' });
const owns = await userAuth.ownsSession(me.id, sessionId);
if (!owns) return res.status(403).json({ message: '无权访问该会话' });
try {
const result = await tkmindProxy.applyLocalFallbackForSession(sessionId);
if (!result.ok) return res.status(503).json(result);
res.json(result);
} catch (err) {
res.status(500).json({
message: err instanceof Error ? err.message : '切换本地 LLM 失败',
});
}
});
api.post('/agent/start', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
return runHandlerChain(tkmindProxy.handlers['POST /agent/start'], req, res, next);
});
api.post('/agent/runs', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy || !agentRunGateway) return next();
return runHandlerChain(
[
tkmindProxy.requireUser,
tkmindProxy.ensureChatAllowed,
createPostAgentRunsHandler({ userAuth, agentRunGateway }),
],
req,
res,
next,
);
});
api.get('/agent/runs/:runId', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy || !agentRunGateway) return next();
return runHandlerChain(
[
tkmindProxy.requireUser,
createGetAgentRunHandler({ agentRunGateway }),
],
req,
res,
next,
);
});
api.get('/agent/runs/:runId/events', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy || !agentRunGateway) return next();
return runHandlerChain(
[
tkmindProxy.requireUser,
createAgentRunEventsHandler({ agentRunGateway }),
],
req,
res,
next,
);
});
api.post('/agent/resume', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
return runHandlerChain(tkmindProxy.handlers['POST /agent/resume'], req, res, next);
});
api.get('/sessions', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return 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) {
// REGRESSION GUARD: without both hints, stale snapshot can wipe mid-turn chat.
const canUseSnapshotCache = hintMc != null && hintUa != null;
const mcMatch = snapshot.meta.synced_msg_count === hintMc;
const uaMatch = snapshot.meta.source_updated_at === hintUa;
if (canUseSnapshotCache && mcMatch && uaMatch) {
const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks(
snapshot.messages,
req.currentUser,
);
// Cache hit — reconstruct a Goose-compatible session response.
const cachedGooseSession = {
...snapshot.session,
// Embed only userVisible messages so getSession callers still work.
conversation: sanitizedMessages,
};
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();
if (Array.isArray(gooseSession.conversation)) {
gooseSession.conversation = sanitizeSessionConversationPublicHtmlLinks(
gooseSession.conversation,
req.currentUser,
);
}
// Write-through: persist snapshot async, don't block the response.
if (sessionSnapshotService?.isEnabled()) {
const messages = (gooseSession.conversation ?? [])
.filter((m) => m.metadata?.userVisible);
if (messages.length > 0) {
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();
const sessionId = req.params.sessionId;
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const deleteTarget = await tkmindProxy.resolveTarget(sessionId);
const upstream = await tkmindProxy.apiFetchTo(deleteTarget, `/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
});
if (!upstream.ok && upstream.status !== 404) {
const text = await upstream.text().catch(() => '');
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 : '删除会话失败' });
}
});
api.get('/sessions/:sessionId/events', 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: '无权访问该会话' });
}
const publishDir = resolvePublishDir(__dirname, { id: req.currentUser.id });
const syncPublicHtmlDuringStream = (event) => {
materializePublicHtmlWritesFromSessionEvent(event, { publishDir });
};
// After Finish, refresh the snapshot and persist any newly generated public
// workspace HTML into the asset store before a later restart rebuilds the
// workspace from DB-backed assets only.
const onAfterFinish = async (sid, uid) => {
const apiFetchFn = async (pathname, init) => {
const target = await tkmindProxy.resolveTarget(sid);
return tkmindProxy.apiFetchTo(target, pathname, init);
};
let messages = null;
if (sessionSnapshotService?.isEnabled()) {
await sessionSnapshotService.refresh(sid, uid, apiFetchFn);
messages = (await sessionSnapshotService.get(sid))?.messages ?? null;
} else {
try {
const upstream = await apiFetchFn(`/sessions/${encodeURIComponent(sid)}`, { method: 'GET' });
if (upstream.ok) {
const payload = await upstream.json().catch(() => null);
messages = Array.isArray(payload?.conversation)
? payload.conversation.filter((message) => message?.metadata?.userVisible)
: null;
}
} catch {
messages = null;
}
}
const syncResult = await syncPublicHtmlAfterFinish({
messages,
currentUser: req.currentUser,
publishDir,
syncWorkspaceAssets:
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
: null,
});
if (Array.isArray(syncResult?.docxSync?.missing) && syncResult.docxSync.missing.length > 0) {
console.warn(
`[MindSpace] missing public download files after finish for user ${uid}: ${syncResult.docxSync.missing.join(', ')}`,
);
}
await syncUserGeneratedPages(uid);
};
return tkmindProxy.proxySessionEvents(req, res, sessionId, {
onAfterFinish,
onEvent: syncPublicHtmlDuringStream,
});
});
api.use(async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
if (isNativeH5ApiPath(req.path)) {
return sendError(res, req, 404, 'not_found', `接口不存在:${req.method} ${req.path}`);
}
if (req.method === 'GET' && /^\/agent\/runs\/[^/]+\/events$/.test(req.path)) {
return res.status(503).json({
message: 'Agent Run SSE 接口需在 Portal 本地处理,请确认 server.mjs 已更新并重启后端',
code: 'AGENT_RUNS_NATIVE_REQUIRED',
});
}
const sessionMatch = req.path.match(/^\/sessions\/([^/]+)/);
if (sessionMatch) {
const sessionId = sessionMatch[1];
if (req.path.endsWith('/events') && req.method === 'GET') {
return next();
}
if (req.path.endsWith('/reply') && req.method === 'POST') {
return res.status(410).json({
message: '聊天提交入口已统一为 POST /agent/runs',
code: 'AGENT_RUNS_REQUIRED',
});
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
}
if (req.method === 'POST' && req.path === '/agent/resume' && req.body?.session_id) {
const owns = await userAuth.ownsSession(req.currentUser.id, req.body.session_id);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
}
if (req.body?.working_dir) {
const allowed = await userAuth.isPathAllowed(req.currentUser.id, req.body.working_dir);
if (!allowed) {
return res.status(403).json({ message: '工作目录不在授权范围内' });
}
}
return tkmindProxy.proxyFallback(req, res);
});
api.use(
createProxyMiddleware({
target: API_TARGET,
changeOrigin: true,
secure: false,
pathRewrite: { '^/api': '' },
on: {
proxyReq: (proxyReq) => {
proxyReq.setHeader('X-Secret-Key', API_SECRET);
},
},
}),
);
app.use('/api', api);
function scriptSrcDirective({ inline = false, urls = [], hashes = [] } = {}) {
const parts = [];
if (inline) parts.push("'unsafe-inline'");
for (const hash of hashes) parts.push(`'sha256-${hash}'`);
for (const url of urls) parts.push(url);
return parts.length ? `script-src ${parts.join(' ')}` : "script-src 'none'";
}
function publishedPageCsp(html, { embed = false, raw = false, wechatShare = false, scriptHashes = [] } = {}) {
const isFullHtml = /^\s*]/i.test(html);
if (embed && isFullHtml) {
return publishedPageCspForEmbed(true);
}
if (wechatShare && isFullHtml) {
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline' https://res.wx.qq.com";
}
if (raw && isFullHtml) {
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'";
}
if (isFullHtml) {
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({ hashes: scriptHashes })}`;
}
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
}
const PUBLIC_FILE_SHARE_SCRIPT = `(function(){var root=document.querySelector('[data-mindspace-public-share]');if(!root)return;var button=root.querySelector('button');var status=root.querySelector('small');var timer=null;function setStatus(text,err){if(!status)return;status.textContent=text||'';status.classList.toggle('is-error',!!err);if(timer)clearTimeout(timer);if(text)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},2200);}function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}button&&button.addEventListener('click',async function(){var url=location.href.split('#')[0];var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});})();`;
const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
.createHash('sha256')
.update(PUBLIC_FILE_SHARE_SCRIPT)
.digest('base64');
function injectPublicFileShareButton(html) {
const source = String(html ?? '');
if (!source || source.includes('data-mindspace-public-share')) {
return { html: source, scriptHashes: [] };
}
const markup = `
`;
if (/<\/body>/i.test(source)) {
return {
html: source.replace(/<\/body>/i, `${markup}
分享链接
${safeShareUrl}