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