Files
memind/server.mjs
2026-07-25 09:44:26 +08:00

1597 lines
53 KiB
JavaScript
Raw Permalink 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 { createAuthManager } from './auth.mjs';
import { createDbPool, isDatabaseConfigured } from './db.mjs';
import { attachMindSpaceImageGenerationRoutes } from './mindspace-image-generation-routes.mjs';
import { sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs';
import { createWikiAuth } from './wiki-auth.mjs';
import { isLocalDevHostname } from './scripts/local-test-config.mjs';
import { PUBLISH_ROOT_DIR } from './user-publish.mjs';
import { startWorkspaceThumbnailWatcher } from './mindspace-workspace-thumbnails.mjs';
import {
buildProductAnalyticsContext,
resolveMindSpaceAnalyticsConfig,
resolveProductAnalyticsConfig,
sendMindSpaceAnalyticsEvent,
} from './mindspace-analytics.mjs';
import { resolveMindSpaceRybbitConfig } from './mindspace-rybbit.mjs';
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
import { attachRequestId, sendData, sendError } from './api-response.mjs';
import { attachPortalAgentJobRoutes } from './server/portal-agent-job-routes.mjs';
import { attachPortalAgentRuntimeRoutes } from './server/portal-agent-runtime-routes.mjs';
import {
attachPortalBlockedWordsRoute,
attachPortalImageMakeRuntimeConfigRoute,
} from './server/portal-config-routes.mjs';
import {
createPortalAccessShadowReporter,
resolvePortalAccessEnforcementConfig,
resolvePortalAccessPolicyMode,
resolvePortalAccessShadowLogIntervalMs,
} from './server/portal-access-policy.mjs';
import { createPortalApiAuthMiddleware } from './server/portal-api-auth-middleware.mjs';
import { attachPortalAccountFeedbackRoutes } from './server/portal-account-feedback-routes.mjs';
import { attachPortalBillingRoutes } from './server/portal-billing-routes.mjs';
import { attachPortalCoreAuthRoutes } from './server/portal-core-auth-routes.mjs';
import { attachPortalWechatAuthRoutes } from './server/portal-wechat-auth-routes.mjs';
import { attachPortalWebhookRoutes } from './server/portal-webhook-routes.mjs';
import { attachPortalWikiRoutes } from './server/portal-wiki-routes.mjs';
import { bootstrapPortalAgentServices } from './server/portal-agent-services-bootstrap.mjs';
import { bootstrapPortalAuthServices } from './server/portal-auth-services-bootstrap.mjs';
import { bootstrapPortalDomainServices } from './server/portal-domain-services-bootstrap.mjs';
import { bootstrapPortalGatewayServices } from './server/portal-gateway-services-bootstrap.mjs';
import { bootstrapPortalIntegrationServices } from './server/portal-integration-services-bootstrap.mjs';
import { bootstrapPortalMemorySessionServices } from './server/portal-memory-session-services-bootstrap.mjs';
import { attachPortalNotificationRoutes } from './server/portal-notification-routes.mjs';
import { attachPortalMindSpaceAssetDeliveryRoutes } from './server/portal-mindspace-asset-delivery-routes.mjs';
import { attachPortalMindSpaceAgentOperationRoutes } from './server/portal-mindspace-agent-operation-routes.mjs';
import { attachPortalMindSpaceAssetRoutes } from './server/portal-mindspace-asset-routes.mjs';
import { attachPortalMindSpaceChatSaveRoutes } from './server/portal-mindspace-chat-save-routes.mjs';
import { attachPortalMindSpaceChatShareRoutes } from './server/portal-mindspace-chat-share-routes.mjs';
import { attachPortalMindSpacePageCoreRoutes } from './server/portal-mindspace-page-core-routes.mjs';
import { attachPortalMindSpacePagePublishRoutes } from './server/portal-mindspace-page-publish-routes.mjs';
import { attachPortalMindSpaceSpaceRoutes } from './server/portal-mindspace-space-routes.mjs';
import { attachPortalPlazaDiscoveryRoutes } from './server/portal-plaza-discovery-routes.mjs';
import { attachPortalPlazaRoutes } from './server/portal-plaza-routes.mjs';
import { attachPortalPublicationRoutes } from './server/portal-publication-routes.mjs';
import { createPortalPublishedPageDelivery } from './server/portal-published-page-delivery.mjs';
import {
removeQueryParam,
resolveRequestOrigin,
} from './server/portal-publication-shell.mjs';
import { attachPortalRuntimeRoutes } from './server/portal-runtime-routes.mjs';
import { attachPortalStaticDeliveryRoutes } from './server/portal-static-delivery-routes.mjs';
import { createPortalWorkspacePublicationDelivery } from './server/portal-workspace-publication-delivery.mjs';
import { createPortalAuthSessionHelpers } from './server/portal-auth-session-helpers.mjs';
import { createPortalSessionCoordinator } from './server/portal-session-coordinator.mjs';
import { attachPortalSessionRoutes } from './server/portal-session-routes.mjs';
import { attachPortalUserMemoryRoutes } from './server/portal-user-memory-routes.mjs';
import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
import { registerPublicHtmlArtifactsForConversationPackage } from './mindspace-conversation-package-public-html.mjs';
import {
buildMindSpacePublicUrlForUser,
resolveMindSpaceRuntimeConfig,
resolveMindSpacePublishRoot,
resolveMindSpaceServerRuntimeOptions,
resolveMindSpaceUserPublishDir,
resolvePortalH5Root,
} from './mindspace-runtime-config.mjs';
import {
publicationInternals,
} from './mindspace-publications.mjs';
import { mapPlazaError } from './plaza-posts.mjs';
import { createNoopPlazaRedis } from './plaza-redis.mjs';
import {
buildChatSavePreviewFrameUrl,
buildChatSaveThumbnailUrl,
buildWorkspaceAssetUrl,
buildWorkspaceThumbnailUrl,
materializePrivateAssetsInWorkspaceHtml,
repairMissingHtmlAssetReferences,
resolveChatSaveAnalysis,
} from './mindspace-chat-save.mjs';
import {
normalizePublicHtmlRelativePath,
} from './mindspace-public-finish-sync.mjs';
import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.mjs';
import { listRecentlyModifiedPublicHtmlRelativePaths } from './mindspace-run-public-html-scope.mjs';
import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES } from './user-image-normalize.mjs';
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
import {
filterUserVisibleConversation,
repairSessionConversationFromDb,
restoreConversationUserMessagesFromAgentRunsFailOpen,
} from './conversation-repair.mjs';
import { attachAsrRoutes } from './asr-proxy.mjs';
import { attachPageDataRoutes, isLegacyPageDataApiPath } from './page-data-routes.mjs';
import { isPageDataPublicPath } from './page-data-public-service.mjs';
import { attachShenmeiOpinionFormRoutes } from './shenmei-opinion-form-routes.mjs';
import { isNativeH5ApiPath } from './policies.mjs';
import {
applyMemindRuntimeProfile,
describeMemindRuntimeProfile,
loadMemindEnvFiles,
} from './scripts/memind-runtime-profile.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
loadMemindEnvFiles(__dirname);
const H5_ROOT = resolvePortalH5Root(__dirname, process.env);
if (!fs.existsSync(H5_ROOT) || !fs.statSync(H5_ROOT).isDirectory()) {
throw new Error(
`MEMIND_PORTAL_H5_ROOT must reference an existing directory: ${H5_ROOT}`,
);
}
if (H5_ROOT !== __dirname) {
console.log(`[Portal] Code root: ${__dirname}`);
console.log(`[Portal] Persistent H5 root: ${H5_ROOT}`);
loadMemindEnvFiles(H5_ROOT);
}
applyMemindRuntimeProfile({ rootDir: H5_ROOT });
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 DEEP_SEARCH_INTERNAL_SECRET =
process.env.TKMIND_DEEP_SEARCH_SECRET ?? INTERNAL_AGENT_SECRET;
const portalAccessPolicyMode = resolvePortalAccessPolicyMode(process.env);
const portalAccessEnforcementConfig = resolvePortalAccessEnforcementConfig(process.env);
const portalAccessShadowReporter = createPortalAccessShadowReporter({
intervalMs: resolvePortalAccessShadowLogIntervalMs(process.env),
emit: (payload) => {
console.warn('[Auth][PortalAccessShadow]', JSON.stringify(payload));
},
});
if (portalAccessEnforcementConfig.killSwitch) {
console.warn('[Auth][PortalAccessEnforce] kill switch active; all policy groups use legacy auth');
} else if (portalAccessEnforcementConfig.enabled) {
console.log(
`[Auth][PortalAccessEnforce] groups enabled: ${portalAccessEnforcementConfig.activeGroups.join(', ')}`,
);
}
const mindSpaceServerRuntime = resolveMindSpaceServerRuntimeOptions(H5_ROOT, 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(H5_ROOT, '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 isWechatMiniProgramSource(value) {
if (!value) return false;
try {
return new URL(value).hostname.endsWith('servicewechat.com');
} catch {
return false;
}
}
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];
if ([origin, referer].some(isWechatMiniProgramSource)) {
return next();
}
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(H5_ROOT), '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;
let agentRunGateway = null;
const {
ownsAgentSession,
unregisterAgentSessionForUser,
beginSessionPageDelivery,
endSessionPageDelivery,
isSessionPageDeliveryActive,
} = createPortalSessionCoordinator({
getSessionAccess: () => sessionAccess,
getUserAuth: () => userAuth,
});
let chatIntentRouter = null;
let toolGateway = null;
let assetGatewayConfigService = null;
let imageMakeAdminConfigService = null;
let mindSpaceImageGeneration = null;
let directChatService = null;
let sessionSnapshotService = null;
let conversationMemoryService = null;
let memoryV2 = null;
let episodicMemoryService = null;
let memoryV2ConfigService = null;
let skillRuntimeConfigService = null;
let systemDisclosurePolicyService = null;
let agentCodeRunPolicyService = 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;
let mindSpaceAnalyticsConfig = resolveMindSpaceAnalyticsConfig();
let mindSpaceRybbitConfig = resolveMindSpaceRybbitConfig();
async function bootstrapUserAuth() {
try {
if (!isDatabaseConfigured()) return false;
const pool = createDbPool();
const domainServices =
await bootstrapPortalDomainServices({
pool,
h5Root: H5_ROOT,
env: process.env,
runtime: mindSpaceServerRuntime,
analyticsConfig: mindSpaceAnalyticsConfig,
getUserAuth: () => userAuth,
getSessionSnapshotService: () =>
sessionSnapshotService,
onMindSearchSchemaReady: () => {
authPool = pool;
},
registerPublicHtmlArtifactsForConversation,
workspaceMaintenanceEnabled:
WORKSPACE_MAINTENANCE_ENABLED,
logger: console,
});
const {
mindSearchConfigService,
mindSpaceRuntimeAdapter,
resolveUserIdByDirKey,
} = domainServices;
mindSpaceAnalyticsConfig =
domainServices.mindSpaceAnalyticsConfig;
scheduleService = domainServices.scheduleService;
pageDataService = domainServices.pageDataService;
pageDataPublicService =
domainServices.pageDataPublicService;
feedbackService = domainServices.feedbackService;
mindSpace = domainServices.mindSpace;
mindSpaceServiceFacade =
domainServices.mindSpaceServiceFacade;
mindSpaceConversationPackageRegistry =
domainServices.mindSpaceConversationPackageRegistry;
mindSpaceAssets = domainServices.mindSpaceAssets;
mindSpacePages = domainServices.mindSpacePages;
mindSpacePageSync = domainServices.mindSpacePageSync;
mindSpacePageLiveEdit =
domainServices.mindSpacePageLiveEdit;
mindSpaceAssetAgent =
domainServices.mindSpaceAssetAgent;
mindSpacePublications =
domainServices.mindSpacePublications;
workspacePageDeliver =
domainServices.workspacePageDeliver;
plazaRedis = domainServices.plazaRedis;
plazaSeo = domainServices.plazaSeo;
plazaInteractions = domainServices.plazaInteractions;
plazaEvents = domainServices.plazaEvents;
plazaRecommend = domainServices.plazaRecommend;
plazaPosts = domainServices.plazaPosts;
plazaOps = domainServices.plazaOps;
mindSpaceCleanup = domainServices.mindSpaceCleanup;
const authServices =
await bootstrapPortalAuthServices({
pool,
h5Root: H5_ROOT,
usersRoot: USERS_ROOT,
mindSearchConfigService,
env: process.env,
logger: console,
});
subscriptionService =
authServices.subscriptionService;
userAuth = authServices.userAuth;
sessionAccess = authServices.sessionAccess;
wechatPayClient = authServices.wechatPayClient;
wechatOAuthService =
authServices.wechatOAuthService;
rechargeService = authServices.rechargeService;
const agentServices =
await bootstrapPortalAgentServices({
pool,
env: process.env,
runtime: mindSpaceServerRuntime,
apiTarget: API_TARGET,
apiTargets: API_TARGETS,
apiSecret: API_SECRET,
userAuth,
sessionAccess,
mindSpaceRuntimeAdapter,
mindSpaceAssets,
resolveUserIdByDirKey,
workspaceMaintenanceEnabled:
WORKSPACE_MAINTENANCE_ENABLED,
startWorkspaceThumbnailWatcher,
startWorkspaceAssetSyncWatcher,
logger: console,
});
mindSpaceAgentJobs =
agentServices.mindSpaceAgentJobs;
mindSpaceAgentRunner =
agentServices.mindSpaceAgentRunner;
mindSpaceAudit = agentServices.mindSpaceAudit;
llmProviderService =
agentServices.llmProviderService;
assetGatewayConfigService =
agentServices.assetGatewayConfigService;
imageMakeAdminConfigService =
agentServices.imageMakeAdminConfigService;
mindSpaceImageGeneration =
agentServices.mindSpaceImageGeneration;
wordFilterService =
agentServices.wordFilterService;
const memorySessionServices =
await bootstrapPortalMemorySessionServices({
pool,
h5Root: H5_ROOT,
env: process.env,
llmProviderService,
userAuth,
sessionAccess,
logger: console,
});
memoryV2ConfigService =
memorySessionServices.memoryV2ConfigService;
skillRuntimeConfigService =
memorySessionServices.skillRuntimeConfigService;
systemDisclosurePolicyService =
memorySessionServices.systemDisclosurePolicyService;
agentCodeRunPolicyService =
memorySessionServices.agentCodeRunPolicyService;
wechatScheduleLlmConfigService =
memorySessionServices.wechatScheduleLlmConfigService;
conversationMemoryService =
memorySessionServices.conversationMemoryService;
memoryV2 = memorySessionServices.memoryV2;
episodicMemoryService =
memorySessionServices.episodicMemoryService;
sessionSnapshotService =
memorySessionServices.sessionSnapshotService;
sessionStreamStore =
memorySessionServices.sessionStreamStore;
directChatService =
memorySessionServices.directChatService;
chatIntentRouter =
memorySessionServices.chatIntentRouter;
const gatewayServices =
bootstrapPortalGatewayServices({
pool,
h5Root: H5_ROOT,
env: process.env,
apiTarget: API_TARGET,
apiTargets: API_TARGETS,
apiSecret: API_SECRET,
userAuth,
sessionAccess,
sessionStreamStore,
llmProviderService,
subscriptionService,
sessionSnapshotService,
conversationMemoryService,
memoryV2,
systemDisclosurePolicyService,
mindSpaceAssets,
directChatService,
chatIntentRouter,
syncUserGeneratedPages,
isSessionPageDeliveryActive,
});
tkmindProxy = gatewayServices.tkmindProxy;
toolGateway = gatewayServices.toolGateway;
agentRunGateway =
gatewayServices.agentRunGateway;
const integrationServices =
await bootstrapPortalIntegrationServices({
pool,
h5Root: H5_ROOT,
env: process.env,
usersRoot: USERS_ROOT,
wechatMpConfig: WECHAT_MP_CONFIG,
authPool,
userAuth,
sessionAccess,
tkmindProxy,
scheduleService,
wechatScheduleLlmConfigService,
llmProviderService,
chatIntentRouter,
systemDisclosurePolicyService,
sessionSnapshotService,
mindSpaceAnalyticsConfig,
mindSpaceRybbitConfig,
subscriptionService,
apiTarget: API_TARGET,
apiSecret: API_SECRET,
mindSpacePages,
mindSpacePageLiveEdit,
logger: console,
});
wechatMpService =
integrationServices.wechatMpService;
notificationDispatcher =
integrationServices.notificationDispatcher;
scheduleReminderWorker =
integrationServices.scheduleReminderWorker;
mindSpacePageEditSession =
integrationServices.mindSpacePageEditSession;
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();
const {
legacySessionToken,
userToken,
setUserLoginCookies,
clearUserLoginCookies,
attachUserSession,
} = createPortalAuthSessionHelpers({
isSecureRequest,
getLegacyAuth: () => legacyAuth,
getUserAuth: () => userAuth,
logger: console,
});
app.use(attachUserSession);
// ============ Legacy password auth ============
async function resolveSkillRuntimeForClient() {
if (!skillRuntimeConfigService?.getPublicRuntimeConfig) return null;
try {
return await skillRuntimeConfigService.getPublicRuntimeConfig();
} catch (err) {
console.warn('[SkillRuntime] public config unavailable:', err instanceof Error ? err.message : err);
return null;
}
}
async function resolveAgentCodeRunForClient(userId) {
if (!agentCodeRunPolicyService?.getPublicClientPolicy) return null;
try {
return await agentCodeRunPolicyService.getPublicClientPolicy(userId);
} catch (err) {
console.warn('[AgentCodeRun] public policy unavailable:', err instanceof Error ? err.message : err);
return null;
}
}
attachPortalCoreAuthRoutes({
app,
jsonBody,
userAuthReady,
getUserAuth: () => userAuth,
getLegacyAuth: () => legacyAuth,
userToken,
legacySessionToken,
setUserLoginCookies,
isSecureRequest,
resolveSkillRuntimeForClient,
resolveAgentCodeRunForClient,
getPlazaSeo: () => plazaSeo,
plazaClientIp,
logger: console,
});
attachPortalWechatAuthRoutes({
app,
jsonBody,
userAuthReady,
getUserAuth: () => userAuth,
getWechatOAuthService: () => wechatOAuthService,
getWechatMpService: () => wechatMpService,
wechatMpConfig: WECHAT_MP_CONFIG,
userToken,
setUserLoginCookies,
isSecureRequest,
getPlazaSeo: () => plazaSeo,
plazaClientIp,
logger: console,
});
attachPortalAccountFeedbackRoutes({
app,
jsonBody,
userAuthReady,
getUserAuth: () => userAuth,
getLegacyAuth: () => legacyAuth,
getSubscriptionService: () => subscriptionService,
getFeedbackService: () => feedbackService,
userToken,
legacySessionToken,
clearUserLoginCookies,
resolveSkillRuntimeForClient,
resolveAgentCodeRunForClient,
logger: console,
});
attachPortalNotificationRoutes({
app,
userAuthReady,
getUserAuth: () => userAuth,
getScheduleService: () => scheduleService,
userToken,
logger: console,
});
attachPortalBillingRoutes({
app,
jsonBody,
userAuthReady,
getUserAuth: () => userAuth,
getRechargeService: () => rechargeService,
getSubscriptionService: () => subscriptionService,
getMindSpace: () => mindSpace,
userToken,
});
const wechatNotifyBody = express.raw({
type: ['application/json', 'text/xml', 'application/xml'],
limit: '64kb',
});
const wechatMpBody = express.text({
type: ['text/xml', 'application/xml'],
limit: '128kb',
});
attachPortalWebhookRoutes({
app,
userAuthReady,
wechatNotifyBody,
wechatMpBody,
wechatMpConfig: WECHAT_MP_CONFIG,
getRechargeService: () => rechargeService,
getWechatPayClient: () => wechatPayClient,
getWechatMpService: () => wechatMpService,
logger: console,
});
// ============ Wiki API ============
const wikiApi = express.Router();
attachPortalWikiRoutes({
router: wikiApi,
jsonBody,
wikiAuth,
isSecureRequest,
});
app.use('/wiki-api', wikiApi);
// ============ TKMind API proxy ============
const api = express.Router();
api.use(jsonUnlessMultipart);
attachShenmeiOpinionFormRoutes(api, { rootDir: __dirname });
api.use(createPortalApiAuthMiddleware({
waitForUserAuthReady: () => userAuthReady,
getUserAuth: () => userAuth,
getTkmindProxy: () => tkmindProxy,
getLegacyAuth: () => legacyAuth,
getLegacySessionToken: legacySessionToken,
isPageDataPublicPath,
isLegacyPageDataApiPath,
isProductAnalyticsPublicPath: (requestPath, method) =>
method === 'GET' && requestPath === '/analytics/context',
accessPolicyMode: portalAccessPolicyMode,
accessEnforcementConfig: portalAccessEnforcementConfig,
accessShadowReporter: portalAccessShadowReporter,
logger: console,
}));
attachPortalImageMakeRuntimeConfigRoute(api, {
getImageMakeAdminConfigService: () => imageMakeAdminConfigService,
});
api.get('/analytics/context', (req, res) => {
const config = resolveProductAnalyticsConfig(process.env, mindSpaceAnalyticsConfig);
res.set('Cache-Control', 'private, no-store');
res.json(buildProductAnalyticsContext({ config, user: req.currentUser ?? null }));
});
attachAsrRoutes(api, { sendError, sendData });
attachMindSpaceImageGenerationRoutes(api, {
getService: () => mindSpaceImageGeneration,
requireInternal: requireInternalAgentSecret,
});
attachPageDataRoutes(api, {
sendError,
sendData,
getPageDataService: () => pageDataService,
getPageDataPublicService: () => pageDataPublicService,
});
attachPortalBlockedWordsRoute(api, {
waitForUserAuthReady: () => userAuthReady,
getWordFilterService: () => wordFilterService,
});
attachPortalRuntimeRoutes(api, {
waitForUserAuthReady: () => userAuthReady,
getUserAuth: () => userAuth,
getTkmindProxy: () => tkmindProxy,
getEpisodicMemoryService: () => episodicMemoryService,
getAgentRunGateway: () => agentRunGateway,
getCodeRunPolicyService: () => agentCodeRunPolicyService,
env: process.env,
});
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);
}
const visible = filterUserVisibleConversation(session?.conversation ?? []);
if (!authPool || !userId) return visible;
return restoreConversationUserMessagesFromAgentRunsFailOpen(
authPool,
visible,
sessionId,
userId,
);
}
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(() => []);
}
attachPortalUserMemoryRoutes(api, {
getMemoryV2: () => memoryV2,
getTkmindProxy: () => tkmindProxy,
ensureUserMemoryCapability,
ownsAgentSession,
loadUserVisibleConversation,
syncUserMemoriesIntoSession,
resolveUserMemoryItems,
});
attachPortalMindSpaceSpaceRoutes(api, {
getMindSpace: () => mindSpace,
getScheduleService: () => scheduleService,
getMindSpaceCleanup: () => mindSpaceCleanup,
getMindSpaceAudit: () => mindSpaceAudit,
ensureMindSpaceEnabled,
sendData,
sendError,
handleMindSpaceError: mindSpaceError,
});
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 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;
}
attachPortalAgentJobRoutes(api, {
getMindSpaceAgentJobs: () => mindSpaceAgentJobs,
getMindSpaceAgentRunner: () => mindSpaceAgentRunner,
getMindSpaceAudit: () => mindSpaceAudit,
ensureMindSpaceEnabled,
requireInternalAgentSecret,
bearerToken,
sendData,
handleMindSpaceError: mindSpaceError,
getSsePollMs: () => mindSpaceServerRuntime.agentWorker.ssePollMs,
logger: console,
});
attachPortalMindSpaceAssetDeliveryRoutes(api, {
rawUploadBody,
getConversationPackageRegistry: () => mindSpaceConversationPackageRegistry,
getUserAuth: () => userAuth,
getSessionAccess: () => sessionAccess,
beforeReadManifest: ({ req, sessionId }) =>
mindSpaceServiceFacade?.prepareConversationPackageRead({
user: req.currentUser,
sessionId,
}),
getMindSpaceAssets: () => mindSpaceAssets,
ensureMindSpaceEnabled,
sendData,
handleMindSpaceError: mindSpaceError,
});
attachPortalMindSpaceAssetRoutes(api, {
getMindSpacePublications: () => mindSpacePublications,
getDatabasePool: () => authPool,
getMindSpaceAssets: () => mindSpaceAssets,
getMindSpacePages: () => mindSpacePages,
getMindSpaceAudit: () => mindSpaceAudit,
getInternalAgentSecret: () => INTERNAL_AGENT_SECRET,
ensureMindSpaceEnabled,
resolveRequestOrigin,
sendData,
handleMindSpaceError: mindSpaceError,
});
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);
}
// REGRESSION GUARD: mindspace-page-sync-thumbnail — remote 也经 pageSyncService RPC 同步 public HTML
async function listSessionPublicHtmlRelativePaths(userId, sessionId, { sinceMs = null } = {}) {
if (!authPool || !userId || !sessionId) return [];
const sinceClause = sinceMs == null ? '' : 'AND ca.created_at >= ?';
const params = sinceMs == null ? [userId, sessionId] : [userId, sessionId, sinceMs];
const [rows] = await authPool.query(
`SELECT ca.display_name
FROM h5_conversation_artifacts ca
JOIN h5_conversation_packages cp ON cp.id = ca.package_id
WHERE cp.user_id = ?
AND cp.session_id = ?
AND ca.artifact_kind = 'public_html'
${sinceClause}
ORDER BY ca.sort_order ASC, ca.created_at ASC`,
params,
);
return [...new Set((rows ?? [])
.map((row) => normalizeWorkspaceRelativePath(`public/${String(row.display_name ?? '').trim()}`))
.filter((relativePath) => relativePath?.startsWith('public/') && relativePath.toLowerCase().endsWith('.html')))];
}
async function syncUserGeneratedPages(userId, { sessionId = null, sinceMs = null } = {}) {
if (!userId) return;
// Agent-run/Finish delivery must stay scoped to the current conversation.
// A stale Page Data page elsewhere in the user's workspace must not turn a
// successfully completed current task into a failed run.
const discoveredRelativePaths = sessionId
? await listSessionPublicHtmlRelativePaths(userId, sessionId, { sinceMs })
: null;
// A normal static-page-publish flow may only leave a workspace HTML file and
// no conversation artifact. Discover only files written around this run;
// `null` would rescan every historical page and let stale Page Data pages
// block an unrelated delivery.
const recentWorkspaceRelativePaths = sessionId && !discoveredRelativePaths?.length
? listRecentlyModifiedPublicHtmlRelativePaths(
resolveMindSpaceUserPublishDir(H5_ROOT, { id: userId }),
{ sinceMs },
)
: [];
const pageDataRelativePaths = sessionId
? (discoveredRelativePaths?.length ? discoveredRelativePaths : recentWorkspaceRelativePaths)
: null;
if (workspacePageDeliver?.syncAndDeliver) {
return await workspacePageDeliver.syncAndDeliver(userId, { pageDataRelativePaths });
}
if (!mindSpacePageSync) return;
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: H5_ROOT,
env: process.env,
user,
publishDir,
sessionId,
title,
relativePaths,
artifactRefs,
});
}
attachPortalMindSpaceChatSaveRoutes(api, {
h5Root: H5_ROOT,
env: process.env,
getMindSpacePages: () => mindSpacePages,
getMindSpaceAssets: () => mindSpaceAssets,
getAuthPool: () => authPool,
getConversationPackageRegistry: () =>
mindSpaceConversationPackageRegistry,
resolveChatSaveBundle,
resolveExistingSavedPage,
resolveOwnedAssistantMessage,
sendData,
handleMindSpaceError: mindSpaceError,
});
attachPortalMindSpaceChatShareRoutes(api, {
h5Root: H5_ROOT,
env: process.env,
getMindSpacePages: () => mindSpacePages,
getMindSpacePublications: () => mindSpacePublications,
getPlazaPosts: () => plazaPosts,
getAuthPool: () => authPool,
resolveChatSaveBundle,
resolvePlazaPostUrl: resolvePlazaPostUrlForRequest,
mapPlazaError,
sendData,
sendError,
handlePlazaError: plazaRouteError,
handleMindSpaceError: mindSpaceError,
logger: console,
});
attachPortalMindSpacePageCoreRoutes(api, {
getMindSpacePages: () => mindSpacePages,
getMindSpacePublications: () => mindSpacePublications,
getMindSpace: () => mindSpace,
getMindSpaceAudit: () => mindSpaceAudit,
getMindSpacePageLiveEdit: () => mindSpacePageLiveEdit,
getMindSpacePageEditSession: () => mindSpacePageEditSession,
getUserAuth: () => userAuth,
syncUserGeneratedPages,
ownsAgentSession,
ensureMindSpaceEnabled,
sendData,
sendError,
handleMindSpaceError: mindSpaceError,
});
attachPortalMindSpaceAgentOperationRoutes(api, {
getMindSpacePageLiveEdit: () => mindSpacePageLiveEdit,
getMindSpaceAssetAgent: () => mindSpaceAssetAgent,
getMindSpaceAudit: () => mindSpaceAudit,
sendData,
sendError,
handleMindSpaceError: mindSpaceError,
});
attachPortalMindSpacePagePublishRoutes(api, {
env: process.env,
getMindSpacePages: () => mindSpacePages,
getMindSpacePublications: () => mindSpacePublications,
getMindSpaceAudit: () => mindSpaceAudit,
getAuthPool: () => authPool,
sendData,
handleMindSpaceError: mindSpaceError,
});
attachPortalPlazaDiscoveryRoutes(api, {
getPlazaPosts: () => plazaPosts,
getPlazaSeo: () => plazaSeo,
sendData,
sendError,
handleRouteError: plazaRouteError,
});
attachPortalPlazaRoutes(api, {
getPlazaSeo: () => plazaSeo,
getPlazaOps: () => plazaOps,
getPlazaPosts: () => plazaPosts,
getPlazaEvents: () => plazaEvents,
getPlazaInteractions: () => plazaInteractions,
getPlazaRedis: () => plazaRedis,
resolveClientIp: plazaClientIp,
sendData,
sendError,
handleRouteError: plazaRouteError,
});
attachPortalAgentRuntimeRoutes(api, {
waitForUserAuthReady: () => userAuthReady,
getUserAuth: () => userAuth,
getTkmindProxy: () => tkmindProxy,
getLlmProviderService: () => llmProviderService,
getAgentRunGateway: () => agentRunGateway,
getSessionAccess: () => sessionAccess,
getMindSpaceAssetAgent: () => mindSpaceAssetAgent,
getCodeRunPolicyService: () => agentCodeRunPolicyService,
getUserToken: userToken,
getDeepSearchInternalSecret: () =>
DEEP_SEARCH_INTERNAL_SECRET,
bearerToken,
ownsAgentSession,
logger: console,
});
attachPortalSessionRoutes(api, {
h5Root: H5_ROOT,
env: process.env,
waitForUserAuthReady: () => userAuthReady,
getUserAuth: () => userAuth,
getTkmindProxy: () => tkmindProxy,
getSessionSnapshotService: () => sessionSnapshotService,
getAuthPool: () => authPool,
getMindSpaceAssets: () => mindSpaceAssets,
getMemoryV2: () => memoryV2,
getMindSpaceAnalyticsConfig: () => mindSpaceAnalyticsConfig,
getMindSpaceRybbitConfig: () => mindSpaceRybbitConfig,
isWorkspaceMaintenanceEnabled: () =>
WORKSPACE_MAINTENANCE_ENABLED,
ownsAgentSession,
unregisterAgentSessionForUser,
beginSessionPageDelivery,
endSessionPageDelivery,
syncUserGeneratedPages,
registerPublicHtmlArtifactsForConversation,
logger: console,
});
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);
app.use('/analytics', createProxyMiddleware({
target: 'http://127.0.0.1:3100',
router: () => mindSpaceAnalyticsConfig.analyticsUrl || process.env.MEMIND_ANALYTICS_URL || 'http://127.0.0.1:3100',
changeOrigin: true,
secure: false,
pathRewrite: { [`^${mindSpaceAnalyticsConfig.hostPath}`]: '' },
}));
app.use('/rybbit', createProxyMiddleware({
target: 'https://rybbit.tkmind.cn',
router: () =>
mindSpaceRybbitConfig.rybbitUrl ||
process.env.MEMIND_RYBBIT_URL ||
process.env.RYBBIT_URL ||
'https://rybbit.tkmind.cn',
changeOrigin: true,
secure: true,
// Express strips the /rybbit mount. Rybbit serves trackers under /api/*.
pathRewrite: { '^/': '/api/' },
}));
const { serveUserPublishFile } =
createPortalWorkspacePublicationDelivery({
h5Root: H5_ROOT,
internalAgentSecret:
INTERNAL_AGENT_SECRET,
analyticsConfig:
mindSpaceAnalyticsConfig,
rybbitConfig:
mindSpaceRybbitConfig,
getAuthPool: () => authPool,
getUserAuth: () => userAuth,
getMindSpacePages: () => mindSpacePages,
resolveRequestOrigin,
removeQueryParam,
logger: console,
});
// 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);
const sendPublishedPage =
createPortalPublishedPageDelivery({
registerLongImageArtifact:
registerPublishedLongImageArtifactForConversation,
});
attachPortalPublicationRoutes({
app,
urlencodedBody: express.urlencoded({
extended: false,
limit: '2kb',
}),
userAuthReady,
h5Root: H5_ROOT,
getAuthPool: () => authPool,
getMindSpacePages: () => mindSpacePages,
getMindSpacePublications: () =>
mindSpacePublications,
getUserAuth: () => userAuth,
sendPublishedPage,
});
attachPortalStaticDeliveryRoutes({
app,
h5Root: __dirname,
host: HOST,
port: PORT,
publishRootDir: PUBLISH_ROOT_DIR,
usersRoot: USERS_ROOT,
userAuthReady,
resolveRequestOrigin,
});
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(`[Portal] Runtime profile: ${describeMemindRuntimeProfile()}`);
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;
});
});