1597 lines
53 KiB
JavaScript
1597 lines
53 KiB
JavaScript
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();
|
||
// 无状态前端节点(如 105,MindSpace 经 rclone 挂载)需设 MEMIND_WORKSPACE_MAINTENANCE=0,
|
||
// 否则启动时对挂载树做 readdir/递归 fs.watch 会占满 libuv 线程池导致 boot 卡死。
|
||
const WORKSPACE_MAINTENANCE_ENABLED = process.env.MEMIND_WORKSPACE_MAINTENANCE !== '0';
|
||
const USERS_ROOT = process.env.H5_USERS_ROOT ?? path.join(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;
|
||
});
|
||
});
|