refactor: Business logic and dependencies updates

- 核心服务代码更新 (db, server, auth, proxy)
- Agent 相关模块更新 (mindspace, experience)
- 前端组件和 hooks 更新
- 数据库 schema 更新
- 依赖版本更新
This commit is contained in:
john
2026-06-27 08:25:02 +08:00
parent f1220a7905
commit 25f8223253
20 changed files with 1458 additions and 116 deletions
+257 -4
View File
@@ -95,6 +95,7 @@ import { createScheduleService } from './schedule-service.mjs';
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
import { createSessionSnapshotService } from './session-snapshot.mjs';
import { createExperienceService } from './experience-service.mjs';
import { attachAsrRoutes } from './asr-proxy.mjs';
import { isNativeH5ApiPath } from './policies.mjs';
@@ -186,6 +187,10 @@ const rawUploadBody = express.raw({
type: 'application/octet-stream',
limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
});
const rawUploadBodyImage = express.raw({
type: 'application/octet-stream',
limit: 2 * 1024 * 1024,
});
const wikiAuth = createWikiAuth(path.join(__dirname, PUBLISH_ROOT_DIR, 'wiki-db'));
@@ -270,6 +275,16 @@ async function bootstrapUserAuth() {
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
});
setInterval(async () => {
try {
const result = await mindSpacePublications.cleanupExpiredUnconfirmedPublications();
if (result.cleaned > 0) {
console.log(`[Publication Cleanup] Auto-privatized ${result.cleaned} expired unconfirmed publications`);
}
} catch (err) {
console.error('[Publication Cleanup Error]', err instanceof Error ? err.message : err);
}
}, 60 * 1000);
await ensureAlgorithmConfig(pool);
const plazaAlgorithmConfig = await loadAlgorithmConfig(pool);
plazaRedis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool);
@@ -347,13 +362,104 @@ async function bootstrapUserAuth() {
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES),
});
// Shared experience store (etat C): retrieval before / recording after each
// agent job, so all instances learn from one another. Gated so it can be
// disabled without touching the runner. Polyglot: when EXPERIENCE_PG_URL is
// set we use PostgreSQL + pgvector (semantic search) for this workload only;
// the MySQL business DB is untouched. Falls back to MySQL keyword store if PG
// init fails (e.g. driver missing) so a misconfig never blocks startup.
let experienceService = null;
if (process.env.MINDSPACE_EXPERIENCE_ENABLED !== 'false') {
if (process.env.EXPERIENCE_PG_URL) {
try {
const { createPgExperienceService } = await import('./experience-service-pg.mjs');
experienceService = await createPgExperienceService({
connectionString: process.env.EXPERIENCE_PG_URL,
});
console.log('Experience store: PostgreSQL + pgvector');
} catch (error) {
console.error(
'Experience PG init failed, falling back to MySQL store:',
error instanceof Error ? error.message : error,
);
experienceService = createExperienceService(pool);
}
} else {
experienceService = createExperienceService(pool);
}
}
mindSpaceAgentRunner = createMindSpaceAgentRunner({
apiTarget: API_TARGET,
apiSecret: API_SECRET,
userAuth,
agentJobService: mindSpaceAgentJobs,
experienceService,
});
mindSpaceAudit = createMindSpaceAuditWriter(pool);
// Agent job consumer: a DB-polling worker that atomically claims queued jobs
// (claimNextJob uses SELECT ... FOR UPDATE SKIP LOCKED, so multiple instances
// can run this loop without double-processing) and runs them via the runner.
// Opt-in per instance: must NOT run on the 105 stateless front (see
// docs/g2-load-balancing.md) — gate with MINDSPACE_AGENT_WORKER_ENABLED.
if (process.env.MINDSPACE_AGENT_WORKER_ENABLED === 'true') {
const workerConcurrency = Math.max(
1,
Number(process.env.MINDSPACE_AGENT_WORKER_CONCURRENCY ?? 2),
);
const workerPollMs = Math.max(
200,
Number(process.env.MINDSPACE_AGENT_WORKER_POLL_MS ?? 1000),
);
const workerStaleMs = Math.max(
10_000,
Number(process.env.MINDSPACE_AGENT_WORKER_STALE_MS ?? 5 * 60 * 1000),
);
let inFlight = 0;
let draining = false;
const drainQueue = async () => {
if (draining) return;
draining = true;
try {
while (inFlight < workerConcurrency) {
const claim = await mindSpaceAgentJobs.claimNextJob();
if (!claim) break;
inFlight += 1;
void mindSpaceAgentRunner
.runJob(claim.jobId, claim)
.catch((error) => {
console.error('Agent worker job failed:', error);
})
.finally(() => {
inFlight -= 1;
});
}
} catch (error) {
console.error('Agent worker drain failed:', error);
} finally {
draining = false;
}
};
const workerTimer = setInterval(() => {
void drainQueue();
}, workerPollMs);
const reaperTimer = setInterval(() => {
void mindSpaceAgentJobs
.reapStaleJobs(workerStaleMs)
.then((reaped) => {
if (reaped > 0) {
console.warn(`Agent worker reaped ${reaped} stale running job(s)`);
}
})
.catch((error) => {
console.error('Agent worker reaper failed:', error);
});
}, Math.min(workerStaleMs, 60_000));
workerTimer.unref?.();
reaperTimer.unref?.();
console.log(
`Agent job worker enabled (concurrency=${workerConcurrency}, poll=${workerPollMs}ms)`,
);
}
if (WORKSPACE_MAINTENANCE_ENABLED) {
startWorkspaceThumbnailWatcher(path.join(__dirname, PUBLISH_ROOT_DIR));
startWorkspaceAssetSyncWatcher({
@@ -1742,6 +1848,75 @@ api.get('/mindspace/v1/agent/jobs/:jobId', async (req, res) => {
}
});
// Server-Sent Events stream of a job's progress, so long-running agent tasks can
// be dispatched async (enqueue → 202 → subscribe here) instead of holding a
// synchronous streaming connection. Polls the job (ownership enforced by getJob)
// and pushes on change; closes on terminal status or client disconnect.
api.get('/mindspace/v1/agent/jobs/:jobId/stream', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'timed_out']);
let job;
try {
job = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId);
} catch (error) {
return mindSpaceError(res, req, error);
}
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
const send = (event, payload) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(payload)}\n\n`);
};
let lastSignature = '';
const emitIfChanged = (current) => {
const signature = `${current.status}:${JSON.stringify(current.progress ?? {})}`;
if (signature !== lastSignature) {
lastSignature = signature;
send('progress', current);
}
return signature;
};
emitIfChanged(job);
if (TERMINAL.has(job.status)) {
send('done', job);
return res.end();
}
let closed = false;
const cleanup = () => {
if (closed) return;
closed = true;
clearInterval(pollTimer);
clearInterval(keepAliveTimer);
};
const pollTimer = setInterval(async () => {
if (closed) return;
try {
const current = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId);
emitIfChanged(current);
if (TERMINAL.has(current.status)) {
send('done', current);
cleanup();
res.end();
}
} catch {
// Job vanished or transient read error: end the stream rather than leak it.
cleanup();
res.end();
}
}, Math.max(500, Number(process.env.MINDSPACE_AGENT_SSE_POLL_MS ?? 1000)));
// Comment line keeps proxies from closing an idle connection.
const keepAliveTimer = setInterval(() => {
if (!closed) res.write(': keep-alive\n\n');
}, 15_000);
pollTimer.unref?.();
keepAliveTimer.unref?.();
req.on('close', cleanup);
});
api.get('/mindspace/v1/agent/jobs', async (req, res) => {
if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return;
try {
@@ -1893,7 +2068,7 @@ api.post('/mindspace/v1/uploads', async (req, res) => {
}
});
api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBody, async (req, res) => {
api.put('/mindspace/v1/uploads/:uploadId/content', rawUploadBodyImage, async (req, res) => {
if (!mindSpaceAssets) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const result = await mindSpaceAssets.writeUploadContent(
@@ -1947,6 +2122,58 @@ api.get('/mindspace/v1/assets', async (req, res) => {
}
});
api.get('/mindspace/v1/authorize-image', async (req, res) => {
if (!mindSpacePublications) {
return res.status(503).json({ error: 'Service unavailable' });
}
const assetId = String(req.query.asset_id ?? '');
if (!assetId) {
return res.status(400).json({ error: 'Missing asset_id parameter' });
}
try {
const [refs] = await pool.query(
`SELECT pr.access_mode, pr.expires_at
FROM h5_publication_asset_refs refs
JOIN h5_publish_records pr ON refs.publication_id = pr.id
WHERE refs.asset_id = ? AND pr.status = 'online'
ORDER BY CASE
WHEN pr.access_mode = 'public' THEN 0
WHEN pr.access_mode = 'time_limited' THEN 1
ELSE 2
END,
pr.expires_at DESC
LIMIT 1`,
[assetId],
);
if (!refs[0]) {
return res.status(403).json({ error: 'Forbidden' });
}
const publication = refs[0];
const now = Date.now();
if (publication.access_mode === 'public') {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
return res.status(200).json({ ok: true });
}
if (
publication.access_mode === 'time_limited' &&
publication.expires_at &&
Number(publication.expires_at) > now
) {
res.set('Cache-Control', 'public, max-age=60');
return res.status(200).json({ ok: true });
}
return res.status(403).json({ error: 'Forbidden' });
} catch (error) {
console.error('[authorize-image]', error instanceof Error ? error.message : error);
return res.status(500).json({ error: 'Internal server error' });
}
});
api.get('/mindspace/v1/assets/:assetId/download', async (req, res) => {
if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return;
try {
@@ -2865,6 +3092,30 @@ api.post('/mindspace/v1/pages/:pageId/publish', async (req, res) => {
}
});
api.post('/mindspace/v1/publications/:publicationId/update-status', async (req, res) => {
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
const publication = await mindSpacePublications.updatePublicationStatus(
req.currentUser.id,
req.params.publicationId,
{
accessMode: req.body?.access_mode,
expiresAt: req.body?.expires_at,
},
);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
action: 'publication.status_updated',
objectType: 'publication',
objectId: req.params.publicationId,
ip: req.ip,
});
return sendData(res, req, publication);
} catch (error) {
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/publications/:publicationId/offline', async (req, res) => {
if (!mindSpacePublications) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
@@ -3290,9 +3541,11 @@ api.get('/sessions/:sessionId', async (req, res, next) => {
if (sessionSnapshotService?.isEnabled()) {
const messages = (gooseSession.conversation ?? [])
.filter((m) => m.metadata?.userVisible);
void sessionSnapshotService
.save(sessionId, req.currentUser.id, gooseSession, messages)
.catch(() => {});
if (messages.length > 0) {
void sessionSnapshotService
.save(sessionId, req.currentUser.id, gooseSession, messages)
.catch(() => {});
}
}
return res.json(gooseSession);
} catch (err) {