Add MindSpace page live edit, chat skills, and H5 deploy tooling.

Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-15 22:09:38 -07:00
parent 3cd322ccfe
commit 6ee6fd64dd
94 changed files with 7015 additions and 2136 deletions
+249 -13
View File
@@ -21,7 +21,7 @@ import {
userSessionCookie,
} from './user-auth.mjs';
import { createWikiAuth } from './wiki-auth.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, resolvePublishDir } from './user-publish.mjs';
import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir } from './user-publish.mjs';
import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
import { attachRequestId, sendData, sendError } from './api-response.mjs';
@@ -30,6 +30,8 @@ import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
import { createMindSpaceService } from './mindspace.mjs';
import { createAssetService } from './mindspace-assets.mjs';
import { createPageService, pageInternals } from './mindspace-pages.mjs';
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
import { createPublicationService } from './mindspace-publications.mjs';
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
@@ -151,6 +153,10 @@ app.use('/api', csrfOriginCheck);
app.use('/auth', csrfOriginCheck);
const jsonBody = express.json({ limit: '1mb' });
const jsonUnlessMultipart = (req, res, next) => {
if ((req.headers['content-type'] ?? '').includes('multipart/form-data')) return next();
return jsonBody(req, res, next);
};
const rawUploadBody = express.raw({
type: 'application/octet-stream',
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? 2 * 1024 * 1024),
@@ -169,6 +175,8 @@ let mindSpace = null;
let mindSpaceAssets = null;
let mindSpaceAudit = null;
let mindSpacePages = null;
let mindSpacePageLiveEdit = null;
let mindSpacePageEditSession = null;
let mindSpacePublications = null;
let plazaPosts = null;
let plazaInteractions = null;
@@ -207,6 +215,16 @@ async function bootstrapUserAuth() {
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
});
mindSpacePageLiveEdit = createPageLiveEditService({
pageService: mindSpacePages,
resolveUserIdForAgentSession: async (sessionId) => {
const [rows] = await pool.query(
`SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
[sessionId],
);
return rows[0]?.user_id ?? null;
},
});
mindSpacePublications = createPublicationService(pool, {
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
@@ -253,7 +271,7 @@ async function bootstrapUserAuth() {
userAuth = createUserAuth(pool, {
usersRoot: USERS_ROOT,
h5Root: __dirname,
defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 1000),
defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500),
});
wechatPayClient = createWechatPayClient(loadWechatPayConfig());
wechatOAuthService = createWechatOAuthService(pool, loadWechatOAuthConfig(), { userAuth });
@@ -323,6 +341,14 @@ async function bootstrapUserAuth() {
userAuth,
llmProviderService,
});
mindSpacePageEditSession = createPageEditSessionService({
apiTarget: API_TARGET,
apiSecret: API_SECRET,
userAuth,
pageService: mindSpacePages,
pageLiveEdit: mindSpacePageLiveEdit,
llmProviderService,
});
console.log(`User auth enabled (MySQL), workspace root: ${USERS_ROOT}`);
return true;
} catch (err) {
@@ -665,7 +691,7 @@ app.get('/auth/wechat/callback', async (req, res) => {
.catch(() => {});
}
if (result.authMode === 'open') {
if (result.authMode === 'open' || result.authMode === 'scan') {
return res.send(`<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><title>微信登录</title></head><body><p>扫码登录成功,请返回电脑继续操作。</p></body></html>`);
}
@@ -1038,6 +1064,20 @@ app.get('/auth/usage', async (req, res) => {
res.json({ records });
});
app.get('/auth/billing/ledger', async (req, res) => {
await userAuthReady;
if (!userAuth) return res.status(503).json({ message: '未启用用户系统' });
const me = await userAuth.getMe(userToken(req));
if (!me) return res.status(401).json({ message: '未登录' });
const limit = Math.min(Math.max(Number(req.query?.limit) || 30, 1), 100);
const entries = await userAuth.listBillingLedger({
userId: me.id,
limit,
types: ['recharge', 'adjust', 'refund'],
});
res.json({ entries });
});
app.get('/auth/billing/config', async (req, res) => {
await userAuthReady;
if (!userAuth || !rechargeService) {
@@ -1223,12 +1263,13 @@ app.use('/wiki-api', wikiApi);
// ============ TKMind API proxy ============
const api = express.Router();
api.use(jsonBody);
api.use(jsonUnlessMultipart);
api.use(async (req, res, next) => {
await userAuthReady;
if (req.path === '/status') return next();
if (req.path.startsWith('/internal/agent/')) return next();
if (req.path === '/agent/mindspace_page_patch') return next();
const plazaPublic = isPlazaPublicRead(req.path, req.method);
@@ -1251,6 +1292,8 @@ api.use(async (req, res, next) => {
return res.status(401).json({ message: '未授权,请重新登录' });
});
attachAsrRoutes(api, { sendError, sendData });
api.get('/status', async (_req, res, next) => {
await userAuthReady;
if (userAuth && tkmindProxy) {
@@ -1304,6 +1347,7 @@ api.post('/mindspace/v1/space/cleanup', async (req, res) => {
const username = req.currentUser.username ?? req.currentUser.slug;
const itemIds = Array.isArray(req.body?.item_ids) ? req.body.item_ids : [];
const result = await mindSpaceCleanup.runCleanup(req.currentUser.id, username, itemIds);
const quota = await mindSpace.getQuota(req.currentUser.id);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'space.cleanup',
@@ -1312,7 +1356,7 @@ api.post('/mindspace/v1/space/cleanup', async (req, res) => {
ip: req.ip,
detail: { removedCount: result.removedCount, freedBytes: result.freedBytes },
});
return sendData(res, req, result);
return sendData(res, req, { ...result, quota });
} catch (error) {
return mindSpaceError(res, req, error);
}
@@ -1517,10 +1561,27 @@ function mindSpaceError(res, req, error) {
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 ? 'MindSpace 服务异常' : error.message;
const message =
status === 500 && (!error?.code || code === 'internal_error')
? 'MindSpace 服务异常'
: error?.message || 'MindSpace 服务异常';
return sendError(res, req, status, code, message, error?.details);
}
@@ -2294,6 +2355,7 @@ api.delete('/mindspace/v1/pages/:pageId', async (req, res) => {
if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return;
try {
const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId);
const quota = await mindSpace.getQuota(req.currentUser.id);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'page.delete',
@@ -2306,7 +2368,7 @@ api.delete('/mindspace/v1/pages/:pageId', async (req, res) => {
freedBytes: result.freedBytes,
},
});
return sendData(res, req, result);
return sendData(res, req, { ...result, quota });
} catch (error) {
return mindSpaceError(res, req, error);
}
@@ -2339,6 +2401,143 @@ api.put('/mindspace/v1/pages/:pageId', async (req, res) => {
}
});
api.post('/mindspace/v1/pages/:pageId/live-edit/bind', async (req, res) => {
if (!mindSpacePageLiveEdit || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const sessionId = String(req.body?.session_id ?? '').trim();
if (!sessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 session_id');
}
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return sendError(res, req, 403, 'forbidden', '无权绑定该 Agent 会话');
}
await mindSpacePages.getPage(req.currentUser.id, req.params.pageId);
const parentSessionId = String(req.body?.parent_session_id ?? '').trim() || null;
return sendData(
res,
req,
mindSpacePageLiveEdit.bindSession({
userId: req.currentUser.id,
sessionId,
pageId: req.params.pageId,
parentSessionId,
}),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/live-edit/fork-session', async (req, res) => {
if (!mindSpacePageEditSession || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const parentSessionId = String(req.body?.parent_session_id ?? '').trim();
if (!parentSessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 parent_session_id');
}
const gate = await userAuth.canUseChat(req.currentUser.id);
if (!gate.ok) {
return sendError(res, req, 402, gate.code ?? 'insufficient_balance', gate.message);
}
return sendData(
res,
req,
await mindSpacePageEditSession.forkSession({
userId: req.currentUser.id,
pageId: req.params.pageId,
parentSessionId,
h5ApiBase: String(req.body?.h5_api_base ?? req.body?.h5ApiBase ?? '').trim() || null,
}),
);
} catch (error) {
if (error?.code === 'forbidden') {
return sendError(res, req, 403, error.code, error.message);
}
if (error?.code === 'invalid_request') {
return sendError(res, req, 400, error.code, error.message);
}
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/live-edit/close-session', async (req, res) => {
if (!mindSpacePageEditSession || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const sessionId = String(req.body?.session_id ?? '').trim();
if (!sessionId) {
return sendError(res, req, 400, 'invalid_request', '缺少 session_id');
}
return sendData(
res,
req,
await mindSpacePageEditSession.closeSession({
userId: req.currentUser.id,
pageId: req.params.pageId,
sessionId,
parentSessionId: String(req.body?.parent_session_id ?? '').trim() || null,
summary: String(req.body?.summary ?? ''),
}),
);
} catch (error) {
if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') {
return sendError(res, req, 403, error.code, error.message);
}
if (error?.code === 'invalid_request') {
return sendError(res, req, 400, error.code, error.message);
}
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/live-edit/revision', async (req, res) => {
if (!mindSpacePageLiveEdit || !mindSpacePages) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
return sendData(
res,
req,
await mindSpacePageLiveEdit.getRevisionSnapshot(req.currentUser.id, req.params.pageId),
);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/agent/mindspace_page_patch', async (req, res) => {
if (!mindSpacePageLiveEdit) {
return res.status(503).json({ message: 'MindSpace 未启用' });
}
try {
const result = await mindSpacePageLiveEdit.applyAgentPatch(req.body ?? {});
return res.json({
data: {
pageId: result.page.id,
title: result.page.title,
summary: result.page.summary,
versionNo: result.page.versionNo,
updatedAt: result.page.updatedAt,
liveRevision: result.liveRevision,
},
});
} catch (error) {
if (error?.code === 'forbidden' || error?.code === 'page_binding_mismatch') {
return sendError(res, req, 403, error.code, error.message);
}
if (error?.code === 'invalid_request') {
return sendError(res, req, 400, error.code, error.message);
}
return mindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
@@ -2383,11 +2582,16 @@ api.post('/mindspace/v1/pages/:pageId/thumbnail/regenerate', async (req, res) =>
},
{
suggestCoverMeta: req.body?.use_ai
? (input) =>
suggestCoverMetaWithAi(authPool, {
? (input) => {
if (!authPool) {
throw Object.assign(new Error('LLM 服务未就绪'), { code: 'llm_not_configured' });
}
return suggestCoverMetaWithAi(authPool, {
...input,
encryptionKey: process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY,
})
encryptionKey:
process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY,
});
}
: null,
},
);
@@ -2884,6 +3088,29 @@ api.get('/sessions', async (req, res, next) => {
return runHandlerChain(tkmindProxy.handlers['GET /sessions'], req, res, next);
});
api.delete('/sessions/:sessionId', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
const sessionId = req.params.sessionId;
const owns = await userAuth.ownsSession(req.currentUser.id, sessionId);
if (!owns) {
return res.status(403).json({ message: '无权访问该会话' });
}
try {
const upstream = await tkmindProxy.apiFetch(`/sessions/${encodeURIComponent(sessionId)}`, {
method: 'DELETE',
});
if (!upstream.ok && upstream.status !== 404) {
const text = await upstream.text().catch(() => '');
return res.status(upstream.status).send(text || '删除会话失败');
}
await userAuth.unregisterAgentSession(req.currentUser.id, sessionId);
return res.status(204).end();
} catch (err) {
return res.status(500).json({ message: err instanceof Error ? err.message : '删除会话失败' });
}
});
api.get('/sessions/:sessionId/events', async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
@@ -2895,8 +3122,6 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
return tkmindProxy.proxySessionEvents(req, res, sessionId);
});
attachAsrRoutes(api, { sendError, sendData });
api.use(async (req, res, next) => {
await userAuthReady;
if (!userAuth || !tkmindProxy) return next();
@@ -3243,6 +3468,17 @@ async function serveUserPublishFile(req, res, next) {
}
if (!fs.existsSync(resolvedPath)) {
if (rest.length === 1 && rest[0].toLowerCase().endsWith('.html')) {
const publicFallback = path.resolve(targetDir, PUBLIC_ZONE_DIR, rest[0]);
if (
publicFallback.startsWith(`${resolvedRoot}${path.sep}`) &&
fs.existsSync(publicFallback) &&
fs.statSync(publicFallback).isFile()
) {
res.sendFile(publicFallback);
return;
}
}
res.status(404).json({ message: '文件不存在' });
return;
}