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
+46 -3
View File
@@ -13,13 +13,27 @@ export function isDatabaseConfigured() {
);
}
// Pool size must scale with peak concurrent agent sessions: each session does
// several queries per turn (session node lookup + policy + provider + reconcile).
// The historical default of 10 starved at ~30 concurrent sessions. Tune via
// MYSQL_POOL_SIZE per portal instance × expected concurrency.
function resolvePoolOptions() {
const connectionLimit = Math.max(1, Number(process.env.MYSQL_POOL_SIZE ?? 50));
// queueLimit caps how many requests wait for a free connection before failing
// fast, instead of piling up unboundedly under a connection-starvation storm.
const queueLimit = Math.max(0, Number(process.env.MYSQL_POOL_QUEUE_LIMIT ?? 0));
return { waitForConnections: true, connectionLimit, queueLimit };
}
export function createDbPool() {
if (!isDatabaseConfigured()) {
throw new Error('MySQL 未配置,请设置 DATABASE_URL 或 MYSQL_* 环境变量');
}
const poolOptions = resolvePoolOptions();
if (process.env.DATABASE_URL) {
return mysql.createPool(process.env.DATABASE_URL);
return mysql.createPool({ uri: process.env.DATABASE_URL, ...poolOptions });
}
return mysql.createPool({
@@ -28,8 +42,7 @@ export function createDbPool() {
user: process.env.MYSQL_USER ?? 'boot',
password: process.env.MYSQL_PASSWORD ?? '',
database: process.env.MYSQL_DATABASE ?? 'tkmind',
waitForConnections: true,
connectionLimit: 10,
...poolOptions,
});
}
@@ -199,6 +212,26 @@ export async function migrateSchema(pool) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
// Shared experience store (etat C). Keep in sync with schema.sql.
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_experience (
id CHAR(36) PRIMARY KEY,
scope VARCHAR(64) NOT NULL DEFAULT 'global',
kind VARCHAR(32) NOT NULL DEFAULT 'lesson',
title VARCHAR(255) NOT NULL,
body MEDIUMTEXT NOT NULL,
tags_json JSON NULL,
source_session_id VARCHAR(128) NULL,
source_user_id CHAR(36) NULL,
use_count BIGINT NOT NULL DEFAULT 0,
embedding JSON NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_h5_experience_scope_updated (scope, updated_at),
FULLTEXT KEY ftx_h5_experience (title, body)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
const publishColumns = [
['plaza_view_count', 'BIGINT NOT NULL DEFAULT 0'],
['plaza_like_count', 'BIGINT NOT NULL DEFAULT 0'],
@@ -239,6 +272,16 @@ export async function migrateSchema(pool) {
);
}
// goosed_target stores the resolved upstream URL a session is pinned to, so
// routing survives reordering or resizing the goosed target list. The legacy
// integer goosed_node is kept as a fallback for rows written before this column
// existed (and for older bundles that still read it).
if (!(await columnExists(pool, 'h5_user_sessions', 'goosed_target'))) {
await pool.query(
`ALTER TABLE h5_user_sessions ADD COLUMN goosed_target VARCHAR(255) NULL AFTER goosed_node`,
);
}
const oauthStateColumns = [
['intent', "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"],
['bind_user_id', 'CHAR(36) NULL AFTER intent'],
+227
View File
@@ -0,0 +1,227 @@
import crypto from 'node:crypto';
/**
* PostgreSQL + pgvector backed experience store (etat C, polyglot mode).
*
* Same contract as the MySQL `createExperienceService` (record / search /
* reflect) so callers (mindspace-agent-runner) are backend-agnostic. The MySQL
* business DB is untouched; only this shared-experience workload lives on PG.
*
* Semantic search via pgvector is used when an `embed(text) => number[]` function
* is supplied; otherwise it degrades to keyword (ILIKE) + recency, identical in
* spirit to the MySQL version, so it is useful before an embedding provider is wired.
*
* The `pg` driver is imported dynamically so this module loads even when the
* dependency is not yet installed; it is only required once the service is built.
*
* @param {object} options
* @param {string} options.connectionString - e.g. process.env.EXPERIENCE_PG_URL
* @param {(text: string) => Promise<number[]>} [options.embed] - embedding fn
* @param {number} [options.embeddingDim=1536] - vector dimension (must match embed)
* @param {() => number} [options.now]
* @param {number} [options.recencyHalfLifeMs]
*/
export async function createPgExperienceService(options = {}) {
const connectionString = options.connectionString;
if (!connectionString) {
throw new Error('createPgExperienceService 需要 connectionString(建议用 EXPERIENCE_PG_URL');
}
const now = options.now ?? (() => Date.now());
const embed = options.embed ?? null;
const embeddingDim = Math.max(1, Number(options.embeddingDim ?? 1536));
const halfLifeMs = Math.max(
60_000,
Number(options.recencyHalfLifeMs ?? 30 * 24 * 60 * 60 * 1000),
);
const { default: pg } = await import('pg');
const pool = new pg.Pool({ connectionString, max: Number(options.poolMax ?? 10) });
// Idempotent setup. pgvector must be available on the server (CREATE EXTENSION).
await pool.query('CREATE EXTENSION IF NOT EXISTS vector');
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_experience (
id UUID PRIMARY KEY,
scope TEXT NOT NULL DEFAULT 'global',
kind TEXT NOT NULL DEFAULT 'lesson',
title TEXT NOT NULL,
body TEXT NOT NULL,
tags JSONB NOT NULL DEFAULT '[]'::jsonb,
source_session_id TEXT,
source_user_id TEXT,
use_count BIGINT NOT NULL DEFAULT 0,
embedding vector(${embeddingDim}),
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
)
`);
await pool.query(
'CREATE INDEX IF NOT EXISTS idx_h5_experience_scope_updated ON h5_experience (scope, updated_at DESC)',
);
// Trigram-ish keyword fallback index (btree on lower(title)) is cheap; the
// ANN vector index is created lazily once rows have embeddings.
await pool.query(
"CREATE INDEX IF NOT EXISTS idx_h5_experience_embedding ON h5_experience USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)",
).catch(() => {
// ivfflat needs the extension + may warn on empty table; non-fatal.
});
function normalizeTags(tags) {
if (!Array.isArray(tags)) return [];
return [...new Set(tags.map((t) => String(t).trim()).filter(Boolean))];
}
function toVectorLiteral(vec) {
// pgvector accepts a string like '[0.1,0.2,...]'
return `[${vec.map((n) => Number(n)).join(',')}]`;
}
function rowToExperience(row) {
return {
id: row.id,
scope: row.scope,
kind: row.kind,
title: row.title,
body: row.body,
tags: Array.isArray(row.tags) ? row.tags : [],
sourceSessionId: row.source_session_id ?? null,
sourceUserId: row.source_user_id ?? null,
useCount: Number(row.use_count ?? 0),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
async function record(input) {
const title = String(input?.title ?? '').trim();
const body = String(input?.body ?? '').trim();
if (!title) throw experienceError('经验标题不能为空', 'invalid_experience_input');
if (!body) throw experienceError('经验内容不能为空', 'invalid_experience_input');
const id = crypto.randomUUID();
const ts = now();
const scope = String(input?.scope ?? 'global').trim() || 'global';
const kind = String(input?.kind ?? 'lesson').trim() || 'lesson';
const tags = normalizeTags(input?.tags);
let embedding = null;
if (embed) {
try {
const vec = await embed(`${title}\n${body}`);
if (Array.isArray(vec) && vec.length === embeddingDim) embedding = toVectorLiteral(vec);
} catch {
embedding = null; // embedding failure must not block recording
}
}
await pool.query(
`INSERT INTO h5_experience
(id, scope, kind, title, body, tags, source_session_id, source_user_id,
use_count, embedding, created_at, updated_at)
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,0,$9::vector,$10,$11)`,
[
id,
scope,
kind,
title,
body,
JSON.stringify(tags),
input?.sourceSessionId ?? null,
input?.sourceUserId ?? null,
embedding,
ts,
ts,
],
);
return rowToExperience({
id,
scope,
kind,
title,
body,
tags,
source_session_id: input?.sourceSessionId ?? null,
source_user_id: input?.sourceUserId ?? null,
use_count: 0,
created_at: ts,
updated_at: ts,
});
}
async function search(query, { scope = 'global', limit = 5 } = {}) {
const text = String(query ?? '').trim();
if (!text) return [];
const max = Math.max(1, limit);
// Vector path: cosine distance ordering, recency as tiebreaker.
if (embed) {
try {
const vec = await embed(text);
if (Array.isArray(vec) && vec.length === embeddingDim) {
const { rows } = await pool.query(
`SELECT id, scope, kind, title, body, tags, source_session_id,
source_user_id, use_count, created_at, updated_at
FROM h5_experience
WHERE scope = $1 AND embedding IS NOT NULL
ORDER BY embedding <=> $2::vector, updated_at DESC
LIMIT $3`,
[scope, toVectorLiteral(vec), max],
);
if (rows.length > 0) {
await bumpUseCount(rows.map((r) => r.id));
return rows.map(rowToExperience);
}
}
} catch {
// fall through to keyword path
}
}
// Keyword fallback: ILIKE any term, ordered by recency.
const terms = tokenize(text);
if (terms.length === 0) return [];
const likeClauses = terms.map((_, i) => `(title ILIKE $${i + 2} OR body ILIKE $${i + 2})`);
const params = [scope, ...terms.map((t) => `%${t}%`), max];
const { rows } = await pool.query(
`SELECT id, scope, kind, title, body, tags, source_session_id,
source_user_id, use_count, created_at, updated_at
FROM h5_experience
WHERE scope = $1 AND (${likeClauses.join(' OR ')})
ORDER BY updated_at DESC
LIMIT $${terms.length + 2}`,
params,
);
if (rows.length > 0) await bumpUseCount(rows.map((r) => r.id));
return rows.map(rowToExperience);
}
async function bumpUseCount(ids) {
if (!ids.length) return;
await pool
.query('UPDATE h5_experience SET use_count = use_count + 1 WHERE id = ANY($1::uuid[])', [ids])
.catch(() => {});
}
function tokenize(query) {
return [
...new Set(
String(query ?? '')
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.map((t) => t.trim())
.filter((t) => t.length >= 2),
),
];
}
async function reflect() {
return { ok: false, reason: 'not_implemented' };
}
async function close() {
await pool.end();
}
return { record, search, reflect, close };
}
function experienceError(message, code) {
return Object.assign(new Error(message), { code });
}
+181
View File
@@ -0,0 +1,181 @@
import crypto from 'node:crypto';
/**
* Shared experience store (etat C of the goose scale plan).
*
* All goosed Worker instances read/write the same learned experience so that
* capability is not siloed per instance. This first implementation is backed by
* MySQL (`h5_experience`) with a keyword + recency ranking. The retrieval scoring
* is deliberately isolated in `rankRows` so that swapping to PostgreSQL + pgvector
* later only replaces the query/scoring layer, not the calling contract
* (record / search / reflect).
*
* @param {import('mysql2/promise').Pool} pool
* @param {object} [options]
* @param {() => number} [options.now] - injectable clock for tests
*/
export function createExperienceService(pool, options = {}) {
const now = options.now ?? (() => Date.now());
// Recency half-life: a record this old contributes half its keyword score.
const halfLifeMs = Math.max(
60_000,
Number(options.recencyHalfLifeMs ?? 30 * 24 * 60 * 60 * 1000),
);
function normalizeTags(tags) {
if (!Array.isArray(tags)) return [];
return [...new Set(tags.map((t) => String(t).trim()).filter(Boolean))];
}
function rowToExperience(row) {
return {
id: row.id,
scope: row.scope,
kind: row.kind,
title: row.title,
body: row.body,
tags: row.tags_json ? safeJsonArray(row.tags_json) : [],
sourceSessionId: row.source_session_id ?? null,
sourceUserId: row.source_user_id ?? null,
useCount: Number(row.use_count ?? 0),
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
function safeJsonArray(value) {
if (Array.isArray(value)) return value;
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
// Keyword overlap × recency decay. Kept separate so the pgvector backend can
// replace this with cosine similarity without touching search()'s shape.
function rankRows(rows, queryTerms, nowMs) {
const terms = queryTerms;
return rows
.map((row) => {
const haystack = `${row.title}\n${row.body}`.toLowerCase();
let keywordScore = 0;
for (const term of terms) {
if (haystack.includes(term)) keywordScore += 1;
}
const ageMs = Math.max(0, nowMs - Number(row.updated_at));
const recency = Math.pow(0.5, ageMs / halfLifeMs);
const score = keywordScore * recency;
return { row, score };
})
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score);
}
function tokenize(query) {
return [
...new Set(
String(query ?? '')
.toLowerCase()
.split(/[^\p{L}\p{N}]+/u)
.map((t) => t.trim())
.filter((t) => t.length >= 2),
),
];
}
/**
* Persist a piece of experience. Returns the stored record.
*/
async function record(input) {
const title = String(input?.title ?? '').trim();
const body = String(input?.body ?? '').trim();
if (!title) throw experienceError('经验标题不能为空', 'invalid_experience_input');
if (!body) throw experienceError('经验内容不能为空', 'invalid_experience_input');
const id = crypto.randomUUID();
const ts = now();
const scope = String(input?.scope ?? 'global').trim() || 'global';
const kind = String(input?.kind ?? 'lesson').trim() || 'lesson';
const tags = normalizeTags(input?.tags);
await pool.query(
`INSERT INTO h5_experience
(id, scope, kind, title, body, tags_json, source_session_id, source_user_id,
use_count, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
[
id,
scope,
kind,
title,
body,
JSON.stringify(tags),
input?.sourceSessionId ?? null,
input?.sourceUserId ?? null,
ts,
ts,
],
);
return rowToExperience({
id,
scope,
kind,
title,
body,
tags_json: tags,
source_session_id: input?.sourceSessionId ?? null,
source_user_id: input?.sourceUserId ?? null,
use_count: 0,
created_at: ts,
updated_at: ts,
});
}
/**
* Retrieve the most relevant experience for a query within a scope.
* @returns {Promise<Array>} ranked experiences (best first), max `limit`.
*/
async function search(query, { scope = 'global', limit = 5 } = {}) {
const terms = tokenize(query);
if (terms.length === 0) return [];
// Pull a bounded candidate set by scope+recency, then rank in JS. The
// candidate cap keeps this cheap on MySQL; pgvector would push ranking into SQL.
const [rows] = await pool.query(
`SELECT id, scope, kind, title, body, tags_json, source_session_id,
source_user_id, use_count, created_at, updated_at
FROM h5_experience
WHERE scope = ?
ORDER BY updated_at DESC
LIMIT 500`,
[scope],
);
const ranked = rankRows(rows, terms, now()).slice(0, Math.max(1, limit));
if (ranked.length > 0) {
// Best-effort usage bump so popular experience can be surfaced/weighted later.
const ids = ranked.map((entry) => entry.row.id);
const placeholders = ids.map(() => '?').join(',');
await pool
.query(
`UPDATE h5_experience SET use_count = use_count + 1 WHERE id IN (${placeholders})`,
ids,
)
.catch(() => {});
}
return ranked.map((entry) => rowToExperience(entry.row));
}
/**
* Placeholder for the reflection pass that distills raw session traces into
* durable lessons. Wired later once the record() trigger points are defined;
* kept here so the calling contract is stable.
*/
async function reflect() {
return { ok: false, reason: 'not_implemented' };
}
return { record, search, reflect };
}
function experienceError(message, code) {
return Object.assign(new Error(message), { code });
}
+114
View File
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createExperienceService } from './experience-service.mjs';
// Minimal in-memory pool covering the SQL shapes experience-service issues:
// INSERT, scope-filtered SELECT, and the use_count bump UPDATE.
function createMockPool() {
const rows = [];
const query = async (sql, params = []) => {
if (sql.includes('INSERT INTO h5_experience')) {
const [
id,
scope,
kind,
title,
body,
tagsJson,
sourceSessionId,
sourceUserId,
createdAt,
updatedAt,
] = params;
rows.push({
id,
scope,
kind,
title,
body,
tags_json: tagsJson,
source_session_id: sourceSessionId,
source_user_id: sourceUserId,
use_count: 0,
created_at: createdAt,
updated_at: updatedAt,
});
return [{ affectedRows: 1 }];
}
if (sql.includes('FROM h5_experience') && sql.includes('WHERE scope = ?')) {
const [scope] = params;
const matched = rows
.filter((r) => r.scope === scope)
.sort((a, b) => b.updated_at - a.updated_at);
return [matched.map((r) => ({ ...r }))];
}
if (sql.includes('UPDATE h5_experience SET use_count')) {
for (const id of params) {
const row = rows.find((r) => r.id === id);
if (row) row.use_count += 1;
}
return [{ affectedRows: params.length }];
}
throw new Error(`unexpected SQL: ${sql}`);
};
return { pool: { query }, rows };
}
test('record persists an experience with normalized fields', async () => {
const { pool, rows } = createMockPool();
const svc = createExperienceService(pool, { now: () => 1000 });
const saved = await svc.record({
title: 'Headscale 部署',
body: '在 103 上用固定公网 IP 部署 headscale,注意防火墙放行 41641/udp。',
tags: ['headscale', 'headscale', ' 网络 ', ''],
});
assert.equal(rows.length, 1);
assert.equal(saved.scope, 'global');
assert.equal(saved.kind, 'lesson');
assert.deepEqual(saved.tags, ['headscale', '网络']);
assert.equal(saved.useCount, 0);
});
test('record rejects empty title or body', async () => {
const { pool } = createMockPool();
const svc = createExperienceService(pool);
await assert.rejects(() => svc.record({ title: '', body: 'x' }), /标题不能为空/);
await assert.rejects(() => svc.record({ title: 'x', body: '' }), /内容不能为空/);
});
test('search ranks keyword matches and ignores empty queries', async () => {
const { pool } = createMockPool();
const svc = createExperienceService(pool, { now: () => 1_000_000 });
await svc.record({ title: 'Headscale 部署', body: 'headscale 防火墙 udp' });
await svc.record({ title: '无关经验', body: '这条跟查询完全无关的内容' });
assert.deepEqual(await svc.search(' '), []);
const hits = await svc.search('headscale 部署');
assert.equal(hits.length, 1);
assert.equal(hits[0].title, 'Headscale 部署');
});
test('search applies recency decay so newer wins on equal keyword score', async () => {
const { pool } = createMockPool();
let clock = 0;
const svc = createExperienceService(pool, {
now: () => clock,
recencyHalfLifeMs: 1000,
});
clock = 0;
await svc.record({ title: '部署指南 A', body: 'deploy 部署 指南' });
clock = 10_000; // 10 half-lives newer
await svc.record({ title: '部署指南 B', body: 'deploy 部署 指南' });
clock = 10_000;
const hits = await svc.search('部署 deploy 指南', { limit: 2 });
assert.equal(hits[0].title, '部署指南 B');
});
test('search bumps use_count for returned rows', async () => {
const { pool, rows } = createMockPool();
const svc = createExperienceService(pool, { now: () => 5 });
await svc.record({ title: 'Caddy 灰度', body: 'caddy weighted_round_robin 分流' });
await svc.search('caddy 灰度');
assert.equal(rows[0].use_count, 1);
});
+116 -51
View File
@@ -442,6 +442,96 @@ export function createAgentJobService(pool, options = {}) {
}
};
// Marks a locked, queued job row as running and returns the claim payload.
// Caller must hold a row lock on `job` inside `conn`'s open transaction; this
// commits the transaction.
const finalizeClaim = async (conn, job) => {
const jobId = job.id;
const token = crypto.randomBytes(24).toString('base64url');
const now = nowFactory();
await conn.query(
`UPDATE h5_agent_jobs
SET status = 'running', started_at = COALESCE(started_at, ?), heartbeat_at = ?,
updated_at = ?, expires_at = ?, progress_json = ?, job_token_hash = ?
WHERE id = ?`,
[
now,
now,
now,
now + tokenTtlMs,
JSON.stringify({ stage: 'preparing_files' }),
hashJobToken(token),
jobId,
],
);
await conn.commit();
const assets = await fetchJobAssets(jobId);
const permissionScope = jsonValue(job.permission_scope, {});
const userContext = jsonValue(job.user_context_json, {
locale: 'zh-CN',
timezone: 'Asia/Shanghai',
});
return {
jobId,
userId: job.user_id,
jobToken: token,
instruction: job.instruction,
userContext,
allowedAssets: assets.map((asset) => ({
assetId: asset.assetId,
versionId: asset.assetVersionId,
permission: asset.permission,
displayName: asset.displayName,
mimeType: asset.mimeType,
downloadEndpoint: `/api/internal/agent/jobs/${jobId}/assets/${asset.assetId}`,
})),
output: {
categoryId: job.output_category_id,
categoryCode: job.output_category_code,
allowedTypes: [job.output_type],
maxBytes: asNumber(job.max_output_bytes),
},
capabilities: permissionScope.capabilities ?? {
network: false,
shell: false,
createPage: true,
},
expiresAt: now + tokenTtlMs,
};
};
// Atomically claim the oldest queued, unexpired job. Uses SKIP LOCKED so that
// multiple worker loops (across portal instances) pull distinct jobs without
// blocking each other. Returns null when the queue is empty.
const claimNextJob = async () => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT j.*, c.category_code AS output_category_code
FROM h5_agent_jobs j
JOIN h5_space_categories c ON c.id = j.output_category_id
WHERE j.status = 'queued'
AND (j.expires_at IS NULL OR j.expires_at >= ?)
ORDER BY j.queued_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED`,
[nowFactory()],
);
const job = rows[0];
if (!job) {
await conn.commit();
return null;
}
return await finalizeClaim(conn, job);
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const claimJob = async (jobId) => {
const conn = await pool.getConnection();
try {
@@ -463,57 +553,7 @@ export function createAgentJobService(pool, options = {}) {
if (job.expires_at != null && asNumber(job.expires_at) < nowFactory()) {
throw agentJobError('任务已过期', 'agent_job_expired');
}
const token = crypto.randomBytes(24).toString('base64url');
const now = nowFactory();
await conn.query(
`UPDATE h5_agent_jobs
SET status = 'running', started_at = COALESCE(started_at, ?), heartbeat_at = ?,
updated_at = ?, expires_at = ?, progress_json = ?, job_token_hash = ?
WHERE id = ?`,
[
now,
now,
now,
now + tokenTtlMs,
JSON.stringify({ stage: 'preparing_files' }),
hashJobToken(token),
jobId,
],
);
await conn.commit();
const assets = await fetchJobAssets(jobId);
const permissionScope = jsonValue(job.permission_scope, {});
const userContext = jsonValue(job.user_context_json, {
locale: 'zh-CN',
timezone: 'Asia/Shanghai',
});
return {
jobId,
userId: job.user_id,
jobToken: token,
instruction: job.instruction,
userContext,
allowedAssets: assets.map((asset) => ({
assetId: asset.assetId,
versionId: asset.assetVersionId,
permission: asset.permission,
displayName: asset.displayName,
mimeType: asset.mimeType,
downloadEndpoint: `/api/internal/agent/jobs/${jobId}/assets/${asset.assetId}`,
})),
output: {
categoryId: job.output_category_id,
categoryCode: job.output_category_code,
allowedTypes: [job.output_type],
maxBytes: asNumber(job.max_output_bytes),
},
capabilities: permissionScope.capabilities ?? {
network: false,
shell: false,
createPage: true,
},
expiresAt: now + tokenTtlMs,
};
return await finalizeClaim(conn, job);
} catch (error) {
await conn.rollback();
throw error;
@@ -522,6 +562,7 @@ export function createAgentJobService(pool, options = {}) {
}
};
const requireJobToken = async (jobId, token) => {
const [rows] = await pool.query(
`SELECT j.*, c.category_code AS output_category_code
@@ -648,6 +689,28 @@ export function createAgentJobService(pool, options = {}) {
return getJob(job.user_id, jobId);
};
// Restart resilience: a job left in 'running' by a crashed/restarted worker
// stops heartbeating. Mark such jobs failed with the retryable worker_crashed
// code so they surface to the user (and can be retried) instead of being stuck
// forever. We fail rather than auto-requeue to avoid poison-job loops, since
// there is no per-job attempt counter.
const reapStaleJobs = async (maxStaleMs = 5 * 60 * 1000) => {
const now = nowFactory();
const cutoff = now - Math.max(1, maxStaleMs);
const [result] = await pool.query(
`UPDATE h5_agent_jobs
SET status = 'failed', error_code = 'worker_crashed',
error_message = 'worker heartbeat timed out',
completed_at = ?, updated_at = ?, heartbeat_at = NULL, job_token_hash = NULL,
progress_json = ?
WHERE status = 'running'
AND heartbeat_at IS NOT NULL
AND heartbeat_at < ?`,
[now, now, JSON.stringify({ stage: 'failed' }), cutoff],
);
return result?.affectedRows ?? 0;
};
return {
createJob,
getJob,
@@ -655,6 +718,8 @@ export function createAgentJobService(pool, options = {}) {
cancelJob,
retryJob,
claimJob,
claimNextJob,
reapStaleJobs,
getAssetForJob,
heartbeat,
completeJob,
+49 -4
View File
@@ -305,6 +305,7 @@ export function createMindSpaceAgentRunner({
apiSecret,
userAuth,
agentJobService,
experienceService = null,
executeSessionReply = defaultExecuteSessionReply,
apiFetchImpl = null,
}) {
@@ -324,11 +325,15 @@ export function createMindSpaceAgentRunner({
});
});
const runJob = async (jobId) => {
// preClaimed lets a polling worker that already atomically claimed the job
// (via claimNextJob + SKIP LOCKED) hand the claim payload straight in, instead
// of claiming a second time. The HTTP-triggered path calls runJob(jobId) with
// no preClaimed and claims here as before.
const runJob = async (jobId, preClaimed = null) => {
let claim = null;
let sessionId = null;
try {
claim = await agentJobService.claimJob(jobId);
claim = preClaimed ?? (await agentJobService.claimJob(jobId));
const gate = await userAuth.canUseChat(claim.userId);
if (!gate.ok) {
throw runnerError(gate.message || '当前用户无法执行 Agent 任务', 'worker_unavailable');
@@ -377,7 +382,11 @@ export function createMindSpaceAgentRunner({
const localAsset = await agentJobService.getAssetForJob(jobId, claim.jobToken, asset.assetId);
assetContexts.push(await readAssetContext(localAsset));
}
const prompt = buildAgentJobPrompt(claim, assetContexts);
let prompt = buildAgentJobPrompt(claim, assetContexts);
// Inject shared experience (etat C): relevant lessons all instances learned.
// Best-effort — retrieval must never block or fail a job.
const relevant = await retrieveExperience(claim.instruction);
if (relevant) prompt = `${relevant}\n\n${prompt}`;
const requestId = crypto.randomUUID();
const reply = await executeSessionReply(apiFetch, sessionId, requestId, prompt);
const parsed = normalizeStructuredResult(extractJsonObject(reply.text));
@@ -386,7 +395,7 @@ export function createMindSpaceAgentRunner({
await userAuth.billSessionUsage(claim.userId, sessionId, reply.tokenState, requestId);
}
return agentJobService.completeJob(jobId, claim.jobToken, {
const completed = await agentJobService.completeJob(jobId, claim.jobToken, {
title: parsed.title,
summary: parsed.summary,
content: parsed.content,
@@ -395,6 +404,9 @@ export function createMindSpaceAgentRunner({
templateId: parsed.templateId,
sourceAssetIds: claim.allowedAssets.map((asset) => asset.assetId),
});
// Record the outcome as shared experience so other instances benefit.
await recordExperience(claim, sessionId, parsed);
return completed;
} catch (error) {
if (claim?.jobToken) {
await agentJobService
@@ -409,6 +421,39 @@ export function createMindSpaceAgentRunner({
}
};
// Returns a prompt-ready block of relevant prior experience, or '' if none /
// disabled / on any error. Never throws.
async function retrieveExperience(instruction) {
if (!experienceService || !instruction) return '';
try {
const hits = await experienceService.search(instruction, { limit: 3 });
if (!hits.length) return '';
const lines = hits.map((h) => `- ${h.title}: ${h.body}`).join('\n');
return `# 相关经验(供参考,来自历史任务)\n${lines}`;
} catch {
return '';
}
}
// Persists a one-line lesson from a completed job. Best-effort; never throws.
async function recordExperience(claim, sessionId, parsed) {
if (!experienceService) return;
try {
const title = String(parsed?.title ?? claim.instruction ?? '').slice(0, 200);
const summary = String(parsed?.summary ?? '').trim();
if (!title || !summary) return;
await experienceService.record({
kind: 'task_outcome',
title,
body: summary,
sourceSessionId: sessionId,
sourceUserId: claim.userId,
});
} catch {
// swallow — experience recording is non-critical
}
}
return { runJob };
}
+52 -1
View File
@@ -32,7 +32,7 @@ const ALLOWED_EXTENSIONS = new Map([
['.html', 'text/html'],
['.htm', 'text/html'],
]);
const MAX_IMAGE_UPLOAD_BYTES = 1536 * 1024;
const MAX_IMAGE_UPLOAD_BYTES = 2 * 1024 * 1024;
const PUBLIC_TEMP_IMAGE_DIR = '.tmp-images';
const PUBLIC_IMAGE_EXTENSIONS = new Map([
@@ -65,6 +65,54 @@ function expectedMimeType(filename) {
return ALLOWED_EXTENSIONS.get(path.extname(filename).toLowerCase()) ?? null;
}
function extractImageDimensions(buffer, mimeType) {
if (mimeType === 'image/png' && buffer.length >= 24) {
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
return { width, height };
}
if (mimeType === 'image/jpeg' && buffer.length >= 2) {
let offset = 2;
while (offset < buffer.length - 9) {
if (buffer[offset] !== 0xff) break;
const marker = buffer[offset + 1];
if (marker === 0xd9) break;
if (marker >= 0xd0 && marker <= 0xd8) {
offset += 2;
continue;
}
const segmentLength = buffer.readUInt16BE(offset + 2);
if (marker === 0xc0 || marker === 0xc1 || marker === 0xc2) {
const height = buffer.readUInt16BE(offset + 5);
const width = buffer.readUInt16BE(offset + 7);
return { width, height };
}
offset += segmentLength + 2;
}
}
if (mimeType === 'image/webp' && buffer.length >= 30) {
const width = buffer.readUInt32LE(24) + 1;
const height = buffer.readUInt32LE(28) + 1;
return { width, height };
}
return null;
}
function validateImagePixels(buffer, mimeType) {
if (!mimeType?.startsWith('image/')) return null;
const dims = extractImageDimensions(buffer, mimeType);
if (!dims) return { ok: true };
const { width, height } = dims;
const megapixels = (width * height) / 1_000_000;
if (megapixels > 25) {
throw Object.assign(
new Error(`图片像素超过限制(${megapixels.toFixed(1)}MP > 25MP`),
{ code: 'image_pixel_exceeded' }
);
}
return { ok: true, width, height };
}
function detectMimeType(buffer, filename) {
const head = buffer.subarray(0, 256).toString('utf8').trimStart().toLowerCase();
if (head.startsWith('<!doctype html') || head.startsWith('<html')) return 'text/html';
@@ -386,6 +434,9 @@ export function createAssetService(pool, options = {}) {
if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) {
throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' });
}
if (detectedMimeType.startsWith('image/')) {
validateImagePixels(buffer, detectedMimeType);
}
const target = absoluteStoragePath(upload.temporary_storage_key);
await fs.mkdir(path.dirname(target), { recursive: true });
+110 -6
View File
@@ -6,6 +6,7 @@ import { replacePrivateResourceReferences, scanContent } from './mindspace-conte
import { pageInternals } from './mindspace-pages.mjs';
import { loadMindSpaceConfig } from './mindspace-config.mjs';
import { resolvePublicBaseUrl } from './user-publish.mjs';
import { createImgproxySigner } from './imgproxy-signer.mjs';
const SCANNER_VERSION = 'mindspace-content-v1';
const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN =
@@ -159,7 +160,13 @@ function buildPublicationThumbnailFallback(ownerSlug, urlSlug) {
return `/u/${encodeURIComponent(ownerSlug)}/pages/${encodeURIComponent(urlSlug)}.thumbnail.png`;
}
async function localizePrivateImageReferences({ pool, userId, html, absoluteStoragePath }) {
async function localizePrivateImageReferences({
pool,
userId,
html,
absoluteStoragePath,
imgproxySigner = null,
}) {
const source = String(html ?? '');
const matches = [...source.matchAll(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN)];
if (matches.length === 0) return source;
@@ -181,9 +188,14 @@ async function localizePrivateImageReferences({ pool, userId, html, absoluteStor
for (const assetId of assetIds) {
const asset = byId.get(assetId);
if (!asset) continue;
const mimeType = String(asset.mime_type || 'application/octet-stream');
const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
replacements.set(assetId, `data:${mimeType};base64,${buffer.toString('base64')}`);
if (imgproxySigner) {
replacements.set(assetId, imgproxySigner.buildUrl(imgproxySigner.baseUrl, asset.storage_key, 'display'));
} else {
const mimeType = String(asset.mime_type || 'application/octet-stream');
const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
replacements.set(assetId, `data:${mimeType};base64,${buffer.toString('base64')}`);
}
}
if (replacements.size === 0) return source;
@@ -199,6 +211,7 @@ async function prepareHtmlPublishContent({
ownerSlug,
urlSlug,
absoluteStoragePath,
imgproxySigner = null,
}) {
let publishContent = (await localizeGoogleFontsCss(html)).html;
publishContent = await localizePrivateImageReferences({
@@ -206,6 +219,7 @@ async function prepareHtmlPublishContent({
userId,
html: publishContent,
absoluteStoragePath,
imgproxySigner,
});
return replacePrivateResourceReferences(
publishContent,
@@ -217,6 +231,21 @@ export function createPublicationService(pool, options = {}) {
const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
const idFactory = options.idFactory ?? (() => crypto.randomUUID());
const publicPageLimitFallback = Number(options.publicPageLimit ?? 5);
let imgproxySigner = null;
if (process.env.IMGPROXY_BASE_URL && process.env.IMGPROXY_SIGNING_KEY && process.env.IMGPROXY_SIGNING_SALT) {
try {
imgproxySigner = createImgproxySigner(
process.env.IMGPROXY_SIGNING_KEY,
process.env.IMGPROXY_SIGNING_SALT,
);
imgproxySigner.baseUrl = process.env.IMGPROXY_BASE_URL;
console.log('[Publication] imgproxy signer initialized');
} catch (err) {
console.warn('[Publication] imgproxy signer init failed:', err instanceof Error ? err.message : err);
}
}
const resolvePublicPageLimit = async () => {
try {
const config = await loadMindSpaceConfig(pool);
@@ -309,6 +338,7 @@ export function createPublicationService(pool, options = {}) {
ownerSlug,
urlSlug,
absoluteStoragePath,
imgproxySigner: imgproxySigner ? { buildUrl: (path, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path, preset) } : null,
});
};
@@ -549,9 +579,9 @@ export function createPublicationService(pool, options = {}) {
await conn.query(
`INSERT INTO h5_publish_records
(id, user_id, page_id, page_version_id, publish_type, url_slug, public_url,
access_mode, password_hash, token_hash, token_prefix, expires_at, published_at,
access_mode, password_hash, token_hash, token_prefix, expires_at, user_confirmed_at, published_at,
status, view_count, security_scan_id, created_at, updated_at)
VALUES (?, ?, ?, ?, 'page', ?, ?, ?, ?, ?, ?, ?, ?, 'online', 0, ?, ?, ?)`,
VALUES (?, ?, ?, ?, 'page', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'online', 0, ?, ?, ?)`,
[
publishId,
userId,
@@ -564,6 +594,7 @@ export function createPublicationService(pool, options = {}) {
tokenHash,
privateToken?.slice(0, 8) ?? null,
result.expiresAt,
null,
now,
scanId,
now,
@@ -605,6 +636,23 @@ export function createPublicationService(pool, options = {}) {
WHERE id = ? AND user_id = ?`,
[htmlBytes, now, page.space_id, userId],
);
const assetUrlPattern = /\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)/gi;
const assetIds = new Set();
let match;
while ((match = assetUrlPattern.exec(html)) !== null) {
assetIds.add(match[1]);
}
if (assetIds.size > 0) {
const refValues = Array.from(assetIds).map((assetId) => [publishId, assetId, now]);
await conn.query(
`INSERT IGNORE INTO h5_publication_asset_refs
(publication_id, asset_id, created_at) VALUES ` +
refValues.map(() => '(?, ?, ?)').join(','),
refValues.flat(),
);
}
await conn.commit();
return {
...publicationResponse({
@@ -642,6 +690,46 @@ export function createPublicationService(pool, options = {}) {
return publicationResponse(rows[0]);
};
const updatePublicationStatus = async (userId, publicationId, { accessMode, expiresAt }) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT * FROM h5_publish_records
WHERE id = ? AND user_id = ? AND status = 'online' LIMIT 1 FOR UPDATE`,
[publicationId, userId],
);
const publication = rows[0];
if (!publication) throw publicationError('发布记录不存在', 'publication_not_found');
const normalizedMode = normalizeAccessMode(accessMode);
const normalizedExpiresAt = normalizeExpiresAt(expiresAt, normalizedMode === 'time_limited');
const now = Date.now();
await conn.query(
`UPDATE h5_publish_records
SET access_mode = ?, expires_at = ?, user_confirmed_at = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[normalizedMode, normalizedExpiresAt, now, now, publicationId, userId],
);
await conn.commit();
const updated = {
...publication,
access_mode: normalizedMode,
expires_at: normalizedExpiresAt,
user_confirmed_at: now,
updated_at: now,
};
return publicationResponse(updated);
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const offline = async (userId, publicationId) => {
const conn = await pool.getConnection();
try {
@@ -832,6 +920,20 @@ export function createPublicationService(pool, options = {}) {
return publicHomepageResponse(owner, rows);
};
const cleanupExpiredUnconfirmedPublications = async (now = Date.now()) => {
const [result] = await pool.query(
`UPDATE h5_publish_records
SET access_mode = 'private', expires_at = NULL, updated_at = ?
WHERE access_mode = 'public'
AND expires_at IS NOT NULL
AND expires_at <= ?
AND user_confirmed_at IS NULL
AND status = 'online'`,
[now, now],
);
return { cleaned: result.affectedRows };
};
return {
check,
publish,
@@ -839,8 +941,10 @@ export function createPublicationService(pool, options = {}) {
getPublicHomepage,
getStats,
offline,
updatePublicationStatus,
resolvePublic,
resolvePrivateLink,
cleanupExpiredUnconfirmedPublications,
};
}
+1
View File
@@ -57,6 +57,7 @@
"http-proxy-middleware": "^3.0.3",
"jsonrepair": "^3.14.0",
"mysql2": "^3.22.5",
"pg": "^8.22.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+34
View File
@@ -223,6 +223,7 @@ CREATE TABLE IF NOT EXISTS h5_publish_records (
token_hash CHAR(64) NULL,
token_prefix VARCHAR(16) NULL,
expires_at BIGINT NULL,
user_confirmed_at BIGINT NULL,
published_at BIGINT NOT NULL,
offline_at BIGINT NULL,
status ENUM('draft', 'online', 'expired', 'offline', 'blocked') NOT NULL DEFAULT 'online',
@@ -232,6 +233,7 @@ CREATE TABLE IF NOT EXISTS h5_publish_records (
updated_at BIGINT NOT NULL,
KEY idx_h5_publish_route (url_slug, status, published_at),
KEY idx_h5_publish_page (user_id, page_id, published_at),
KEY idx_h5_publish_auto_private (expires_at, user_confirmed_at) WHERE access_mode = 'public' AND user_confirmed_at IS NULL,
UNIQUE KEY uq_h5_publish_token_hash (token_hash),
CONSTRAINT fk_h5_publish_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_publish_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE CASCADE,
@@ -239,6 +241,17 @@ CREATE TABLE IF NOT EXISTS h5_publish_records (
CONSTRAINT fk_h5_publish_scan FOREIGN KEY (security_scan_id) REFERENCES h5_security_scans(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_publication_asset_refs (
publication_id CHAR(36) NOT NULL,
asset_id CHAR(36) NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (publication_id, asset_id),
KEY idx_asset_id (asset_id),
KEY idx_created_at (created_at),
CONSTRAINT fk_h5_pub_asset_ref_pub FOREIGN KEY (publication_id) REFERENCES h5_publish_records(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_pub_asset_ref_asset FOREIGN KEY (asset_id) REFERENCES h5_assets(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_publication_events (
id CHAR(36) PRIMARY KEY,
publish_id CHAR(36) NOT NULL,
@@ -997,3 +1010,24 @@ CREATE TABLE IF NOT EXISTS h5_blocked_words (
updated_at BIGINT NOT NULL,
UNIQUE KEY uk_blocked_word (word)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Shared experience store (etat C): all goosed Worker instances read/write the
-- same learned experience so capability is not siloed per instance. Built on
-- MySQL first with a keyword + recency retrieval; `embedding` is reserved for a
-- later move to PostgreSQL + pgvector without changing the calling contract.
CREATE TABLE IF NOT EXISTS h5_experience (
id CHAR(36) PRIMARY KEY,
scope VARCHAR(64) NOT NULL DEFAULT 'global',
kind VARCHAR(32) NOT NULL DEFAULT 'lesson',
title VARCHAR(255) NOT NULL,
body MEDIUMTEXT NOT NULL,
tags_json JSON NULL,
source_session_id VARCHAR(128) NULL,
source_user_id CHAR(36) NULL,
use_count BIGINT NOT NULL DEFAULT 0,
embedding JSON NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_h5_experience_scope_updated (scope, updated_at),
FULLTEXT KEY ftx_h5_experience (title, body)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+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) {
+3
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { checkAuth, logout, setUnauthorizedHandler } from './api/client';
import { clearAllStoredSessionIds } from './utils/sessionStorage';
import { loadBlockedWords } from './utils/wordFilter';
import { AuthView } from './components/AuthView';
import { ChatView } from './components/ChatView';
@@ -51,6 +52,7 @@ function AuthenticatedApp({
const navigate = useNavigate();
const handleLogout = () => {
clearAllStoredSessionIds();
void logout().finally(() => {
onLogout();
navigate('/', { replace: true });
@@ -191,6 +193,7 @@ export function App() {
grantedSkills={grantedSkills}
onUserUpdate={setUser}
onLogout={() => {
clearAllStoredSessionIds();
setAuthed(false);
setUser(null);
}}
+30 -6
View File
@@ -995,6 +995,26 @@ export async function publishMindSpacePage(
return result.data;
}
export async function updatePublicationStatus(
publicationId: string,
input: {
accessMode: MindSpacePublishCheck['accessMode'];
expiresAt?: number | null;
},
): Promise<MindSpacePublication> {
const result = await apiFetch<{ data: MindSpacePublication }>(
`/mindspace/v1/publications/${encodeURIComponent(publicationId)}/update-status`,
{
method: 'POST',
body: JSON.stringify({
access_mode: input.accessMode,
expires_at: input.expiresAt,
}),
},
);
return result.data;
}
export async function redactMindSpacePage(
pageId: string,
input: {
@@ -1967,12 +1987,16 @@ export async function listSessions(): Promise<Session[]> {
return result.sessions ?? [];
}
function sessionPath(sessionId: string, suffix = '') {
return `/sessions/${encodeURIComponent(sessionId)}${suffix}`;
}
export async function getSession(sessionId: string): Promise<Session> {
return apiFetch<Session>(`/sessions/${sessionId}`);
return apiFetch<Session>(sessionPath(sessionId));
}
export async function deleteChatSession(sessionId: string): Promise<void> {
await apiFetch(`/sessions/${sessionId}`, { method: 'DELETE' });
await apiFetch(sessionPath(sessionId), { method: 'DELETE' });
}
export async function loadSessionDetail(
@@ -1986,7 +2010,7 @@ export async function loadSessionDetail(
if (hints?.messageCount != null) params.set('hint_mc', String(hints.messageCount));
if (hints?.updatedAt) params.set('hint_ua', hints.updatedAt);
const qs = params.size ? `?${params.toString()}` : '';
const detail = await apiFetch<Session>(`/sessions/${sessionId}${qs}`);
const detail = await apiFetch<Session>(`${sessionPath(sessionId)}${qs}`);
const messages = normalizeConversationMessages(
(detail.conversation ?? []).filter((m) => m.metadata?.userVisible),
);
@@ -1998,7 +2022,7 @@ export async function sendReply(
requestId: string,
userMessage: Message,
): Promise<void> {
await apiFetch(`/sessions/${sessionId}/reply`, {
await apiFetch(`${sessionPath(sessionId)}/reply`, {
method: 'POST',
body: JSON.stringify({
request_id: requestId,
@@ -2008,7 +2032,7 @@ export async function sendReply(
}
export async function cancelRequest(sessionId: string, requestId: string): Promise<void> {
await apiFetch(`/sessions/${sessionId}/cancel`, {
await apiFetch(`${sessionPath(sessionId)}/cancel`, {
method: 'POST',
body: JSON.stringify({ request_id: requestId }),
});
@@ -2053,7 +2077,7 @@ export function subscribeSessionEvents(
const headers: Record<string, string> = { Accept: 'text/event-stream' };
if (lastEventId) headers['Last-Event-ID'] = lastEventId;
const res = await fetch(`${API}/sessions/${sessionId}/events`, {
const res = await fetch(`${API}${sessionPath(sessionId)}/events`, {
headers,
signal: controller.signal,
});
+77 -1
View File
@@ -13,6 +13,7 @@ import {
publishPageToPlaza,
redactMindSpacePage,
updateMindSpacePage,
updatePublicationStatus,
} from '../api/client';
import type {
MindSpacePage,
@@ -288,6 +289,8 @@ export function MindSpacePageDetail({
pageTitle: string;
pushedToPlaza: boolean;
} | null>(null);
const [confirmPublicationStatusOpen, setConfirmPublicationStatusOpen] = useState(false);
const [statusConfirming, setStatusConfirming] = useState(false);
const applyPageRecord = useCallback(
(next: MindSpacePage, options?: { recordHistory?: boolean }) => {
@@ -334,6 +337,13 @@ export function MindSpacePageDetail({
? await getMindSpacePublicationStats(next.publication.id).catch(() => null)
: null,
);
if (
next.publication &&
next.publication.accessMode === 'public' &&
next.publication.userConfirmedAt === null
) {
setConfirmPublicationStatusOpen(true);
}
} catch (err) {
setError(err instanceof Error ? err.message : '页面加载失败');
} finally {
@@ -434,6 +444,34 @@ export function MindSpacePageDetail({
}
};
const confirmPublicationStatus = async (nextAccessMode: AccessMode) => {
if (!page || !page.publication) return;
setStatusConfirming(true);
try {
const nextExpiresAt =
nextAccessMode === 'public'
? null
: nextAccessMode === 'time_limited'
? Date.now() + 30 * 60 * 1000
: null;
const updated = await updatePublicationStatus(page.publication.id, {
accessMode: nextAccessMode,
expiresAt: nextExpiresAt,
});
setPage({ ...page, publication: updated });
setAccessMode(updated.accessMode);
setExpiresAt(
updated.expiresAt ? localDateTimeValue(updated.expiresAt) : '',
);
setConfirmPublicationStatusOpen(false);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : '更新发布状态失败');
} finally {
setStatusConfirming(false);
}
};
const offlineFromSuccess = async () => {
if (!publishSuccess) return;
setPublishing(true);
@@ -1395,7 +1433,13 @@ export function MindSpacePageDetail({
))}
</section>
</section>
{sharePayload ? <ShareSheet payload={sharePayload} onClose={() => setSharePayload(null)} /> : null}
{sharePayload ? (
<ShareSheet
payload={sharePayload}
publication={page?.publication}
onClose={() => setSharePayload(null)}
/>
) : null}
{fullscreenPreviewOpen && page && page.contentFormat === 'html' ? (
<MindSpacePageFullscreenPreview
@@ -1414,6 +1458,38 @@ export function MindSpacePageDetail({
onContentChange={handlePreviewContentChange}
/>
) : null}
{confirmPublicationStatusOpen && page?.publication ? (
<MindSpaceModal className="mindspace-confirm-publication-status-modal">
<div className="mindspace-modal-content">
<h3></h3>
<p> 30 </p>
<div className="mindspace-modal-actions">
<button
className="mindspace-secondary"
disabled={statusConfirming}
onClick={() => confirmPublicationStatus('private')}
>
{statusConfirming ? '处理中...' : '改为私有'}
</button>
<button
className="mindspace-secondary"
disabled={statusConfirming}
onClick={() => confirmPublicationStatus('time_limited')}
>
{statusConfirming ? '处理中...' : '再预览 30 分钟'}
</button>
<button
className="mindspace-primary"
disabled={statusConfirming}
onClick={() => confirmPublicationStatus('public')}
>
{statusConfirming ? '处理中...' : '发布为永久公开'}
</button>
</div>
</div>
</MindSpaceModal>
) : null}
</>
);
}
+35 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import type { MindSpacePublication } from '../types';
import {
SHARE_CHANNELS,
canUseNativeShare,
@@ -10,13 +11,37 @@ import {
export function ShareSheet({
payload,
publication,
onClose,
}: {
payload: SharePayload;
publication?: MindSpacePublication | null;
onClose: () => void;
}) {
const [message, setMessage] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [expiresInMinutes, setExpiresInMinutes] = useState<number | null>(null);
useEffect(() => {
if (!publication || publication.accessMode !== 'public' || publication.userConfirmedAt !== null) {
setExpiresInMinutes(null);
return;
}
if (!publication.expiresAt) {
setExpiresInMinutes(null);
return;
}
const update = () => {
const now = Date.now();
const remaining = Math.ceil((publication.expiresAt! - now) / (1000 * 60));
setExpiresInMinutes(Math.max(0, remaining));
};
update();
const interval = setInterval(update, 10000);
return () => clearInterval(interval);
}, [publication]);
const handleChannel = async (channel: ShareChannel) => {
setBusy(true);
@@ -60,6 +85,15 @@ export function ShareSheet({
<p className="share-sheet-eyebrow">SHARE</p>
<h2 id="share-sheet-title"></h2>
<p className="share-sheet-subtitle">{payload.title}</p>
{expiresInMinutes !== null && (
<p className="share-sheet-preview-notice">
{' '}
<strong>
{expiresInMinutes > 0 ? `${expiresInMinutes} 分钟` : '不到 1 分钟'}
</strong>
</p>
)}
</div>
<button type="button" className="share-sheet-close" onClick={onClose} aria-label="关闭">
×
+5 -30
View File
@@ -24,6 +24,11 @@ import {
updateProvider,
} from '../api/client';
import { appConfig } from '../config';
import {
clearStoredSessionId,
readStoredSessionId,
writeStoredSessionId,
} from '../utils/sessionStorage';
import type {
CapabilityMap,
ChatState,
@@ -57,36 +62,6 @@ import {
} from '../utils/message';
import { prependUnique, shouldShowNewChatTitle, sortAndTrim, touchSession } from '../utils/sessions';
const LEGACY_SESSION_KEY = 'tkmind-h5-session-id';
function resolveSessionStorageKey(userId?: string | null): string {
return userId ? `${LEGACY_SESSION_KEY}:${userId}` : LEGACY_SESSION_KEY;
}
function readStoredSessionId(userId?: string | null): string | null {
const scopedKey = resolveSessionStorageKey(userId);
const scopedValue = localStorage.getItem(scopedKey);
if (scopedValue) return scopedValue;
const legacyValue = localStorage.getItem(LEGACY_SESSION_KEY);
if (legacyValue && userId) {
localStorage.setItem(scopedKey, legacyValue);
localStorage.removeItem(LEGACY_SESSION_KEY);
return legacyValue;
}
return legacyValue;
}
function writeStoredSessionId(userId: string | null | undefined, sessionId: string) {
localStorage.setItem(resolveSessionStorageKey(userId), sessionId);
}
function clearStoredSessionId(userId?: string | null) {
localStorage.removeItem(resolveSessionStorageKey(userId));
if (!userId) {
localStorage.removeItem(LEGACY_SESSION_KEY);
}
}
const INSUFFICIENT_BALANCE_NOTICE = '余额不足,请充值后继续使用';
export { INSUFFICIENT_BALANCE_NOTICE };
+1
View File
@@ -407,6 +407,7 @@ export type MindSpacePublication = {
publicUrl: string;
accessMode: MindSpacePublishCheck['accessMode'];
expiresAt: number | null;
userConfirmedAt: number | null;
status: 'online' | 'offline' | 'expired' | 'blocked';
viewCount: number;
publishedAt: number;
+39
View File
@@ -0,0 +1,39 @@
const LEGACY_SESSION_KEY = 'tkmind-h5-session-id';
export function resolveSessionStorageKey(userId?: string | null): string {
return userId ? `${LEGACY_SESSION_KEY}:${userId}` : LEGACY_SESSION_KEY;
}
export function readStoredSessionId(userId?: string | null): string | null {
if (userId) {
// Only use per-user scoped storage. Do not migrate the legacy global key,
// which may belong to another account on the same browser profile.
return localStorage.getItem(resolveSessionStorageKey(userId));
}
return localStorage.getItem(LEGACY_SESSION_KEY);
}
export function writeStoredSessionId(userId: string | null | undefined, sessionId: string) {
localStorage.setItem(resolveSessionStorageKey(userId), sessionId);
}
export function clearStoredSessionId(userId?: string | null) {
localStorage.removeItem(resolveSessionStorageKey(userId));
if (!userId) {
localStorage.removeItem(LEGACY_SESSION_KEY);
}
}
export function clearAllStoredSessionIds() {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
if (!key) continue;
if (key === LEGACY_SESSION_KEY || key.startsWith(`${LEGACY_SESSION_KEY}:`)) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
}
+53 -4
View File
@@ -6,6 +6,7 @@ import { evaluateProxyRequest, isNativeH5ApiPath } from './policies.mjs';
import { buildSandboxSessionConstraints } from './user-publish.mjs';
import { buildTaskRoutingAgentText } from './user-memory-profile.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { createImgproxySigner } from './imgproxy-signer.mjs';
const insecureDispatcher = new Agent({
connect: { rejectUnauthorized: false },
@@ -158,6 +159,7 @@ export async function buildVisionPayload({
publishLayout,
localFetchAsset,
llmProviderService,
imgproxySigner = null,
}) {
if (!localFetchAsset || !llmProviderService || !userId) return null;
const rawImageUrls = extractImageUrlsFromMessage(userMessage);
@@ -166,10 +168,39 @@ export async function buildVisionPayload({
const imageItems = [];
for (const rawUrl of rawImageUrls) {
try {
const match = rawUrl.match(/\/mindspace\/v1\/assets\/([^/?#]+)\/download/);
const match = rawUrl.match(/\/mindspace\/v1\/assets\/([^/?#]+)(?:\/download)?/);
if (!match) continue;
const assetId = decodeURIComponent(match[1]);
const { buffer, mimeType } = await localFetchAsset(userId, assetId);
let buffer;
let mimeType = 'image/jpeg';
if (imgproxySigner) {
try {
const visionUrl = imgproxySigner.buildUrl(
process.env.IMGPROXY_BASE_URL || 'https://img.tkmind.cn',
`users/${userId}/assets/${assetId}`,
'vision'
);
const visionResponse = await undiciFetch(visionUrl);
if (visionResponse.ok) {
buffer = Buffer.from(await visionResponse.arrayBuffer());
mimeType = visionResponse.headers.get('content-type') || 'image/jpeg';
} else {
throw new Error(`Vision fetch failed: ${visionResponse.status}`);
}
} catch (visionErr) {
console.warn(`Vision fetch fallback for asset ${assetId}:`, visionErr instanceof Error ? visionErr.message : visionErr);
const asset = await localFetchAsset(userId, assetId);
buffer = asset.buffer;
mimeType = asset.mimeType;
}
} else {
const asset = await localFetchAsset(userId, assetId);
buffer = asset.buffer;
mimeType = asset.mimeType;
}
let relativePath = rawUrl;
try {
const parsed = new URL(rawUrl);
@@ -240,6 +271,19 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
const primaryTarget = targets[0] ?? apiTarget ?? '';
let rrIdx = 0;
let imgproxySigner = null;
if (process.env.IMGPROXY_BASE_URL && process.env.IMGPROXY_SIGNING_KEY && process.env.IMGPROXY_SIGNING_SALT) {
try {
imgproxySigner = createImgproxySigner(
process.env.IMGPROXY_SIGNING_KEY,
process.env.IMGPROXY_SIGNING_SALT,
);
console.log('[TKMindProxy] imgproxy signer initialized');
} catch (err) {
console.warn('[TKMindProxy] imgproxy signer init failed:', err instanceof Error ? err.message : err);
}
}
async function targetHealthy(target) {
try {
const upstream = await apiFetch(target, apiSecret, '/status', {
@@ -265,7 +309,11 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
async function resolveTarget(sessionId) {
if (targets.length <= 1 || !sessionId) return primaryTarget;
try {
const node = await userAuth.getSessionNode(sessionId);
const { target, node } = await userAuth.getSessionTarget(sessionId);
// Prefer the pinned URL: stable across reordering/resizing the target list.
// Only honor it if that upstream is still configured; otherwise fall back to
// the legacy integer index, then to primary.
if (target && targets.includes(target)) return target;
return targets[node] ?? primaryTarget;
} catch {
return primaryTarget;
@@ -323,6 +371,7 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
publishLayout,
localFetchAsset,
llmProviderService,
imgproxySigner,
});
}
@@ -416,7 +465,7 @@ export function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth,
await userAuth.registerAgentSession(
req.currentUser.id,
session.id,
Math.max(0, targets.indexOf(startTarget)),
startTarget,
);
if (sessionPolicy.gooseMode) {
const modeRes = await apiFetch(startTarget, apiSecret, '/agent/update_session', {
+28 -5
View File
@@ -639,15 +639,27 @@ export function createUserAuth(pool, options = {}) {
}
};
const registerAgentSession = async (userId, agentSessionId, goosedNode = 0) => {
// goosedTarget may be the upstream URL the session is pinned to (preferred), or
// a legacy integer index into the targets list. We persist the URL in
// goosed_target and still derive a numeric goosed_node so older readers keep
// working; a string target stores goosed_node = 0 (its value is ignored once
// goosed_target is set).
const registerAgentSession = async (userId, agentSessionId, goosedTarget = 0) => {
const isLegacyIndex =
typeof goosedTarget === 'number' || /^\d+$/.test(String(goosedTarget));
const goosedNode = isLegacyIndex ? Number(goosedTarget) : 0;
const targetUrl = isLegacyIndex ? null : String(goosedTarget);
await pool.query(
`INSERT INTO h5_user_sessions (agent_session_id, user_id, goosed_node, created_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), goosed_node = VALUES(goosed_node)`,
[agentSessionId, userId, goosedNode, Date.now()],
`INSERT INTO h5_user_sessions (agent_session_id, user_id, goosed_node, goosed_target, created_at)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id),
goosed_node = VALUES(goosed_node), goosed_target = VALUES(goosed_target)`,
[agentSessionId, userId, goosedNode, targetUrl, Date.now()],
);
};
// Legacy integer-index accessor, kept for callers/bundles that still route by
// array index. Prefer getSessionTarget.
const getSessionNode = async (agentSessionId) => {
const [rows] = await pool.query(
`SELECT goosed_node FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
@@ -656,6 +668,16 @@ export function createUserAuth(pool, options = {}) {
return rows[0]?.goosed_node ?? 0;
};
// Returns { target, node }: target is the pinned upstream URL (null if the row
// predates goosed_target), node is the legacy integer index fallback.
const getSessionTarget = async (agentSessionId) => {
const [rows] = await pool.query(
`SELECT goosed_node, goosed_target FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
[agentSessionId],
);
return { target: rows[0]?.goosed_target ?? null, node: rows[0]?.goosed_node ?? 0 };
};
const ownsSession = async (userId, agentSessionId) => {
const [rows] = await pool.query(
`SELECT 1 FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ? LIMIT 1`,
@@ -2645,6 +2667,7 @@ export function createUserAuth(pool, options = {}) {
repairAllUserPublishDirs,
registerAgentSession,
getSessionNode,
getSessionTarget,
unregisterAgentSession,
ownsSession,
listOwnedSessionIds,