Files
memind/server.mjs
T
john 32fb2cdeaf feat: chat uploads, vision turn isolation, and MindSpace agent improvements
Add chat file/image upload UX, attachment proxying, vision thumbnails, and per-turn image scoping so agents only use the current upload. Extend MindSpace asset context, billing token state, OA/scenario verify scripts, and related runtime config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 00:23:01 +08:00

6154 lines
221 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { createWorkspacePageDeliverService } from './mindspace-workspace-page-deliver.mjs';
import { createToolGateway } from './tool-gateway.mjs';
import { createAgentRunGateway } from './agent-run-gateway.mjs';
import {
createAgentRunEventsHandler,
createGetAgentRunHandler,
createPostAgentRunsHandler,
} from './agent-run-routes.mjs';
import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs';
import { createSessionAccess, isSessionBrokerEnabled } from './session-broker.mjs';
import { isSessionBrokerMetricsEnabled } from './session-broker-metrics.mjs';
import {
clearUserLogoutCookies,
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 } 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 } from './mindspace.mjs';
import { ensureMindSpaceConfig } from './mindspace-config.mjs';
import { pageInternals, inlinePrivateAssetsInHtml, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
import {
createDownloadConversationPackageArtifactHandler,
createDownloadConversationPackageManifestHandler,
createGetConversationPackageHandler,
} from './mindspace-conversation-package-routes.mjs';
import { registerPublicHtmlArtifactsForConversationPackage } from './mindspace-conversation-package-public-html.mjs';
import {
assertMindSpaceServerAdapterContract,
createMindSpaceServerAdapter,
} from './mindspace-server-adapter.mjs';
import {
resolveMindSpacePublicRequest,
} from './mindspace-public-route.mjs';
import {
buildPublishedHtmlViewContext,
injectPublishedPageDataContext,
parseMindSpacePublishFilePath,
resolvePublicRequestOrigin,
} from './mindspace-public-page-context.mjs';
import {
preparePublicHtmlAssetDelivery,
verifyPublicAssetToken,
} from './mindspace-public-asset-token.mjs';
import {
collectInlineScriptHashes,
decorateMindSpacePublishedHtml,
handleMindSpaceLongImageDownload,
} from './mindspace-public-delivery.mjs';
import {
buildMindSpacePublicRoutePath,
buildMindSpacePublicUrlForUser,
isPublicWorkspaceHtmlRelativePath,
resolveMindSpaceRuntimeConfig,
resolveMindSpacePublishRoot,
resolveMindSpaceServerRuntimeOptions,
resolveMindSpaceUserPublishDir,
} from './mindspace-runtime-config.mjs';
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
import {
publicationInternals,
rewriteBrokenMindSpacePublicImageUrls,
rewritePublicationCanonicalAssetUrls,
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,
stripPublicationHtmlCspMeta,
} from './plaza-embed.mjs';
import { publishedPageCsp } from './mindspace-published-page-csp.mjs';
import { rewriteKnownCdnScriptSources } from './mindspace-published-script-localize.mjs';
import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs';
import {
analyzeChatMessageForSave,
buildChatSavePreviewFrameUrl,
buildChatSaveThumbnailUrl,
buildWorkspaceAssetUrl,
buildWorkspaceBaseHref,
buildWorkspaceThumbnailUrl,
injectHtmlBaseHref,
resolveClosestHtmlRelativePath,
createAssetDataUriResolver,
htmlReferencesPrivateAssets,
materializePrivateAssetsInWorkspaceHtml,
repairMissingHtmlAssetReferences,
resolveChatSaveAnalysis,
resolveStaticHtmlContent,
} from './mindspace-chat-save.mjs';
import {
materializePublicHtmlWritesFromSessionEvent,
normalizePublicHtmlRelativePath,
syncPublicHtmlAfterFinish,
} from './mindspace-public-finish-sync.mjs';
import { maybeRepairH5HtmlAfterFinish } from './mindspace-h5-html-finish-guard.mjs';
import { quickPlazaFromChat, quickPlazaFromPublicHtml, getQuickPlazaFromPublicHtmlStatus } from './mindspace-chat-plaza.mjs';
import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs';
import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.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 {
isLongImageDownloadRequest,
longImagePathForHtml,
renderLongImage,
renderLongImageBuffer,
} from './mindspace-long-image.mjs';
import { generateDocxBuffer } from './mindspace-docx-export.mjs';
import {
DOCX_MIME_TYPE,
registerChatDocxArtifactForConversation,
} from './mindspace-chat-docx-package.mjs';
import { scanContent } from './mindspace-content-scan.mjs';
import { renderImageAssetViewerHtml, wantsInlineImageViewer } from './mindspace-asset-preview.mjs';
import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES } from './user-image-normalize.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 { loadWechatMpConfig } from './wechat-mp-config.mjs';
import { loadWechatMpModule } from './wechat-mp-loader.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 { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshot } from './direct-chat-service.mjs';
import { filterUserVisibleConversation, repairSessionConversationFromDb } from './conversation-repair.mjs';
import { filterNonemptyUserVisibleMessages } from './conversation-transcript-persist.mjs';
import { createSessionStreamStore } from './session-stream-store.mjs';
import { isSessionStreamReplayEnabled } from './session-stream.mjs';
import { createManagedChatIntentRouter, resolveNormalizedRouterDecisionMode } from './chat-intent-router.mjs';
import { createSessionSnapshotService } from './session-snapshot.mjs';
import { createConversationMemoryService } from './conversation-memory.mjs';
import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs';
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
import { createExperienceService } from './experience-service.mjs';
import { attachAsrRoutes } from './asr-proxy.mjs';
import { attachPageDataRoutes } from './page-data-routes.mjs';
import { createPageDataService } from './page-data-service.mjs';
import { createPageDataPublicService, isPageDataPublicPath } from './page-data-public-service.mjs';
import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs';
import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs';
import { attachShenmeiOpinionFormRoutes } from './shenmei-opinion-form-routes.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 mindSpaceServerRuntime = resolveMindSpaceServerRuntimeOptions(__dirname, process.env);
const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD;
const WECHAT_MP_CONFIG = loadWechatMpConfig();
// 无状态前端节点(如 105MindSpace 经 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);
app.use('/mindspace', 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: Math.max(mindSpaceServerRuntime.maxFileBytes, DEFAULT_IMAGE_UPLOAD_MAX_BYTES),
});
const wikiAuth = createWikiAuth(path.join(resolveMindSpacePublishRoot(__dirname), '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 sessionAccess = null;
let sessionStreamStore = null;
let tkmindProxy = null;
async function resolveActiveSessionAccess() {
if (sessionAccess) return sessionAccess;
if (!userAuth) return null;
return createSessionAccess({ userAuth, enabled: isSessionBrokerEnabled() });
}
async function ownsAgentSession(userId, sessionId) {
if (!userId || !sessionId) return false;
const access = await resolveActiveSessionAccess();
if (!access) return false;
return access.validateOwnership(userId, sessionId);
}
async function unregisterAgentSessionForUser(userId, sessionId) {
if (!userId || !sessionId) return;
const access = await resolveActiveSessionAccess();
if (!access) return;
await access.unregisterSession({ userId, sessionId });
}
let agentRunGateway = null;
let chatIntentRouter = null;
let toolGateway = null;
let directChatService = null;
let sessionSnapshotService = null;
let conversationMemoryService = null;
let memoryV2 = null;
let memoryV2ConfigService = null;
let wechatScheduleLlmConfigService = null;
let mindSpace = null;
let mindSpaceAssets = null;
let mindSpaceAudit = null;
let mindSpaceServiceFacade = null;
let mindSpaceConversationPackageRegistry = null;
let mindSpacePages = null;
let mindSpacePageSync = null;
let mindSpacePageLiveEdit = null;
let mindSpaceAssetAgent = null;
let mindSpacePageEditSession = null;
let mindSpacePublications = null;
let workspacePageDeliver = 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;
let pageDataService = null;
let pageDataPublicService = 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',
});
pageDataService = createPageDataService({
getUserAuth: () => userAuth,
getPool: () => authPool,
resolveWorkspaceRoot: async (user) => {
if (user?.workspaceRoot) return user.workspaceRoot;
if (!userAuth || !user?.id) return null;
return userAuth.resolveWorkingDir(user.id);
},
});
pageDataPublicService = createPageDataPublicService({
getPool: () => authPool,
resolveH5Root: () => __dirname,
});
feedbackService = createFeedbackService(pool);
mindSpace = createMindSpaceService(pool, {
maxFileBytes: mindSpaceServerRuntime.maxFileBytes,
aiDailyLimit: mindSpaceServerRuntime.aiDailyLimit,
publicPageLimit: mindSpaceServerRuntime.publicPageLimit,
monthlyViewLimit: mindSpaceServerRuntime.monthlyViewLimit,
scheduleService,
});
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;
};
const mindSpaceRuntimeAdapter = assertMindSpaceServerAdapterContract(
createMindSpaceServerAdapter({
pool,
h5Root: __dirname,
env: process.env,
maxFileBytes: mindSpaceServerRuntime.maxFileBytes,
publicPageLimit: mindSpaceServerRuntime.publicPageLimit,
resolveUserIdForAgentSession,
resolveWorkspaceRoot: (userId) => userAuth.resolveWorkingDir(userId),
resolveSessionSnapshot: (sessionId) => sessionSnapshotService?.get?.(sessionId),
registerPublicHtmlArtifactsForConversation,
syncWorkspaceAssetsEnabled: WORKSPACE_MAINTENANCE_ENABLED,
remote: mindSpaceServerRuntime.remote,
logger: console,
}),
);
mindSpaceRuntimeAdapter.assertReady?.();
mindSpaceServiceFacade = mindSpaceRuntimeAdapter.serviceFacade;
mindSpaceConversationPackageRegistry = mindSpaceRuntimeAdapter.conversationPackageRegistry;
mindSpaceAssets = mindSpaceRuntimeAdapter.assetService;
mindSpacePages = mindSpaceRuntimeAdapter.pageService;
mindSpacePageSync = mindSpaceRuntimeAdapter.pageSyncService;
mindSpacePageLiveEdit = mindSpaceRuntimeAdapter.pageLiveEditService;
mindSpaceAssetAgent = mindSpaceRuntimeAdapter.assetAgentService;
mindSpacePublications = mindSpaceRuntimeAdapter.publicationService;
workspacePageDeliver = createWorkspacePageDeliverService({
pool,
pageService: mindSpacePages,
publicationService: mindSpacePublications,
pageSyncService: mindSpacePageSync,
logger: console,
});
const resolveUserIdByDirKey = async (dirKey) => {
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;
}
return userId ?? null;
};
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 = mindSpaceRuntimeAdapter.cleanupService;
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,
});
sessionAccess = createSessionAccess({ userAuth, enabled: isSessionBrokerEnabled() });
if (sessionAccess.enabled) {
console.log('[Portal] Session Broker enabled (MEMIND_SESSION_BROKER_ENABLED=1)');
}
const routerDecisionMode = resolveNormalizedRouterDecisionMode(process.env);
const h5SessionFlags = [
sessionAccess.enabled && 'MEMIND_SESSION_BROKER_ENABLED',
isSessionBrokerMetricsEnabled() && 'MEMIND_SESSION_BROKER_METRICS',
routerDecisionMode !== 'off' && `MEMIND_ROUTER_NORMALIZED_DECISION=${routerDecisionMode}`,
isSessionStreamReplayEnabled() && 'MEMIND_SESSION_STREAM_REPLAY',
['1', 'true', 'yes', 'on'].includes(String(process.env.MEMIND_SSE_EVENT_TAXONOMY ?? '').trim().toLowerCase()) && 'MEMIND_SSE_EVENT_TAXONOMY',
['1', 'true', 'yes', 'on'].includes(String(process.env.MEMIND_RUN_STREAM_REPLAY ?? '').trim().toLowerCase()) && 'MEMIND_RUN_STREAM_REPLAY',
['1', 'true', 'yes', 'on'].includes(String(process.env.MEMIND_H5_HTML_FINISH_GUARD ?? '').trim().toLowerCase()) && 'MEMIND_H5_HTML_FINISH_GUARD',
].filter(Boolean);
if (h5SessionFlags.length) {
console.log(`[Portal] H5 session flags: ${h5SessionFlags.join(', ')}`);
}
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 = mindSpaceRuntimeAdapter.agentJobService;
// 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 (mindSpaceServerRuntime.experienceEnabled) {
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,
sessionAccess,
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.
mindSpaceRuntimeAdapter.startBackgroundJobs({
publicationCleanupIntervalMs: 60 * 1000,
agentWorker: mindSpaceServerRuntime.agentWorker,
agentRunner: mindSpaceAgentRunner,
workspaceMaintenanceEnabled: WORKSPACE_MAINTENANCE_ENABLED,
publishRoot: mindSpaceServerRuntime.publishRoot,
startWorkspaceThumbnailWatcher,
startWorkspaceAssetSyncWatcher,
syncUserWorkspaceByDirKey: async (dirKey, options) => {
const userId = await resolveUserIdByDirKey(dirKey);
if (!userId) return;
await mindSpaceAssets.syncWorkspaceAssets(userId, options);
},
expireStaleUploadsIntervalMs: 5 * 60 * 1000,
});
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);
});
memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
conversationMemoryService = createConversationMemoryService(pool, {
llmProviderService,
getEffectiveEnv: async () => {
const state = await memoryV2ConfigService.getRuntimeState().catch(() => null);
return { ...process.env, ...(state?.overrides ?? {}) };
},
});
memoryV2 = await createManagedMemoryV2Runtime({
legacyMemoryService: conversationMemoryService,
configService: memoryV2ConfigService,
mysqlPool: pool,
});
sessionSnapshotService = createSessionSnapshotService(pool, {
conversationMemoryService,
memoryV2,
});
if (isSessionStreamReplayEnabled()) {
sessionStreamStore = createSessionStreamStore({ pool });
}
directChatService = createDirectChatService({
userAuth,
sessionAccess,
llmProviderService,
sessionSnapshotService,
memoryV2,
conversationMemoryService,
});
chatIntentRouter = createManagedChatIntentRouter({
llmProviderService,
memoryV2,
conversationMemoryService,
configService: memoryV2ConfigService,
});
// GOOSED PROXY BOUNDARY: H5 chat → goosed 唯一入口(Patch 5, goosed-proxy-boundary.mjs
tkmindProxy = createTkmindProxy({
apiTarget: API_TARGET,
apiTargets: API_TARGETS,
apiSecret: API_SECRET,
userAuth,
sessionAccess,
sessionStreamStore,
llmProviderService,
subscriptionService,
sessionSnapshotService,
conversationMemoryService,
memoryV2,
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,
sessionAccess,
tkmindProxy,
toolGateway,
directChatService,
chatIntentRouter,
sessionSnapshotService,
conversationMemoryService,
syncUserPagesOnSuccess: async ({ userId }) => {
await syncUserGeneratedPages(userId);
},
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),
});
const wechatMp = await loadWechatMpModule(__dirname);
wechatMpService = wechatMp.createWechatMpService({
config: WECHAT_MP_CONFIG,
userAuth,
sessionAccess,
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,
wechatScheduleLlmConfigService,
llmProviderService,
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,
sessionAccess,
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) {
const secure = isSecureRequest(req);
const domain = resolveCookieDomainForRequest(req);
const cookies = clearUserLogoutCookies(secure, domain);
if (legacyAuth) {
cookies.push(clearSessionCookie(secure));
}
res.set('Set-Cookie', cookies);
}
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(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>微信登录</title></head><body><p>扫码登录成功,请返回电脑继续操作。</p></body></html>`);
}
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;
if (userAuth) {
await userAuth.revoke(userToken(req));
}
if (legacyAuth) {
legacyAuth.revoke(legacySessionToken(req));
}
if (userAuth || legacyAuth) {
clearUserLoginCookies(res, req);
}
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(
'<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[支付未启用]]></return_msg></xml>',
);
}
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(
`<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[${message}]]></return_msg></xml>`,
);
}
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><!\[CDATA\[([\s\S]*?)\]\]><\/FromUserName>/)?.[1] ?? null;
const msgType = bodyText.match(/<MsgType><!\[CDATA\[([\s\S]*?)\]\]><\/MsgType>/)?.[1] ?? null;
const content = bodyText.match(/<Content><!\[CDATA\[([\s\S]*?)\]\]><\/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);
attachShenmeiOpinionFormRoutes(api, { rootDir: __dirname });
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 === '/agent/mindspace_asset_download') 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);
const pageDataPublic = isPageDataPublicPath(req.path, req.method);
if (userAuth && tkmindProxy) {
if (req.userSessionError) {
if (plazaPublic || pageDataPublic) 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 || pageDataPublic) 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 });
attachPageDataRoutes(api, {
sendError,
sendData,
getPageDataService: () => pageDataService,
getPageDataPublicService: () => pageDataPublicService,
});
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, userId) {
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 || '读取会话失败');
}
let session = await upstream.json();
if (authPool && userId) {
session = await repairSessionConversationFromDb(authPool, session, sessionId, userId);
}
return filterUserVisibleConversation(session?.conversation ?? []);
}
async function syncUserMemoriesIntoSession(userId, sessionId) {
if (!tkmindProxy || !sessionId) return false;
await tkmindProxy.reconcileSessionPolicyForUser(userId, sessionId);
return true;
}
async function resolveUserMemoryItems(userId, { sessionId = null, limit = 200 } = {}) {
const resolved = await memoryV2.resolve({
userId,
sessionId,
limit,
});
const memories = Array.isArray(resolved?.memories) ? resolved.memories : [];
if (memories.length || !conversationMemoryService?.listMemories) {
return memories;
}
return conversationMemoryService.listMemories(userId, { limit }).catch(() => []);
}
api.post('/user-memory/v1/remember-recent', async (req, res) => {
const memoryStatus = await memoryV2?.getStatus?.().catch(() => null);
if (!memoryStatus?.enabled) {
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 ownsAgentSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const messages = await loadUserVisibleConversation(sessionId, req.currentUser.id);
const result = await memoryV2.write({
userId: req.currentUser.id,
sessionId,
messages,
});
const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId);
const memories = await resolveUserMemoryItems(req.currentUser.id, {
sessionId,
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) => {
const memoryStatus = await memoryV2?.getStatus?.().catch(() => null);
if (!memoryStatus?.enabled) {
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 ownsAgentSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const result = await memoryV2.compact({
userId: req.currentUser.id,
sessionId,
});
const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId);
const memories = await resolveUserMemoryItems(req.currentUser.id, {
sessionId,
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 resolvePlazaPostUrlForRequest(postId, req) {
const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(':')[0];
const base = resolvePlazaPublicBase({
configuredBase: process.env.PLAZA_PUBLIC_BASE ?? '',
dev: process.env.NODE_ENV !== 'production',
hostname: host,
});
return resolvePlazaPostPath(base, postId);
}
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,
page_already_online: 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 isRequestBodyTooLarge(error) {
return (
error?.type === 'entity.too.large' ||
error?.status === 413 ||
error?.statusCode === 413
);
}
function apiRequestBodyError(error, req, res, next) {
if (!isRequestBodyTooLarge(error)) return next(error);
const isMindSpaceUpload = req.path.startsWith('/mindspace/v1/uploads/');
return sendError(
res,
req,
413,
isMindSpaceUpload ? 'file_too_large' : 'request_body_too_large',
isMindSpaceUpload
? '上传文件超过单文件大小限制,请压缩后重试'
: '请求内容过大,请缩小后重试',
);
}
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();
}
}, mindSpaceServerRuntime.agentWorker.ssePollMs);
// 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.get('/mindspace/v1/conversation-packages/:sessionId', createGetConversationPackageHandler({
getRegistry: () => mindSpaceConversationPackageRegistry,
getUserAuth: () => userAuth,
getSessionAccess: () => sessionAccess,
ensureMindSpaceEnabled,
sendData,
mindSpaceError,
beforeReadManifest: ({ req, sessionId }) =>
mindSpaceServiceFacade?.prepareConversationPackageRead({
user: req.currentUser,
sessionId,
}),
}));
api.get(
'/mindspace/v1/conversation-packages/:sessionId/manifest.json',
createDownloadConversationPackageManifestHandler({
getRegistry: () => mindSpaceConversationPackageRegistry,
getUserAuth: () => userAuth,
getSessionAccess: () => sessionAccess,
ensureMindSpaceEnabled,
mindSpaceError,
beforeReadManifest: ({ req, sessionId }) =>
mindSpaceServiceFacade?.prepareConversationPackageRead({
user: req.currentUser,
sessionId,
}),
}),
);
api.get(
'/mindspace/v1/conversation-packages/:sessionId/artifacts/:artifactId/download',
createDownloadConversationPackageArtifactHandler({
getRegistry: () => mindSpaceConversationPackageRegistry,
getUserAuth: () => userAuth,
getSessionAccess: () => sessionAccess,
ensureMindSpaceEnabled,
mindSpaceError,
}),
);
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,
sourceSessionId: req.body?.session_id,
sourceMessageId: req.body?.message_id,
});
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.post('/mindspace/v1/conversation-packages/:sessionId/claim-uploads', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req, { upload: true })) return;
try {
const result = await mindSpaceAssets.claimUploadArtifactsForConversation(req.currentUser.id, {
sessionId: req.params.sessionId,
messageId: req.body?.message_id,
});
return sendData(res, req, result);
} 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 ?? req.userSession?.userId ?? 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 && verifyPublicAssetToken(assetId, req.query.public_token, INTERNAL_AGENT_SECRET)) {
allowedByPublication = true;
publicationAccessMode = 'signed-public-token';
}
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: currentUserId,
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(user, sessionId, messageId) {
const userId = user?.id;
if (!sessionId || !messageId) {
throw Object.assign(new Error('缺少来源会话或消息'), {
code: 'invalid_page_input',
});
}
if (!(await ownsAgentSession(userId, sessionId))) {
throw Object.assign(new Error('来源会话不存在'), { code: 'source_message_not_found' });
}
let session = null;
if (sessionSnapshotService?.isEnabled()) {
const snapshot = await sessionSnapshotService.get(sessionId).catch(() => null);
if (snapshot?.messages?.length) {
session = {
...snapshot.session,
conversation: sanitizeSessionConversationPublicHtmlLinks(snapshot.messages, user),
};
if (authPool) {
session = await repairSessionConversationFromDb(authPool, session, sessionId, userId);
}
}
}
if (!session) {
const target = await tkmindProxy.resolveTarget(sessionId);
let upstream;
try {
upstream = await tkmindProxy.apiFetchTo(
target,
`/sessions/${encodeURIComponent(sessionId)}`,
{ method: 'GET', signal: AbortSignal.timeout(15_000) },
);
} catch (error) {
throw Object.assign(
new Error(
error?.name === 'TimeoutError' || error?.name === 'AbortError'
? '读取来源会话超时,请稍后重试'
: '无法读取来源会话',
),
{ code: 'source_message_not_found' },
);
}
if (!upstream.ok) {
throw Object.assign(new Error('无法读取来源会话'), { code: 'source_message_not_found' });
}
session = await upstream.json();
if (Array.isArray(session.conversation)) {
session.conversation = sanitizeSessionConversationPublicHtmlLinks(session.conversation, user);
}
if (authPool) {
session = await repairSessionConversationFromDb(authPool, session, sessionId, userId);
}
}
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']);
// REGRESSION GUARD: mindspace-page-sync-thumbnail — remote 也经 pageSyncService RPC 同步 public HTML
async function syncUserGeneratedPages(userId) {
if (!userId) return;
if (workspacePageDeliver?.syncAndDeliver) {
await workspacePageDeliver.syncAndDeliver(userId);
return;
}
if (!mindSpacePageSync) return;
await mindSpacePageSync.syncUserGeneratedPages(userId);
}
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, 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 } = resolveMindSpaceRuntimeConfig(h5Root, process.env);
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 = resolveMindSpaceUserPublishDir(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,
};
}
async function registerPublishedLongImageArtifactForConversation({
result,
image,
canonicalUrl,
}) {
const ownerId = String(result?.ownerId ?? '').trim();
const pageSource = result?.pageSource ?? {};
const sessionId = String(pageSource.sourceSessionId ?? '').trim();
const messageId = String(pageSource.sourceMessageId ?? '').trim();
const publicationId = String(result?.publication?.id ?? '').trim();
if (
!mindSpaceConversationPackageRegistry ||
!ownerId ||
!sessionId ||
!publicationId ||
!Buffer.isBuffer(image)
) {
return;
}
try {
const hash = crypto
.createHash('sha256')
.update(`${ownerId}:${sessionId}:${publicationId}:long-image`)
.digest('hex')
.slice(0, 16);
const artifactId = `ca_long_image_${hash}`;
const filename = `${publicationId || 'published-page'}.long.png`;
const relativePath = `artifacts/${artifactId}/${filename}`;
const now = Date.now();
const { packageRecord, writeResult } =
await mindSpaceConversationPackageRegistry.putObjectForSession({
userId: ownerId,
sessionId,
title: pageSource.title ?? null,
relativePath,
body: image,
});
await mindSpaceConversationPackageRegistry.recordArtifact({
id: artifactId,
packageId: packageRecord.id,
artifactKind: 'long_image',
role: 'assistant',
pageId: pageSource.pageId ?? result?.publication?.pageId ?? null,
publicationId,
messageId: messageId || null,
displayName: filename,
mimeType: 'image/png',
sizeBytes: image.length,
storageKey: writeResult.key,
canonicalUrl,
sortOrder: now,
now,
});
await mindSpaceConversationPackageRegistry.writeManifestForSession({
userId: ownerId,
sessionId,
});
} catch (error) {
console.warn('[MindSpace] failed to record published long image artifact:', error?.message ?? error);
}
}
async function registerPublicHtmlArtifactsForConversation({
user,
publishDir,
sessionId,
title = null,
relativePaths = [],
artifactRefs = [],
}) {
return registerPublicHtmlArtifactsForConversationPackage({
conversationPackageRegistry: mindSpaceConversationPackageRegistry,
h5Root: __dirname,
env: process.env,
user,
publishDir,
sessionId,
title,
relativePaths,
artifactRefs,
});
}
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 = resolveMindSpaceUserPublishDir(__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 } = resolveMindSpaceRuntimeConfig(__dirname, process.env);
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 = resolveMindSpaceUserPublishDir(__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 } = resolveMindSpaceRuntimeConfig(__dirname, process.env);
const { html: localizedHtml } = await inlinePrivateAssetsInHtml(
authPool,
storageRoot,
req.currentUser.id,
bundle.resolvedHtml.content,
);
const publishDir = resolveMindSpaceUserPublishDir(__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 = buildMindSpacePublicUrlForUser({
h5Root: __dirname,
env: process.env,
user: publishKey,
relativePath: sharedRelativePath,
});
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/quick-plaza-from-chat', async (req, res) => {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用');
}
const startedAt = Date.now();
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
const result = await quickPlazaFromChat({
user: req.currentUser,
h5Root: __dirname,
bundle,
body: req.body ?? {},
mindSpacePages,
mindSpacePublications,
plazaPosts,
publishDir,
});
console.info('[quick-plaza] ok', {
ms: Date.now() - startedAt,
pageId: result.pageId,
publicationId: result.publicationId,
postId: result.post?.id,
});
return sendData(res, req, result, 201);
} catch (error) {
console.error('[quick-plaza] error:', { ms: Date.now() - startedAt, error });
if (error?.code && mapPlazaError(error) !== 500) {
return plazaRouteError(res, req, error);
}
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/quick-plaza-from-public-html/status', async (req, res) => {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用');
}
try {
const relativePath = String(req.query?.relative_path ?? req.query?.relativePath ?? '').trim();
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
const result = await getQuickPlazaFromPublicHtmlStatus({
user: req.currentUser,
relativePath,
mindSpacePages,
mindSpacePublications,
plazaPosts,
});
const postId = result.post?.id;
return sendData(res, req, {
...result,
plaza_url: postId ? resolvePlazaPostUrlForRequest(postId, req) : null,
});
} catch (error) {
if (error?.code && mapPlazaError(error) !== 500) {
return plazaRouteError(res, req, error);
}
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/quick-plaza-from-public-html', async (req, res) => {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用');
}
const startedAt = Date.now();
try {
const relativePath = String(req.body?.relative_path ?? req.body?.relativePath ?? '').trim();
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
const result = await quickPlazaFromPublicHtml({
user: req.currentUser,
relativePath,
mindSpacePages,
mindSpacePublications,
plazaPosts,
publishDir,
});
const plazaUrl = resolvePlazaPostUrlForRequest(result.post.id, req);
console.info('[quick-plaza-public-html] ok', {
ms: Date.now() - startedAt,
pageId: result.pageId,
publicationId: result.publicationId,
postId: result.post?.id,
relativePath,
});
return sendData(res, req, { ...result, plaza_url: plazaUrl }, 201);
} catch (error) {
console.error('[quick-plaza-public-html] error:', { ms: Date.now() - startedAt, error });
if (error?.code === 'ALREADY_PUBLISHED') {
const postId = error?.details?.post_id ?? error?.details?.postId;
if (postId) {
error.details = {
...(error.details ?? {}),
plaza_url: resolvePlazaPostUrlForRequest(postId, req),
};
}
}
if (error?.code && mapPlazaError(error) !== 500) {
return plazaRouteError(res, req, error);
}
return mindSpaceError(res, req, error);
}
});
async function handleChatSaveDocx(req, res) {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const bundle = await resolveChatSaveBundle(req.currentUser, __dirname, req.body);
const title =
bundle.previewTitle ||
bundle.resolvedHtml?.suggestedTitle ||
bundle.analysis?.suggestedTitle ||
'AI 创作文档';
const content = bundle.resolvedHtml?.content ?? bundle.source.content;
const contentFormat = bundle.resolvedHtml?.content ? 'html' : 'markdown';
const buffer = generateDocxBuffer({ title, content, contentFormat });
const filenameBase = String(title)
.replace(/[\\/:*?"<>|]+/g, '-')
.replace(/\s+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60) || 'mindspace-document';
const filename = `${filenameBase}.docx`;
await registerChatDocxArtifactForConversation({
registry: mindSpaceConversationPackageRegistry,
user: req.currentUser,
bundle,
requestBody: req.body,
filename,
buffer,
}).catch((error) => {
console.warn('[MindSpace] failed to record chat docx artifact:', error?.message ?? error);
});
res.set('Content-Type', DOCX_MIME_TYPE);
res.set(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(filename)}"; filename*=UTF-8''${encodeURIComponent(filename)}`,
);
res.set('Cache-Control', 'no-store');
res.setHeader('X-Request-Id', req.requestId);
return res.send(buffer);
} catch (error) {
return mindSpaceError(res, req, error);
}
}
api.post('/mindspace/v1/pages/chat-save-docx', handleChatSaveDocx);
api.post('/v1/pages/chat-save-docx', handleChatSaveDocx);
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,
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 = resolveMindSpaceUserPublishDir(__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 ownsAgentSession(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.post('/agent/mindspace_asset_download', async (req, res) => {
if (!mindSpaceAssetAgent) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const result = await mindSpaceAssetAgent.applyAgentDownload(req.body ?? {});
await mindSpaceAudit?.write({
userId: result.userId ?? null,
action: 'asset.download',
objectType: 'asset',
objectId: result.assetId,
ip: req.ip,
metadata: { via: 'agent', relativePath: result.relativePath ?? null },
});
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 === 'asset_not_found') {
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,
},
);
const workspaceRoot = req.currentUser.workspaceRoot ?? null;
if (workspaceRoot) {
const syncedPolicy = syncPageDataPolicyAccessMode(
workspaceRoot,
req.params.pageId,
publication.accessMode,
req.currentUser.id,
);
if (syncedPolicy) {
await upsertPageDataPolicyIndex(authPool, syncedPolicy).catch(() => null);
}
}
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 ownsAgentSession(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, sessionAccess, agentRunGateway, mindSpaceAssetAgent }),
],
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 ownsAgentSession(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()) {
let snapshot = await sessionSnapshotService.get(sessionId);
if (snapshot) {
if (authPool && await shouldExpirePortalDirectChatSnapshot(authPool, sessionId, snapshot)) {
await sessionSnapshotService.remove(sessionId).catch(() => {});
snapshot = null;
}
}
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 (
isDirectChatSessionId(sessionId) ||
isPortalDirectChatSnapshot(snapshot, { sessionId }) ||
(canUseSnapshotCache && mcMatch && uaMatch)
) {
const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks(
snapshot.messages,
req.currentUser,
);
// Cache hit — reconstruct a Goose-compatible session response.
let cachedGooseSession = {
...snapshot.session,
// Embed only userVisible messages so getSession callers still work.
conversation: sanitizedMessages,
};
if (authPool) {
cachedGooseSession = await repairSessionConversationFromDb(
authPool,
cachedGooseSession,
sessionId,
req.currentUser.id,
);
}
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);
}
let gooseSession = await upstream.json();
if (Array.isArray(gooseSession.conversation)) {
gooseSession.conversation = sanitizeSessionConversationPublicHtmlLinks(
gooseSession.conversation,
req.currentUser,
);
}
if (authPool) {
gooseSession = await repairSessionConversationFromDb(
authPool,
gooseSession,
sessionId,
req.currentUser.id,
);
}
// Write-through: persist snapshot async, don't block the response.
if (sessionSnapshotService?.isEnabled()) {
const messages = filterNonemptyUserVisibleMessages(gooseSession.conversation ?? []);
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 ownsAgentSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
if (isDirectChatSessionId(sessionId)) {
await unregisterAgentSessionForUser(req.currentUser.id, sessionId);
void sessionSnapshotService?.remove(sessionId).catch(() => {});
return res.status(204).end();
}
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 unregisterAgentSessionForUser(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 ownsAgentSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
if (isDirectChatSessionId(sessionId)) {
const snapshot = await sessionSnapshotService?.get(sessionId).catch(() => null);
if (!snapshot) {
return res.status(404).json({ message: '会话不存在' });
}
return sendDirectChatSessionEvents(req, res, snapshot);
}
const portalDirectSnapshot = await sessionSnapshotService?.get(sessionId).catch(() => null);
if (
portalDirectSnapshot &&
authPool &&
await shouldExpirePortalDirectChatSnapshot(authPool, sessionId, portalDirectSnapshot)
) {
await sessionSnapshotService?.remove(sessionId).catch(() => {});
} else if (portalDirectSnapshot && isPortalDirectChatSnapshot(portalDirectSnapshot, { sessionId })) {
return sendDirectChatSessionEvents(req, res, portalDirectSnapshot);
}
const publishDir = resolveMindSpaceUserPublishDir(__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,
sessionId: sid,
pool: authPool,
storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot,
h5Root: __dirname,
syncWorkspaceAssets:
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
: null,
registerPublicHtmlArtifacts: (_userId, options) =>
registerPublicHtmlArtifactsForConversation({
user: req.currentUser,
publishDir,
sessionId: options?.sessionId,
relativePaths: options?.relativePaths,
artifactRefs: options?.artifactRefs,
}),
});
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 maybeRepairH5HtmlAfterFinish({
sessionId: sid,
userId: uid,
currentUser: req.currentUser,
messages,
publishDir,
syncResult,
tkmindProxy,
});
await syncUserGeneratedPages(uid);
};
return tkmindProxy.proxySessionEvents(req, res, sessionId, {
onAfterFinish,
onEvent: syncPublicHtmlDuringStream,
});
});
api.use(apiRequestBodyError);
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 ownsAgentSession(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 ownsAgentSession(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);
// Express routing is case-insensitive by default, so the lowercase /mindspace API
// mount would otherwise capture public /MindSpace/... page URLs.
app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {
await userAuthReady;
return serveUserPublishFile(req, res, next);
});
app.use('/mindspace', api);
function extractOgImageUrl(html) {
return (
String(html ?? '').match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] ||
String(html ?? '').match(/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)?.[1] ||
''
);
}
function extractShareMetaFromPageHtml(html) {
const signals = extractCoverSignals(String(html ?? ''));
return {
title: detectPublishedPageTitle(html),
subtitle: signals.subtitle,
};
}
function appendQueryParam(url, key, value) {
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
function removeQueryParam(url, key) {
if (!url || !url.includes('?')) return url;
const [base, queryAndHash] = url.split('?', 2);
const [query, hash = ''] = queryAndHash.split('#', 2);
const params = new URLSearchParams(query);
params.delete(key);
const nextQuery = params.toString();
return `${base}${nextQuery ? `?${nextQuery}` : ''}${hash ? `#${hash}` : ''}`;
}
function resolveRequestOrigin(req) {
return resolvePublicRequestOrigin({
hostHeader: req.headers['x-forwarded-host'] || req.headers.host || '',
forwardedProto: req.headers['x-forwarded-proto'] || '',
protocol: req.protocol,
});
}
function detectPublishedPageTitle(html) {
const match = String(html ?? '').match(/<title[^>]*>([^<]+)<\/title>/i);
return match?.[1]?.replace(/\s+/g, ' ').trim() || 'MindSpace 页面';
}
function publishedPageShellHtml({ iframeUrl, shareUrl, title, longImageUrl }) {
const iframeSrc = escapePublicHtml(iframeUrl);
const safeShareUrl = escapePublicHtml(shareUrl);
const safeTitle = escapePublicHtml(title);
const serializedShareUrl = JSON.stringify(shareUrl).replace(/</g, '\\u003c');
const serializedTitle = JSON.stringify(title).replace(/</g, '\\u003c');
const serializedLongImageUrl = JSON.stringify(longImageUrl).replace(/</g, '\\u003c');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; script-src 'unsafe-inline'; frame-src 'self'; base-uri 'none'; form-action 'none'">
<title>${safeTitle}</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; background: #f6f1e7; }
body { font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif; color: #18211d; }
.publication-shell { position: relative; min-height: 100vh; }
.publication-frame { display: block; width: 100%; min-height: 100vh; border: 0; background: #fff; }
.publication-share-fab {
position: fixed;
right: 18px;
bottom: calc(18px + env(safe-area-inset-bottom, 0px));
z-index: 50;
border: 0;
border-radius: 999px;
padding: 14px 18px;
background: linear-gradient(135deg, #2f6f57, #1f4c3c);
color: #fffdf7;
font: 700 14px/1 ui-sans-serif, sans-serif;
letter-spacing: .04em;
box-shadow: 0 20px 48px rgba(24, 33, 29, .24);
cursor: pointer;
}
.publication-share-sheet[hidden] { display: none; }
.publication-share-sheet {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: end center;
padding: 16px;
background: rgba(7, 12, 10, 0.56);
}
.publication-share-panel {
width: min(480px, 100%);
padding: 18px 18px 22px;
border-radius: 24px 24px 20px 20px;
background: #fffaf2;
box-shadow: 0 24px 80px rgba(24, 33, 29, 0.24);
}
.publication-share-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.publication-share-header p {
margin: 0;
color: #9b6518;
font-size: 11px;
font-weight: 800;
letter-spacing: .16em;
text-transform: uppercase;
}
.publication-share-header h2 {
margin: 6px 0 0;
font: 500 28px/1.1 Georgia, 'Times New Roman', serif;
color: #18211d;
}
.publication-share-header strong {
display: block;
margin-top: 6px;
color: #68716c;
font-size: 13px;
font-weight: 500;
}
.publication-share-close {
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: rgba(24, 33, 29, 0.08);
color: #18211d;
font-size: 24px;
line-height: 1;
cursor: pointer;
}
.publication-share-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.publication-share-option {
display: grid;
gap: 8px;
justify-items: center;
padding: 14px 10px;
border: 1px solid rgba(24, 33, 29, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
color: #18211d;
font: inherit;
cursor: pointer;
}
.publication-share-option:disabled {
opacity: .6;
cursor: wait;
}
.publication-share-option span {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border-radius: 14px;
color: #fff;
font-size: 18px;
font-weight: 700;
}
.publication-share-option[data-action="wechat"] span { background: linear-gradient(145deg, #07c160, #06ad56); }
.publication-share-option[data-action="copy"] span { background: linear-gradient(145deg, #2f6f57, #245845); }
.publication-share-option[data-action="capture"] span { background: linear-gradient(145deg, #8f6b2f, #6f5121); }
.publication-share-link, .publication-share-message {
margin-top: 16px;
padding: 12px 14px;
border-radius: 14px;
font-size: 13px;
}
.publication-share-link {
background: rgba(24, 33, 29, 0.05);
}
.publication-share-link code {
display: block;
margin-top: 6px;
word-break: break-all;
color: #445049;
font-size: 12px;
}
.publication-share-message {
display: none;
color: #245845;
background: rgba(47, 111, 87, 0.12);
}
.publication-share-message.is-visible {
display: block;
}
@media (max-width: 480px) {
.publication-share-sheet {
padding: 0;
place-items: end stretch;
}
.publication-share-panel {
width: 100%;
border-radius: 24px 24px 0 0;
}
.publication-share-fab {
right: 14px;
bottom: calc(14px + env(safe-area-inset-bottom, 0px));
}
}
</style>
</head>
<body>
<div class="publication-shell">
<!-- Game-like publications need script execution, but not same-origin sandbox escape. -->
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}" sandbox="allow-scripts allow-downloads"></iframe>
</div>
<button type="button" class="publication-share-fab" id="publication-share-fab">分享</button>
<div class="publication-share-sheet" id="publication-share-sheet" hidden>
<section class="publication-share-panel" role="dialog" aria-modal="true" aria-labelledby="publication-share-title">
<header class="publication-share-header">
<div>
<p>Share</p>
<h2 id="publication-share-title">分享页面</h2>
<strong>${safeTitle}</strong>
</div>
<button type="button" class="publication-share-close" id="publication-share-close" aria-label="关闭">×</button>
</header>
<div class="publication-share-grid">
<button type="button" class="publication-share-option" data-action="wechat"><span>微</span>微信</button>
<button type="button" class="publication-share-option" data-action="copy"><span>链</span>复制链接</button>
<button type="button" class="publication-share-option" data-action="capture"><span>图</span>保存长图</button>
</div>
<div class="publication-share-link">
分享链接
<code>${safeShareUrl}</code>
</div>
<div class="publication-share-message" id="publication-share-message" role="status"></div>
</section>
</div>
<script>
(function () {
var shareUrl = ${serializedShareUrl};
var shareTitle = ${serializedTitle};
var longImageUrl = ${serializedLongImageUrl};
var fab = document.getElementById('publication-share-fab');
var sheet = document.getElementById('publication-share-sheet');
var close = document.getElementById('publication-share-close');
var message = document.getElementById('publication-share-message');
var frame = document.querySelector('.publication-frame');
var actionButtons = sheet ? Array.prototype.slice.call(sheet.querySelectorAll('.publication-share-option')) : [];
function setBusy(busy) {
actionButtons.forEach(function (button) {
button.disabled = !!busy;
button.setAttribute('aria-busy', busy ? 'true' : 'false');
});
}
function setMessage(text, isError) {
if (!message) return;
message.textContent = text || '';
message.classList.toggle('is-visible', Boolean(text));
message.style.color = isError ? '#8b2d20' : '#245845';
message.style.background = isError ? 'rgba(139, 45, 32, 0.12)' : 'rgba(47, 111, 87, 0.12)';
}
function openSheet() {
if (sheet) sheet.hidden = false;
setMessage('');
}
function closeSheet() {
if (sheet) sheet.hidden = true;
}
async function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fall back to execCommand for browsers where clipboard API exists but is blocked.
}
}
var textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
var copied = document.execCommand('copy');
textarea.remove();
if (!copied) throw new Error('复制失败,请手动复制链接');
}
async function saveLongImage() {
var link = document.createElement('a');
link.href = longImageUrl;
link.download = '';
document.body.appendChild(link);
link.click();
link.remove();
}
if (fab) fab.addEventListener('click', openSheet);
if (close) close.addEventListener('click', closeSheet);
if (sheet) {
sheet.addEventListener('click', function (event) {
if (event.target === sheet) closeSheet();
});
actionButtons.forEach(function (button) {
button.addEventListener('click', async function () {
var action = button.getAttribute('data-action');
try {
setBusy(true);
setMessage('');
if (action === 'wechat') {
await copyText([shareTitle, shareUrl].filter(Boolean).join('\\n'));
setMessage('文案已复制,请打开微信粘贴分享');
return;
}
if (action === 'copy') {
await copyText(shareUrl);
setMessage('链接已复制');
return;
}
if (action === 'capture') {
setMessage('正在生成长图,请稍候');
await saveLongImage();
setMessage('长图已保存');
}
} catch (error) {
setMessage(error && error.message ? error.message : '操作失败,请稍后再试', true);
} finally {
setBusy(false);
}
});
});
}
function syncFrameHeight() {
if (!frame) return;
var doc = frame.contentDocument;
var body = doc && doc.body;
var root = doc && doc.documentElement;
var height = Math.max(
body ? body.scrollHeight : 0,
body ? body.offsetHeight : 0,
root ? root.scrollHeight : 0,
root ? root.offsetHeight : 0,
window.innerHeight,
320
);
frame.style.height = height + 'px';
}
if (frame) {
frame.addEventListener('load', function () {
syncFrameHeight();
window.setTimeout(syncFrameHeight, 300);
});
}
window.addEventListener('message', function (event) {
if (!event || !event.data || event.data.type !== 'plaza:embed-section' || !frame) return;
if (event.data.height) {
frame.style.height = Math.max(Number(event.data.height) || 0, window.innerHeight, 320) + 'px';
}
});
window.addEventListener('resize', syncFrameHeight);
})();
</script>
</body>
</html>`;
}
async function sendPublishedPage(req, res, result, { embed = false, raw = false, ownerSlug = null } = {}) {
let html = rewriteBrokenMindSpacePublicImageUrls(result.html);
if (ownerSlug) {
html = rewritePublicationCanonicalAssetUrls(html, ownerSlug);
}
const origin = resolveRequestOrigin(req);
const originalPath = req.originalUrl || req.url || '';
const sharePath = removeQueryParam(removeQueryParam(originalPath, 'download'), 'export');
const pageUrl = originalPath ? new URL(sharePath, origin || 'http://localhost').toString().split('#')[0] : '';
const pageDirUrl = pageUrl ? `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}` : '';
const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || '');
if (embed) {
html = preparePublicationHtmlForEmbed(html);
allowPlazaEmbedFrame(res);
} else if (raw) {
html = stripPublicationHtmlCspMeta(html);
}
html = injectPublishedPageDataContext(html, {
pageSource: result.pageSource,
publication: result.publication,
});
if (!embed) {
try {
html = injectOgTags(html, { origin, pageUrl, pageDirUrl });
if (wechatShare) html = injectWechatShareBridge(html, { pageUrl });
} catch {
// Never block public page delivery on share metadata injection.
}
}
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
if (!embed && !raw && isFullHtml && isLongImageDownloadRequest(req.query)) {
try {
const rawUrl = new URL(appendQueryParam(sharePath || originalPath, 'view', 'raw'), origin || 'http://localhost');
const image = await renderLongImageBuffer({ url: rawUrl.toString() });
const longImageUrl = new URL(
appendQueryParam(sharePath || originalPath, 'download', 'long-image'),
origin || 'http://localhost',
).toString();
await registerPublishedLongImageArtifactForConversation({
result,
image,
canonicalUrl: longImageUrl,
});
res.set('Content-Type', 'image/png');
res.set('Content-Disposition', 'attachment; filename="mindspace-public-page.long.png"');
res.set('Cache-Control', 'no-store');
return res.send(image);
} catch (error) {
return res
.status(500)
.type('text/plain; charset=utf-8')
.send(`长图生成失败:${error?.message || '未知错误'}`);
}
}
const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password';
if (canWrapWithShell) {
const title = detectPublishedPageTitle(html);
const rawUrl = appendQueryParam(sharePath || originalPath, 'view', 'raw');
const longImageUrl = appendQueryParam(sharePath || originalPath, 'download', 'long-image');
let shellHtml = publishedPageShellHtml({
iframeUrl: rawUrl,
shareUrl: pageUrl,
title,
longImageUrl,
});
try {
shellHtml = injectOgTags(shellHtml, {
origin,
pageUrl,
pageDirUrl,
fallbackImageUrl: extractOgImageUrl(html),
meta: extractShareMetaFromPageHtml(html),
});
if (wechatShare) shellHtml = injectWechatShareBridge(shellHtml, { pageUrl });
} catch {
// Keep the share shell usable even if metadata injection fails.
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.set(
'Content-Security-Policy',
wechatShare
? "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net https://res.wx.qq.com; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
: "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
);
res.set(
'Cache-Control',
result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store',
);
return res.send(shellHtml);
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.set(
'Content-Security-Policy',
publishedPageCsp(html, {
embed,
raw,
wechatShare,
scriptHashes: collectInlineScriptHashes(html),
}),
);
res.set(
'Cache-Control',
result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store',
);
return res.send(html);
}
function passwordGateHtml(action) {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'">
<title>受保护页面</title>
<style>
* { box-sizing: border-box; }
body { min-height: 100vh; margin: 0; display: grid; place-items: center; padding: 24px; color: #17221d; background: radial-gradient(circle at top right, #d9e9df, transparent 42rem), #f5f0e5; font-family: ui-sans-serif, sans-serif; }
form { width: min(420px, 100%); padding: 36px; border: 1px solid #d6d0c3; border-radius: 28px; background: #fffdf7; box-shadow: 0 24px 70px rgba(35, 48, 40, .12); }
p { color: #68716c; line-height: 1.7; }
input, button { width: 100%; margin-top: 14px; padding: 14px 16px; border-radius: 12px; font: inherit; }
input { border: 1px solid #c9c6bc; }
button { border: 0; color: white; background: #2f6f57; cursor: pointer; font-weight: 800; }
</style>
</head>
<body>
<form method="post" action="${action}">
<h1>此页面受密码保护</h1>
<p>请输入发布者提供的访问密码。密码不会写入链接或浏览器日志。</p>
<input type="password" name="password" minlength="8" maxlength="128" required autocomplete="current-password">
<button type="submit">访问页面</button>
</form>
</body>
</html>`;
}
function escapePublicHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function publicHomepageHtml(data) {
const cards = data.pages
.map(
(page) => `\
<a class="card" href="${escapePublicHtml(page.publicUrl)}">
<div class="card-top">
<span>${escapePublicHtml(page.templateId)}</span>
<strong>${Number(page.viewCount)} 次浏览</strong>
</div>
<h2>${escapePublicHtml(page.title)}</h2>
<p>${escapePublicHtml(page.summary || '暂无摘要')}</p>
<div class="card-foot">
<span>${new Date(page.publishedAt).toLocaleDateString('zh-CN')}</span>
<span>打开页面</span>
</div>
</a>`,
)
.join('');
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'">
<title>${escapePublicHtml(data.owner.displayName)} · MindSpace</title>
<style>
:root { color: #17221d; background: #f5f0e5; font-family: 'Iowan Old Style', 'Palatino Linotype', serif; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background:
radial-gradient(circle at top right, rgba(79, 137, 112, .18), transparent 32rem),
linear-gradient(180deg, #f9f6ef 0%, #f1eadc 100%);
color: #17221d;
}
main { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 48px 0 72px; }
.hero { padding: 28px 0 32px; border-bottom: 1px solid rgba(23, 34, 29, .12); }
.eyebrow { margin: 0 0 12px; color: #8f6b2f; font: 700 12px/1.4 ui-sans-serif, sans-serif; letter-spacing: .18em; text-transform: uppercase; }
h1 { margin: 0; font-size: clamp(40px, 8vw, 82px); line-height: .96; letter-spacing: -.05em; }
.lead { max-width: 760px; margin: 18px 0 0; color: #56615b; font: 18px/1.8 ui-sans-serif, sans-serif; }
.stats { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 22px; }
.stats span { border: 1px solid rgba(23, 34, 29, .12); border-radius: 999px; padding: 10px 14px; background: rgba(255,255,255,.6); font: 600 13px/1 ui-sans-serif, sans-serif; color: #405048; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; margin-top: 34px; }
.card { display: flex; flex-direction: column; gap: 16px; min-height: 230px; padding: 22px; border-radius: 24px; text-decoration: none; color: inherit; background: rgba(255,253,247,.92); border: 1px solid rgba(23, 34, 29, .08); box-shadow: 0 20px 60px rgba(45, 53, 47, .08); transition: transform .18s ease, box-shadow .18s ease; }
.card:hover { transform: translateY(-2px); box-shadow: 0 24px 70px rgba(45, 53, 47, .12); }
.card-top, .card-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; font: 600 12px/1.4 ui-sans-serif, sans-serif; color: #6a746f; text-transform: uppercase; letter-spacing: .08em; }
.card h2 { margin: 0; font-size: 28px; line-height: 1.08; letter-spacing: -.03em; }
.card p { margin: 0; color: #56615b; font: 15px/1.75 ui-sans-serif, sans-serif; }
.empty { margin-top: 32px; padding: 28px; border-radius: 24px; background: rgba(255,253,247,.76); border: 1px dashed rgba(23, 34, 29, .18); color: #56615b; font: 16px/1.7 ui-sans-serif, sans-serif; }
@media (max-width: 640px) {
main { width: min(100% - 24px, 1120px); padding: 24px 0 48px; }
.hero { padding-top: 12px; }
.grid { gap: 14px; }
.card { min-height: 0; border-radius: 20px; }
.card h2 { font-size: 24px; }
}
</style>
</head>
<body>
<main>
<section class="hero">
<p class="eyebrow">MindSpace Public</p>
<h1>${escapePublicHtml(data.owner.displayName)}</h1>
<p class="lead">这里展示 ${escapePublicHtml(data.owner.displayName)} 当前公开发布且在线的 MindSpace 页面。</p>
<div class="stats">
<span>${Number(data.pageCount)} 个公开页面</span>
<span>${Number(data.totalViews)} 次累计浏览</span>
<span>/u/${escapePublicHtml(data.owner.slug)}</span>
</div>
</section>
${
data.pages.length
? `<section class="grid">${cards}</section>`
: '<section class="empty">这个主页还没有公开页面。稍后再来看看,或者直接访问作者分享给你的专属链接。</section>'
}
</main>
</body>
</html>`;
}
async function resolvePublishedRoute(req, res, password = null) {
await userAuthReady;
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
try {
const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null;
const result = await mindSpacePublications.resolvePublic(
req.params.ownerSlug,
req.params.urlSlug,
viewer?.id,
password,
{
userAgent: req.get('user-agent'),
referrer: req.get('referer'),
},
);
const embed = isPlazaEmbedRequest(req.query);
const raw = String(req.query.view ?? '').toLowerCase() === 'raw';
if (
!password &&
!embed &&
!raw &&
result.publication?.accessMode === 'public' &&
isPublicWorkspaceHtmlRelativePath(result.workspaceRelativePath)
) {
const redirectPath = buildMindSpacePublicRoutePath(
result.ownerId,
String(result.workspaceRelativePath).split('/'),
);
return res.redirect(301, redirectPath);
}
return await sendPublishedPage(req, res, result, {
embed,
raw,
ownerSlug: req.params.ownerSlug,
});
} catch (error) {
if (error?.code === 'publication_password_required') {
return res.status(password ? 403 : 200).send(passwordGateHtml(req.originalUrl));
}
if (error?.code === 'publication_login_required') {
return res.status(401).send('请先登录 TKMind 后再访问此页面');
}
if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线');
return res.status(500).send('页面加载失败');
}
}
app.use(async (req, res, next) => {
const thumbnailMatch = /^\/u\/([^/]+)\/pages\/([^/]+)\.thumbnail\.png$/.exec(req.path);
if (!thumbnailMatch) return next();
const ownerSlug = thumbnailMatch[1];
const urlSlug = thumbnailMatch[2];
await userAuthReady;
if (!authPool || !mindSpacePages) return res.status(503).send('MindSpace 未启用');
try {
const [rows] = await authPool.query(
`SELECT pr.user_id, pr.page_id
FROM h5_publish_records pr
JOIN h5_users u ON u.id = pr.user_id
WHERE COALESCE(u.slug, u.username) = ?
AND pr.url_slug = ?
AND pr.status = 'online'
ORDER BY pr.published_at DESC
LIMIT 1`,
[ownerSlug, urlSlug],
);
const row = rows[0];
if (!row) return res.status(404).send('缩略图不存在');
const svg = await mindSpacePages.renderThumbnail(row.user_id, row.page_id);
res.set('Content-Type', 'image/png');
res.set('Cache-Control', 'public, max-age=300');
return res.send(rasterizeThumbnailSvgToPng(svg));
} catch (error) {
return res.status(500).send('缩略图加载失败');
}
});
app.get('/u/:ownerSlug/pages/:urlSlug', async (req, res) => {
return resolvePublishedRoute(req, res);
});
app.use('/u/:ownerSlug/public', async (req, res) => {
await userAuthReady;
const ownerSlug = String(req.params.ownerSlug ?? '').trim().toLowerCase();
if (!ownerSlug) return res.status(404).json({ message: '用户不存在' });
if (!authPool) return res.status(503).send('MindSpace 未启用');
try {
const [rows] = await authPool.query(
`SELECT id FROM h5_users WHERE LOWER(COALESCE(slug, username)) = ? LIMIT 1`,
[ownerSlug],
);
const userId = rows[0]?.id ? String(rows[0].id) : null;
if (!userId) return res.status(404).json({ message: '用户不存在' });
const relativePath = String(req.path ?? '')
.replace(/^\/+/, '')
.split('/')
.map((segment) => decodeURIComponent(segment))
.filter(Boolean);
if (relativePath.length === 0) return res.status(404).json({ message: '文件不存在' });
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId });
const resolvedRoot = path.resolve(publishDir);
const filePath = path.resolve(publishDir, PUBLIC_ZONE_DIR, ...relativePath);
if (filePath !== resolvedRoot && !filePath.startsWith(`${resolvedRoot}${path.sep}`)) {
return res.status(403).json({ message: '禁止访问' });
}
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
return res.status(404).json({ message: '文件不存在' });
}
return res.sendFile(filePath, (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' });
});
} catch {
return res.status(500).send('文件加载失败');
}
});
app.get('/u/:ownerSlug', async (req, res) => {
await userAuthReady;
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
try {
const data = await mindSpacePublications.getPublicHomepage(req.params.ownerSlug);
res.set('Content-Type', 'text/html; charset=utf-8');
res.set(
'Content-Security-Policy',
"default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
);
res.set('Cache-Control', 'public, max-age=60');
return res.send(publicHomepageHtml(data));
} catch (error) {
if (error?.code === 'publication_not_found') return res.status(404).send('主页不存在');
return res.status(500).send('主页加载失败');
}
});
app.post(
'/u/:ownerSlug/pages/:urlSlug',
express.urlencoded({ extended: false, limit: '2kb' }),
async (req, res) => resolvePublishedRoute(req, res, req.body?.password),
);
app.get('/s/:token', async (req, res) => {
await userAuthReady;
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
try {
const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null;
return await sendPublishedPage(
req,
res,
await mindSpacePublications.resolvePrivateLink(req.params.token, viewer?.id, {
userAgent: req.get('user-agent'),
referrer: req.get('referer'),
}),
{
embed: isPlazaEmbedRequest(req.query),
raw: String(req.query.view ?? '').toLowerCase() === 'raw',
},
);
} catch (error) {
if (error?.code === 'publication_not_found') return res.status(404).send('页面不存在或已下线');
return res.status(500).send('页面加载失败');
}
});
/**
* Send a file, injecting Open Graph tags for .html so forwarded links unfurl with a cover.
* Non-HTML files (assets, etc.) are streamed unchanged via res.sendFile.
*/
async function sendLongImageDownloadIfRequested(req, res, filePath) {
const origin = resolveRequestOrigin(req);
const currentUrl = origin ? `${origin}${req.originalUrl || req.url || ''}` : null;
return handleMindSpaceLongImageDownload({
query: req.query,
filePath,
pageUrl: currentUrl ? removeQueryParam(removeQueryParam(currentUrl, 'download'), 'export') : null,
res,
isLongImageDownloadRequest,
longImagePathForHtml,
renderLongImage,
});
}
async function ensurePublicHtmlPrivateAssetsMaterialized(filePath, html) {
if (!authPool || !htmlReferencesPrivateAssets(html)) return html;
const normalized = path.resolve(String(filePath ?? ''));
const ownerMatch = normalized.match(
new RegExp(`[\\\\/]${PUBLISH_ROOT_DIR}[\\\\/]([0-9a-f-]{36})[\\\\/]`, 'i'),
);
const userId = ownerMatch?.[1] ?? null;
if (!userId) return html;
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId });
const htmlRelativePath = path.relative(publishDir, normalized).replace(/\\/g, '/');
if (!htmlRelativePath.startsWith(`${PUBLIC_ZONE_DIR}/`)) return html;
const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env);
const result = await materializePrivateAssetsInWorkspaceHtml({
pool: authPool,
storageRoot,
h5Root: __dirname,
userId,
html,
htmlRelativePath,
writeBack: true,
}).catch(() => ({ html, changed: false }));
return result.changed ? result.html : html;
}
async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) {
if (!filePath.toLowerCase().endsWith('.html')) {
res.sendFile(filePath, (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' });
});
return;
}
if (await sendLongImageDownloadIfRequested(req, res, filePath)) return;
let html;
try {
html = fs.readFileSync(filePath, 'utf8');
} catch {
res.status(404).json({ message: '文件不存在' });
return;
}
html = await ensurePublicHtmlPrivateAssetsMaterialized(filePath, html);
html = rewriteKnownCdnScriptSources(html).html;
html = preparePublicHtmlAssetDelivery(html, INTERNAL_AGENT_SECRET);
const embed = isPlazaEmbedRequest(req.query);
if (embed) {
html = preparePublicationHtmlForEmbed(html);
allowPlazaEmbedFrame(res);
res.set('Content-Security-Policy', publishedPageCsp(html, { embed }));
}
const context = buildPublishedHtmlViewContext({
origin: resolveRequestOrigin(req),
requestPath: req.originalUrl || req.url || '',
filePath,
thumbnailPngPathForSvg,
});
let pageDataContext = null;
if (mindSpacePages) {
const parsed = parseMindSpacePublishFilePath(filePath, __dirname);
if (parsed?.userId && parsed.relativePath) {
const page = await mindSpacePages
.findPageByRelativePath(parsed.userId, parsed.relativePath)
.catch(() => null);
if (page?.id) {
pageDataContext = {
pageId: page.id,
accessMode: page.publicationAccessMode ?? null,
};
}
}
}
const decorated = decorateMindSpacePublishedHtml({
html,
embed,
isOwner,
pageDataContext,
context,
htmlFilePath: filePath,
userAgent: req.get('user-agent') || '',
preparePublicationHtmlForEmbed,
injectOgTags,
injectWechatShareBridge,
injectPublicFileShareButton,
publishedPageCsp,
isWechatUserAgent,
});
html = decorated.html;
if (decorated.allowEmbedFrame) {
allowPlazaEmbedFrame(res);
}
res.set('Content-Security-Policy', decorated.csp);
res.set('Content-Type', 'text/html; charset=utf-8');
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — this HTML now varies by viewer
// (owner sees the Plaza entry, visitors don't), so it must never be cached/shared across
// sessions. There is no CDN/proxy_cache in front of this route today; keep it that way.
res.set('Cache-Control', 'private, no-store');
res.send(html);
}
async function serveUserPublishFile(req, res, next) {
const result = await resolveMindSpacePublicRequest({
h5Root: __dirname,
requestPath: req.path,
resolveUsernameToUserId: async (username) => {
if (!authPool) return null;
const [rows] = await authPool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [username]);
return rows[0]?.id ? String(rows[0].id) : null;
},
resolveClosestHtmlRelativePath,
ensureThumbnail: async (publishDir, relativePath) => {
await ensureWorkspaceHtmlThumbnail(publishDir, relativePath).catch(() => {});
},
logger: console,
});
if (result.action === 'redirect') {
res.redirect(result.status ?? 301, result.location);
return;
}
if (result.action === 'forbidden') {
res.status(403).json({ message: '禁止访问' });
return;
}
if (result.action === 'not_found') {
if (result.reason === 'missing_owner_dir') {
res.status(404).json({ message: '用户不存在' });
return;
}
if (result.reason === 'missing_directory_index') {
res.status(404).json({ message: '目录中没有 index.html' });
return;
}
res.status(404).json({ message: '文件不存在' });
return;
}
// On-demand cover: rasterize <base>.thumbnail.svg → .thumbnail.png the first time a
// forwarded link's og:image is fetched (and refresh it when the SVG changes).
const resolvedPath = result.filePath;
if (/\.thumbnail\.png$/i.test(resolvedPath)) {
const svgSibling = resolvedPath.replace(/\.png$/i, '.svg');
if (fs.existsSync(svgSibling)) {
const pngPath = ensureThumbnailPng(svgSibling);
if (pngPath && fs.existsSync(pngPath)) {
res.set('Cache-Control', 'public, max-age=300');
res.sendFile(pngPath);
return;
}
}
}
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — this workspace URL is the one the
// agent hands back in chat and is reachable by anyone (no login required). Only the logged-in
// author (viewer.id === ownerKey) may see the "发布 Plaza" entry; everyone else is a visitor.
const viewer = req.userSession && userAuth
? await userAuth.getMe(req.userToken).catch(() => null)
: null;
const isOwner = Boolean(
viewer?.id && result.ownerKey && String(viewer.id).toLowerCase() === String(result.ownerKey).toLowerCase(),
);
await sendPublishFile(req, res, resolvedPath, { isOwner });
}
app.use('/temp', (req, res) => {
res.redirect(301, `/${PUBLISH_ROOT_DIR}${req.url}`);
});
// Serve user public pages: /user/<username>/path/to/file
// These are public-facing pages (like travel guides) stored in user directories
// Accessed via URL like https://goo.tkmind.cn/user/john/vietnam-guide/
app.use('/user', async (req, res, next) => {
await userAuthReady;
// Path format: /user/<username>/<rest...>
const parts = req.path.split('/').filter(Boolean);
if (parts.length < 1) return next();
const [username, ...rest] = parts;
const targetDir = path.join(USERS_ROOT, username);
// Security: ensure resolved path is within the user's directory
const filePath = path.join(targetDir, ...rest);
const resolvedRoot = path.resolve(targetDir);
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(resolvedRoot + path.sep) && resolvedPath !== resolvedRoot) {
return res.status(403).json({ message: '禁止访问' });
}
// Check if the user directory exists
if (!fs.existsSync(targetDir)) {
return res.status(404).json({ message: '用户不存在' });
}
if (!fs.existsSync(resolvedPath)) {
return res.status(404).json({ message: '文件不存在' });
}
// If it's a directory, serve index.html
if (fs.statSync(resolvedPath).isDirectory()) {
const indexPath = path.join(resolvedPath, 'index.html');
if (fs.existsSync(indexPath)) {
return res.sendFile(indexPath);
}
return res.status(404).json({ message: '目录中没有 index.html' });
}
res.sendFile(resolvedPath, (err) => {
if (err) res.status(404).json({ message: '文件不存在' });
});
});
app.get(`/${PUBLISH_ROOT_DIR}/wiki/*`, (_req, res) => {
res.sendFile(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki', 'index.html'));
});
app.get('/temp/wiki/*', (req, res) => {
res.redirect(301, `/${PUBLISH_ROOT_DIR}/wiki${req.url.slice('/temp/wiki'.length)}`);
});
app.use(
'/plaza-covers',
express.static(path.join(__dirname, 'public/plaza-covers'), { maxAge: '7d' }),
);
app.use('/brand', express.static(path.join(__dirname, 'public/brand'), { maxAge: '7d' }));
app.use('/assets', express.static(path.join(__dirname, 'public/assets'), { maxAge: '1d' }));
app.get('/dev/wechat-share-preview', async (req, res) => {
try {
const rawTarget = String(req.query.url ?? req.query.path ?? '').trim();
if (!rawTarget) {
return res.status(400).type('text/plain; charset=utf-8').send('缺少 url 参数,例如 ?url=/MindSpace/john/public/demo.html');
}
const origin = resolveRequestOrigin(req) || `http://${HOST}:${PORT}`;
const targetUrl = rawTarget.startsWith('http') ? new URL(rawTarget) : new URL(rawTarget.startsWith('/') ? rawTarget : `/${rawTarget}`, origin);
const upstream = await fetch(targetUrl.toString(), {
headers: {
'user-agent': req.get('user-agent') || 'tkmind-wechat-share-preview',
accept: 'text/html,*/*',
},
redirect: 'follow',
});
if (!upstream.ok) {
return res.status(upstream.status).type('text/plain; charset=utf-8').send(`页面请求失败:HTTP ${upstream.status}`);
}
let html = await upstream.text();
const pageUrl = targetUrl.toString().split('#')[0];
const pageDirUrl = `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}`;
html = injectOgTags(html, { origin: targetUrl.origin, pageUrl, pageDirUrl });
const preview = extractSharePreviewMeta(html, {
origin: targetUrl.origin,
pageUrl,
pageDirUrl,
});
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Cache-Control', 'no-store');
return res.send(
renderWechatSharePreviewHtml(preview, {
note: '此预览读取页面最终 HTML 中的 Open Graph 标签,可用来对照微信里粘贴链接后的卡片效果。',
}),
);
} catch (err) {
return res.status(500).type('text/plain; charset=utf-8').send(String(err?.message ?? err));
}
});
app.use('/dev', express.static(path.join(__dirname, 'public/dev'), { maxAge: 0 }));
app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => {
const fileName = path.basename(req.path);
const filePath = path.join(__dirname, 'public', fileName);
if (!fs.existsSync(filePath)) return res.status(404).end();
res.type('text/plain').sendFile(filePath);
});
app.use('/auth', (_req, res) => {
res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev' });
});
app.use('/admin-api', (_req, res) => {
res.status(404).json({ message: '接口不存在,请重启后端服务(node server.mjs 或 pnpm dev' });
});
app.use(express.static(path.join(__dirname, 'dist'), { index: 'index.html' }));
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
userAuthReady.then((enabled) => {
if (isDatabaseConfigured() && !enabled && process.env.NODE_ENV === 'production') {
console.error('Refusing to start portal without user auth while database is configured');
process.exit(1);
}
const server = app.listen(PORT, HOST, () => {
console.log(`TKMind H5 @ http://${HOST}:${PORT}`);
console.log(`Proxy -> ${API_TARGETS.join(', ')}`);
console.log(`Auth -> ${enabled ? 'multi-user (MySQL)' : legacyAuth ? 'legacy password' : 'disabled'}`);
console.log(`Wiki @ http://${HOST}:${PORT}/${PUBLISH_ROOT_DIR}/wiki`);
});
server.on('error', (err) => {
if (err?.code === 'EADDRINUSE') {
console.error(
`Port ${PORT} already in use (${HOST}:${PORT}); exiting so LaunchAgent can retry after ThrottleInterval`,
);
process.exit(1);
}
throw err;
});
});