refactor: Business logic and dependencies updates
- 核心服务代码更新 (db, server, auth, proxy) - Agent 相关模块更新 (mindspace, experience) - 前端组件和 hooks 更新 - 数据库 schema 更新 - 依赖版本更新
This commit is contained in:
+257
-4
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user