From ea6c49f0467016220e8de5b23e0f915f6e447b8b Mon Sep 17 00:00:00 2001 From: john Date: Tue, 30 Jun 2026 01:22:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(mindspace):=20=E5=85=A8=E9=83=A8=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E7=AE=A1=E7=90=86=E3=80=81=E7=BA=A7=E8=81=94=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E4=B8=8E=E9=A1=B5=E9=9D=A2=E5=90=8C=E6=AD=A5=E5=8E=BB?= =?UTF-8?q?=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支持全部页面分页/多选/删除/分享,删除时可选移除广场帖并清理工作区附件;修复并发同步重复创建页面,并补齐预览下载链接重写与 iframe 下载权限。 Co-authored-by: Cursor --- .runtime/portal/schema.sql | 30 + .runtime/portal/server.mjs | 1181 +++++++++++++---- mindspace-html-download-links.mjs | 157 +++ mindspace-html-download-links.test.mjs | 61 + mindspace-page-purge.mjs | 87 ++ mindspace-page-purge.test.mjs | 23 + mindspace-page-sync.mjs | 30 +- mindspace-page-sync.test.mjs | 47 + mindspace-pages.mjs | 210 ++- mindspace-pages.test.mjs | 13 + mindspace-publications.mjs | 19 +- mindspace-publications.test.mjs | 3 + package.json | 2 +- scripts/purge-user-pages.mjs | 266 ++++ server.mjs | 71 +- src/api/client.ts | 30 +- src/components/ChatSharePreviewModal.tsx | 2 +- src/components/MindSpaceDeletePlazaOption.tsx | 53 + src/components/MindSpaceFeedCard.tsx | 62 +- src/components/MindSpacePageDetail.tsx | 14 +- .../MindSpacePageDraftPreviewFrame.tsx | 2 +- .../MindSpacePageEditablePreviewFrame.tsx | 2 +- src/components/MindSpaceView.tsx | 418 +++++- src/components/PagePreviewFrame.tsx | 2 +- src/components/PageSavePreviewPanel.tsx | 2 +- src/components/WorkCover.tsx | 7 +- src/index.css | 82 ++ src/types.ts | 9 + src/utils/publicUrl.ts | 22 + src/utils/shareChannels.ts | 6 +- 30 files changed, 2562 insertions(+), 351 deletions(-) create mode 100644 mindspace-html-download-links.mjs create mode 100644 mindspace-html-download-links.test.mjs create mode 100644 mindspace-page-purge.mjs create mode 100644 mindspace-page-purge.test.mjs create mode 100644 scripts/purge-user-pages.mjs create mode 100644 src/components/MindSpaceDeletePlazaOption.tsx diff --git a/.runtime/portal/schema.sql b/.runtime/portal/schema.sql index 9b885a0..485f37f 100644 --- a/.runtime/portal/schema.sql +++ b/.runtime/portal/schema.sql @@ -353,6 +353,36 @@ CREATE TABLE IF NOT EXISTS h5_user_sessions ( CONSTRAINT fk_h5_session_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS h5_agent_runs ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + agent_session_id VARCHAR(128) NULL, + request_id VARCHAR(128) NOT NULL, + status ENUM('queued', 'running', 'retryable', 'succeeded', 'failed') NOT NULL DEFAULT 'queued', + attempts INT NOT NULL DEFAULT 0, + user_message_json LONGTEXT NOT NULL, + error_message TEXT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + started_at BIGINT NULL, + completed_at BIGINT NULL, + UNIQUE KEY uq_h5_agent_run_request (user_id, request_id), + KEY idx_h5_agent_run_user_status (user_id, status, updated_at), + KEY idx_h5_agent_run_session (agent_session_id, updated_at), + CONSTRAINT fk_h5_agent_run_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_agent_run_session FOREIGN KEY (agent_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_agent_run_events ( + id CHAR(36) PRIMARY KEY, + run_id CHAR(36) NOT NULL, + event_type VARCHAR(64) NOT NULL, + data_json JSON NULL, + created_at BIGINT NOT NULL, + KEY idx_h5_agent_run_event_run (run_id, created_at), + CONSTRAINT fk_h5_agent_run_event_run FOREIGN KEY (run_id) REFERENCES h5_agent_runs(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS h5_session_billing_state ( agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY, user_id CHAR(36) NOT NULL, diff --git a/.runtime/portal/server.mjs b/.runtime/portal/server.mjs index 96b146b..fc8d5f7 100644 --- a/.runtime/portal/server.mjs +++ b/.runtime/portal/server.mjs @@ -1355,7 +1355,7 @@ var require_cert_signatures = __commonJS({ var require_sasl = __commonJS({ "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/crypto/sasl.js"(exports, module) { "use strict"; - var crypto35 = require_utils2(); + var crypto36 = require_utils2(); var { signatureAlgorithmHashFromCertificate } = require_cert_signatures(); function saslprep(password) { const nonAsciiSpace = /[\u00A0\u1680\u2000-\u200B\u202F\u205F\u3000]/g; @@ -1373,7 +1373,7 @@ var require_sasl = __commonJS({ if (mechanism === "SCRAM-SHA-256-PLUS" && typeof stream.getPeerCertificate !== "function") { throw new Error("SASL: Mechanism SCRAM-SHA-256-PLUS requires a certificate"); } - const clientNonce = crypto35.randomBytes(18).toString("base64"); + const clientNonce = crypto36.randomBytes(18).toString("base64"); const gs2Header = mechanism === "SCRAM-SHA-256-PLUS" ? "p=tls-server-end-point" : stream ? "y" : "n"; return { mechanism, @@ -1415,20 +1415,20 @@ var require_sasl = __commonJS({ const peerCert = stream.getPeerCertificate().raw; let hashName = signatureAlgorithmHashFromCertificate(peerCert); if (hashName === "MD5" || hashName === "SHA-1") hashName = "SHA-256"; - const certHash = await crypto35.hashByName(hashName, peerCert); + const certHash = await crypto36.hashByName(hashName, peerCert); const bindingData = Buffer.concat([Buffer.from("p=tls-server-end-point,,"), Buffer.from(certHash)]); channelBinding = bindingData.toString("base64"); } const clientFinalMessageWithoutProof = "c=" + channelBinding + ",r=" + sv.nonce; const authMessage = clientFirstMessageBare + "," + serverFirstMessage + "," + clientFinalMessageWithoutProof; const saltBytes = Buffer.from(sv.salt, "base64"); - const saltedPassword = await crypto35.deriveKey(saslprep(password), saltBytes, sv.iteration); - const clientKey = await crypto35.hmacSha256(saltedPassword, "Client Key"); - const storedKey = await crypto35.sha256(clientKey); - const clientSignature = await crypto35.hmacSha256(storedKey, authMessage); + const saltedPassword = await crypto36.deriveKey(saslprep(password), saltBytes, sv.iteration); + const clientKey = await crypto36.hmacSha256(saltedPassword, "Client Key"); + const storedKey = await crypto36.sha256(clientKey); + const clientSignature = await crypto36.hmacSha256(storedKey, authMessage); const clientProof = xorBuffers(Buffer.from(clientKey), Buffer.from(clientSignature)).toString("base64"); - const serverKey = await crypto35.hmacSha256(saltedPassword, "Server Key"); - const serverSignatureBytes = await crypto35.hmacSha256(serverKey, authMessage); + const serverKey = await crypto36.hmacSha256(saltedPassword, "Server Key"); + const serverSignatureBytes = await crypto36.hmacSha256(serverKey, authMessage); session.message = "SASLResponse"; session.serverSignature = Buffer.from(serverSignatureBytes).toString("base64"); session.response = clientFinalMessageWithoutProof + ",p=" + clientProof; @@ -3658,7 +3658,7 @@ var require_client = __commonJS({ var Query2 = require_query(); var defaults2 = require_defaults(); var Connection2 = require_connection(); - var crypto35 = require_utils2(); + var crypto36 = require_utils2(); var activeQueryDeprecationNotice = nodeUtils.deprecate( () => { }, @@ -3909,7 +3909,7 @@ var require_client = __commonJS({ _handleAuthMD5Password(msg) { this._getPassword(async () => { try { - const hashedPassword = await crypto35.postgresMd5PasswordHash(this.user, this.password, msg.salt); + const hashedPassword = await crypto36.postgresMd5PasswordHash(this.user, this.password, msg.salt); this.connection.password(hashedPassword); } catch (e) { this.emit("error", e); @@ -5228,7 +5228,7 @@ var experience_service_pg_exports = {}; __export(experience_service_pg_exports, { createPgExperienceService: () => createPgExperienceService }); -import crypto33 from "node:crypto"; +import crypto34 from "node:crypto"; async function createPgExperienceService(options = {}) { const connectionString = options.connectionString; if (!connectionString) { @@ -5294,7 +5294,7 @@ async function createPgExperienceService(options = {}) { const body = String(input?.body ?? "").trim(); if (!title) throw experienceError2("\u7ECF\u9A8C\u6807\u9898\u4E0D\u80FD\u4E3A\u7A7A", "invalid_experience_input"); if (!body) throw experienceError2("\u7ECF\u9A8C\u5185\u5BB9\u4E0D\u80FD\u4E3A\u7A7A", "invalid_experience_input"); - const id = crypto33.randomUUID(); + const id = crypto34.randomUUID(); const ts = now(); const scope = String(input?.scope ?? "global").trim() || "global"; const kind = String(input?.kind ?? "lesson").trim() || "lesson"; @@ -5414,7 +5414,7 @@ var init_experience_service_pg = __esm({ // server.mjs import express2 from "express"; -import crypto34 from "node:crypto"; +import crypto35 from "node:crypto"; import fs26 from "node:fs"; import fsPromises3 from "node:fs/promises"; import { createProxyMiddleware } from "http-proxy-middleware"; @@ -5622,6 +5622,19 @@ var SYSTEM_CATEGORIES = Object.freeze([ function asNumber(value) { return Number(value ?? 0); } +async function withOptionalTimeout(promise, timeoutMs, fallback) { + let timeout; + try { + return await Promise.race([ + promise, + new Promise((resolve) => { + timeout = setTimeout(() => resolve(fallback), timeoutMs); + }) + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} function categoryResponse(row) { return { id: row.id, @@ -5708,7 +5721,11 @@ function createMindSpaceService(pool2, options = {}) { const scheduleService2 = options.scheduleService ?? null; const resolvePublicPageLimit = async () => { try { - const config = await loadMindSpaceConfig(pool2); + const config = await withOptionalTimeout( + loadMindSpaceConfig(pool2), + 1e3, + { publicPageLimit: publicPageLimitFallback } + ); return Number(config.publicPageLimit ?? publicPageLimitFallback); } catch { return publicPageLimitFallback; @@ -5729,24 +5746,32 @@ function createMindSpaceService(pool2, options = {}) { `SELECT c.id, c.category_code, c.category_name, c.visibility_policy, c.ai_access_policy, c.publish_policy, c.is_system, c.sort_order, CASE - WHEN c.category_code = 'draft' THEN COUNT(DISTINCT p.id) - WHEN c.category_code = 'public' THEN COUNT(DISTINCT pr.page_id) - ELSE COUNT(DISTINCT a.id) + WHEN c.category_code = 'draft' THEN COALESCE(pc.item_count, 0) + WHEN c.category_code = 'public' THEN COALESCE(pub.item_count, 0) + ELSE COALESCE(ac.item_count, 0) END AS item_count FROM h5_space_categories c - LEFT JOIN h5_assets a - ON a.category_id = c.id - AND a.user_id = c.user_id - AND a.status <> 'deleted' - LEFT JOIN h5_page_records p - ON p.category_id = c.id AND p.user_id = c.user_id AND p.status <> 'deleted' - LEFT JOIN h5_publish_records pr - ON pr.user_id = c.user_id AND pr.status = 'online' + LEFT JOIN ( + SELECT category_id, user_id, COUNT(*) AS item_count + FROM h5_assets + WHERE user_id = ? AND status <> 'deleted' AND source_type = 'upload' + GROUP BY category_id, user_id + ) ac ON ac.category_id = c.id AND ac.user_id = c.user_id + LEFT JOIN ( + SELECT category_id, user_id, COUNT(*) AS item_count + FROM h5_page_records + WHERE user_id = ? AND status <> 'deleted' + GROUP BY category_id, user_id + ) pc ON pc.category_id = c.id AND pc.user_id = c.user_id + LEFT JOIN ( + SELECT user_id, COUNT(DISTINCT page_id) AS item_count + FROM h5_publish_records + WHERE user_id = ? AND status = 'online' + GROUP BY user_id + ) pub ON pub.user_id = c.user_id WHERE c.user_id = ? AND c.space_id = ? - GROUP BY c.id, c.category_code, c.category_name, c.visibility_policy, - c.ai_access_policy, c.publish_policy, c.is_system, c.sort_order ORDER BY c.sort_order, c.category_name`, - [userId, row.id] + [userId, userId, userId, userId, row.id] ); const quotaBytes = asNumber(row.quota_bytes); const usedBytes = asNumber(row.used_bytes); @@ -5761,15 +5786,19 @@ function createMindSpaceService(pool2, options = {}) { let schedule = null; if (scheduleService2) { try { - const [todayTodoItems, digestSubscriptions] = await Promise.all([ - typeof scheduleService2.listTodayTodoItems === "function" ? scheduleService2.listTodayTodoItems({ userId }) : Promise.resolve([]), - typeof scheduleService2.listDigestSubscriptions === "function" ? scheduleService2.listDigestSubscriptions({ - userId, - digestType: "todo_day", - status: ["active", "locked"], - limit: 10 - }) : Promise.resolve([]) - ]); + const [todayTodoItems, digestSubscriptions] = await withOptionalTimeout( + Promise.all([ + typeof scheduleService2.listTodayTodoItems === "function" ? scheduleService2.listTodayTodoItems({ userId }) : Promise.resolve([]), + typeof scheduleService2.listDigestSubscriptions === "function" ? scheduleService2.listDigestSubscriptions({ + userId, + digestType: "todo_day", + status: ["active", "locked"], + limit: 10 + }) : Promise.resolve([]) + ]), + 1e3, + [[], []] + ); schedule = { todayTodoItems, digestSubscriptions }; } catch { schedule = null; @@ -6038,6 +6067,38 @@ async function migrateSchema(pool2) { CONSTRAINT fk_h5_conversation_session FOREIGN KEY (agent_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + await pool2.query(` + CREATE TABLE IF NOT EXISTS h5_agent_runs ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + agent_session_id VARCHAR(128) NULL, + request_id VARCHAR(128) NOT NULL, + status ENUM('queued', 'running', 'retryable', 'succeeded', 'failed') NOT NULL DEFAULT 'queued', + attempts INT NOT NULL DEFAULT 0, + user_message_json LONGTEXT NOT NULL, + error_message TEXT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + started_at BIGINT NULL, + completed_at BIGINT NULL, + UNIQUE KEY uq_h5_agent_run_request (user_id, request_id), + KEY idx_h5_agent_run_user_status (user_id, status, updated_at), + KEY idx_h5_agent_run_session (agent_session_id, updated_at), + CONSTRAINT fk_h5_agent_run_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_agent_run_session FOREIGN KEY (agent_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool2.query(` + CREATE TABLE IF NOT EXISTS h5_agent_run_events ( + id CHAR(36) PRIMARY KEY, + run_id CHAR(36) NOT NULL, + event_type VARCHAR(64) NOT NULL, + data_json JSON NULL, + created_at BIGINT NOT NULL, + KEY idx_h5_agent_run_event_run (run_id, created_at), + CONSTRAINT fk_h5_agent_run_event_run FOREIGN KEY (run_id) REFERENCES h5_agent_runs(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); await pool2.query(` CREATE TABLE IF NOT EXISTS h5_user_memory_items ( id CHAR(64) PRIMARY KEY, @@ -6431,6 +6492,316 @@ async function initSchema(pool2) { }); } +// agent-run-gateway.mjs +import crypto3 from "node:crypto"; +var DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5e3, 15e3]; +var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["succeeded", "failed"]); +function nowMs() { + return Date.now(); +} +function safeJsonParse(value, fallback = null) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} +function serializeMessage(message) { + return JSON.stringify(message ?? {}); +} +function projectRun(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + sessionId: row.agent_session_id ?? null, + requestId: row.request_id, + status: row.status, + attempts: Number(row.attempts ?? 0), + error: row.error_message ?? null, + createdAt: Number(row.created_at ?? 0), + updatedAt: Number(row.updated_at ?? 0), + startedAt: row.started_at == null ? null : Number(row.started_at), + completedAt: row.completed_at == null ? null : Number(row.completed_at) + }; +} +function createAgentRunGateway({ + pool: pool2, + userAuth: userAuth2, + tkmindProxy: tkmindProxy2, + retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS, + autoDispatch = true +}) { + const inFlight = /* @__PURE__ */ new Set(); + async function appendEvent(runId, type, data = null) { + await pool2.query( + `INSERT INTO h5_agent_run_events (id, run_id, event_type, data_json, created_at) + VALUES (?, ?, ?, ?, ?)`, + [ + crypto3.randomUUID(), + runId, + type, + data == null ? null : JSON.stringify(data), + nowMs() + ] + ); + } + async function getRunById(runId) { + const [rows] = await pool2.query( + `SELECT * FROM h5_agent_runs WHERE id = ? LIMIT 1`, + [runId] + ); + return rows[0] ?? null; + } + async function getRunForUser(userId, runId) { + const [rows] = await pool2.query( + `SELECT * FROM h5_agent_runs WHERE id = ? AND user_id = ? LIMIT 1`, + [runId, userId] + ); + return projectRun(rows[0] ?? null); + } + async function getRunByRequest(userId, requestId) { + const [rows] = await pool2.query( + `SELECT * FROM h5_agent_runs WHERE user_id = ? AND request_id = ? LIMIT 1`, + [userId, requestId] + ); + return rows[0] ?? null; + } + async function createRun(userId, { sessionId = null, requestId, userMessage }) { + const normalizedRequestId = String(requestId ?? "").trim(); + if (!normalizedRequestId) { + throw new Error("\u7F3A\u5C11 request_id"); + } + const existing = await getRunByRequest(userId, normalizedRequestId); + if (existing) { + if (autoDispatch && !TERMINAL_STATUSES.has(existing.status)) dispatchRun(existing.id); + return projectRun(existing); + } + const runId = crypto3.randomUUID(); + const createdAt = nowMs(); + await pool2.query( + `INSERT INTO h5_agent_runs + (id, user_id, agent_session_id, request_id, status, attempts, + user_message_json, error_message, created_at, updated_at, started_at, completed_at) + VALUES (?, ?, ?, ?, 'queued', 0, ?, NULL, ?, ?, NULL, NULL)`, + [ + runId, + userId, + sessionId || null, + normalizedRequestId, + serializeMessage(userMessage), + createdAt, + createdAt + ] + ); + await appendEvent(runId, "queued", { sessionId: sessionId || null }); + if (autoDispatch) dispatchRun(runId); + return projectRun(await getRunById(runId)); + } + async function markRun(runId, status, fields = {}) { + const updates = ["status = ?", "updated_at = ?"]; + const values = [status, nowMs()]; + for (const [key, value] of Object.entries(fields)) { + updates.push(`${key} = ?`); + values.push(value); + } + values.push(runId); + await pool2.query( + `UPDATE h5_agent_runs SET ${updates.join(", ")} WHERE id = ?`, + values + ); + await appendEvent(runId, status, fields); + } + async function processRun(runId) { + const row = await getRunById(runId); + if (!row || TERMINAL_STATUSES.has(row.status)) return; + const nextAttempt = Number(row.attempts ?? 0) + 1; + const [claim] = await pool2.query( + `UPDATE h5_agent_runs + SET status = 'running', attempts = ?, started_at = COALESCE(started_at, ?), updated_at = ?, error_message = NULL + WHERE id = ? AND status IN ('queued', 'retryable')`, + [nextAttempt, nowMs(), nowMs(), runId] + ); + if (Number(claim?.affectedRows ?? 0) === 0) return; + await appendEvent(runId, "running", { attempt: nextAttempt }); + try { + let sessionId = row.agent_session_id ?? null; + if (!sessionId) { + const session = await tkmindProxy2.startSessionForUser(row.user_id); + sessionId = session.id; + await pool2.query( + `UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`, + [sessionId, nowMs(), runId] + ); + await appendEvent(runId, "session_started", { sessionId }); + } + const userMessage = safeJsonParse(row.user_message_json, {}); + await tkmindProxy2.submitSessionReplyForUser( + row.user_id, + sessionId, + row.request_id, + userMessage + ); + await markRun(runId, "succeeded", { + agent_session_id: sessionId, + completed_at: nowMs() + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const retryable = nextAttempt < retryDelaysMs.length; + await markRun(runId, retryable ? "retryable" : "failed", { + error_message: message, + completed_at: retryable ? null : nowMs() + }); + if (retryable) { + setTimeout(() => dispatchRun(runId), retryDelaysMs[nextAttempt - 1]); + } + } + } + function dispatchRun(runId) { + if (!runId || inFlight.has(runId)) return; + inFlight.add(runId); + void processRun(runId).finally(() => inFlight.delete(runId)); + } + return { + createRun, + getRunForUser, + dispatchRun + }; +} + +// agent-run-routes.mjs +function createPostAgentRunsHandler({ userAuth: userAuth2, agentRunGateway: agentRunGateway2 }) { + return async function postAgentRuns(request, response) { + try { + const sessionId = String(request.body?.session_id ?? "").trim() || null; + const requestId = String(request.body?.request_id ?? "").trim(); + const userMessage = request.body?.user_message ?? null; + if (!requestId) { + response.status(400).json({ message: "\u7F3A\u5C11 request_id" }); + return; + } + if (!userMessage) { + response.status(400).json({ message: "\u7F3A\u5C11 user_message" }); + return; + } + if (sessionId) { + const owns = await userAuth2.ownsSession(request.currentUser.id, sessionId); + if (!owns) { + response.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); + return; + } + } + const run = await agentRunGateway2.createRun(request.currentUser.id, { + sessionId, + requestId, + userMessage + }); + response.status(202).json({ run }); + } catch (err) { + response.status(500).json({ + message: err instanceof Error ? err.message : "\u521B\u5EFA\u4EFB\u52A1\u5931\u8D25" + }); + } + }; +} +function createGetAgentRunHandler({ agentRunGateway: agentRunGateway2 }) { + return async function getAgentRun(request, response) { + try { + const run = await agentRunGateway2.getRunForUser( + request.currentUser.id, + request.params.runId + ); + if (!run) { + response.status(404).json({ message: "\u4EFB\u52A1\u4E0D\u5B58\u5728" }); + return; + } + if (run.status !== "succeeded" && run.status !== "failed") { + agentRunGateway2.dispatchRun(run.id); + } + response.json({ run }); + } catch (err) { + response.status(500).json({ + message: err instanceof Error ? err.message : "\u8BFB\u53D6\u4EFB\u52A1\u5931\u8D25" + }); + } + }; +} +function createAgentRunEventsHandler({ + agentRunGateway: agentRunGateway2, + pollIntervalMs = 1e3, + keepaliveIntervalMs = 2e4 +}) { + return async function getAgentRunEvents(request, response) { + const userId = request.currentUser.id; + const runId = request.params.runId; + let closed = false; + let lastPayload = null; + let pollTimer = null; + let keepaliveTimer = null; + const cleanup = () => { + closed = true; + if (pollTimer) clearInterval(pollTimer); + if (keepaliveTimer) clearInterval(keepaliveTimer); + pollTimer = null; + keepaliveTimer = null; + }; + const sendEvent = (event, data) => { + response.write(`event: ${event} +`); + response.write(`data: ${JSON.stringify(data)} + +`); + }; + const publish = async (prefetchedRun = null) => { + if (closed) return; + try { + const run = prefetchedRun ?? await agentRunGateway2.getRunForUser(userId, runId); + if (!run) { + sendEvent("error", { message: "\u4EFB\u52A1\u4E0D\u5B58\u5728" }); + cleanup(); + response.end(); + return; + } + const nextPayload = JSON.stringify(run); + if (nextPayload !== lastPayload) { + lastPayload = nextPayload; + sendEvent("run", { run }); + } + if (run.status !== "succeeded" && run.status !== "failed") { + agentRunGateway2.dispatchRun(run.id); + return; + } + cleanup(); + response.end(); + } catch (err) { + sendEvent("error", { + message: err instanceof Error ? err.message : "\u8BFB\u53D6\u4EFB\u52A1\u5931\u8D25" + }); + cleanup(); + response.end(); + } + }; + const firstRun = await agentRunGateway2.getRunForUser(userId, runId); + if (!firstRun) { + response.status(404).json({ message: "\u4EFB\u52A1\u4E0D\u5B58\u5728" }); + return; + } + response.status(200); + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Connection", "keep-alive"); + request.on("close", cleanup); + keepaliveTimer = setInterval(() => { + if (!closed) response.write(": keepalive\n\n"); + }, keepaliveIntervalMs); + pollTimer = setInterval(() => { + void publish(); + }, pollIntervalMs); + await publish(firstRun); + }; +} + // tkmind-proxy.mjs import fs5 from "node:fs"; import path6 from "node:path"; @@ -6690,6 +7061,9 @@ var USER_API_ALLOWLIST = [ { method: "GET", pattern: /^\/status$/ }, { method: "POST", pattern: /^\/agent\/start$/ }, { method: "POST", pattern: /^\/agent\/resume$/ }, + { method: "POST", pattern: /^\/agent\/runs$/ }, + { method: "GET", pattern: /^\/agent\/runs\/[^/]+$/ }, + { method: "GET", pattern: /^\/agent\/runs\/[^/]+\/events$/ }, { method: "GET", pattern: /^\/sessions$/ }, { method: "GET", pattern: /^\/sessions\/[^/]+$/ }, { method: "DELETE", pattern: /^\/sessions\/[^/]+$/ }, @@ -7981,13 +8355,13 @@ async function reconcileAgentSession(apiFetch2, sessionId, { } // imgproxy-signer.mjs -import crypto3 from "node:crypto"; +import crypto4 from "node:crypto"; function createImgproxySigner(key, salt) { const keyBuf = Buffer.from(key, "hex"); const saltBuf = Buffer.from(salt, "hex"); function signPath(path29) { const signaturePayload = Buffer.concat([saltBuf, Buffer.from(path29)]); - const signature = crypto3.createHmac("sha256", keyBuf).update(signaturePayload).digest(); + const signature = crypto4.createHmac("sha256", keyBuf).update(signaturePayload).digest(); const signatureB64 = signature.toString("base64url"); return `/${signatureB64}${path29}`; } @@ -8221,7 +8595,7 @@ function sanitizeSessionConversationPublicHtmlLinks(conversation, currentUser) { if (!Array.isArray(conversation)) return conversation; return conversation.map((message) => sanitizeSessionMessagePublicHtmlLinks(message, currentUser)); } -function createSessionEventSanitizer(currentUser) { +function createSessionEventSanitizer(currentUser, { onEvent } = {}) { let buffer = ""; const flushChunk = (controller, chunk) => { if (!chunk) return; @@ -8240,6 +8614,12 @@ function createSessionEventSanitizer(currentUser) { } catch { return line; } + if (typeof onEvent === "function") { + try { + onEvent(event); + } catch { + } + } if (event?.type === "Message" && event.message) { event.message = sanitizeSessionMessagePublicHtmlLinks(event.message, currentUser); } else if (event?.type === "UpdateConversation" && Array.isArray(event.conversation)) { @@ -8564,13 +8944,15 @@ function createTkmindProxy({ sessionPolicy = null, recipe = null } = {}) { + const resolvedWorkingDir = workingDir ?? await userAuth2.resolveWorkingDir(userId); + const resolvedSessionPolicy = sessionPolicy ?? await userAuth2.getAgentSessionPolicy(userId); const startTarget = await pickTarget(); const upstream = await apiFetch(startTarget, apiSecret, "/agent/start", { method: "POST", body: JSON.stringify({ - ...workingDir ? { working_dir: workingDir } : {}, - enable_context_memory: sessionPolicy?.enableContextMemory, - ...sessionPolicy?.extensionOverrides ? { extension_overrides: sessionPolicy.extensionOverrides } : {}, + ...resolvedWorkingDir ? { working_dir: resolvedWorkingDir } : {}, + enable_context_memory: resolvedSessionPolicy?.enableContextMemory, + ...resolvedSessionPolicy?.extensionOverrides ? { extension_overrides: resolvedSessionPolicy.extensionOverrides } : {}, ...recipe ? { recipe } : {} }) }); @@ -8583,12 +8965,12 @@ function createTkmindProxy({ throw new Error("\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25\uFF1A\u7F3A\u5C11 session id"); } await userAuth2.registerAgentSession(userId, session.id, startTarget); - if (sessionPolicy?.gooseMode) { + if (resolvedSessionPolicy?.gooseMode) { const modeRes = await apiFetch(startTarget, apiSecret, "/agent/update_session", { method: "POST", body: JSON.stringify({ session_id: session.id, - goose_mode: sessionPolicy.gooseMode + goose_mode: resolvedSessionPolicy.gooseMode }) }); if (!modeRes.ok) { @@ -8596,6 +8978,25 @@ function createTkmindProxy({ throw new Error(modeText || `\u8BBE\u7F6E\u4F1A\u8BDD\u6A21\u5F0F\u5931\u8D25 (${modeRes.status})`); } } + const publishLayout = await userAuth2.getUserPublishLayout(userId); + const userMemories = conversationMemoryService2?.listMemories ? await conversationMemoryService2.listMemories(userId, { limit: 40 }).catch(() => []) : []; + await reconcileAgentSession( + (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init), + session.id, + { + workingDir: resolvedWorkingDir, + sessionPolicy: resolvedSessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + userMemories, + userContext: publishLayout ? { + userId, + displayName: publishLayout.displayName, + username: publishLayout.username, + slug: publishLayout.slug + } : null + } + ); + await applySessionLlmProvider(session.id); return session; } async function resolveTarget(sessionId) { @@ -8676,6 +9077,64 @@ function createTkmindProxy({ } ); } + async function submitSessionReplyForUser(userId, sessionId, requestId, userMessage) { + if (!userId || !sessionId) throw new Error("\u7F3A\u5C11\u4F1A\u8BDD\u4FE1\u606F"); + const owns = await userAuth2.ownsSession(userId, sessionId); + if (!owns) throw new Error("\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD"); + const gate = await userAuth2.canUseChat(userId); + if (!gate.ok) { + const err = new Error(gate.message ?? "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u540E\u7EE7\u7EED\u4F7F\u7528"); + err.code = gate.code; + err.status = 402; + throw err; + } + await reconcileSessionPolicyForUser(userId, sessionId); + await applySessionLlmProvider(sessionId); + const user = await userAuth2.getUserById(userId); + if (!user) throw new Error("\u7528\u6237\u4E0D\u5B58\u5728"); + let finalUserMessage = userMessage; + if (llmProviderService2 && messageHasImages(userMessage) && await llmProviderService2.hasVisionKey()) { + const publishLayout = await userAuth2.getUserPublishLayout(userId).catch(() => null); + const visionResult = await buildVisionBody(userMessage, userId, publishLayout).catch(() => null); + if (visionResult?.userMessage) { + finalUserMessage = visionResult.userMessage; + } + if (visionResult?.billableImageCount > 0 && subscriptionService2) { + await subscriptionService2.consumeImageQuota(userId, visionResult.billableImageCount).catch((err) => { + console.warn( + "Subscription image quota consume skipped:", + err instanceof Error ? err.message : err + ); + }); + } + } + const policyState = await userAuth2.resolveUserPolicies(user); + let body = { + request_id: requestId, + user_message: finalUserMessage + }; + if (!policyState.unrestricted) { + body = injectTaskRoutingHint( + body, + await userAuth2.getAgentSessionPolicy(userId) + ); + } + const target = await resolveTarget(sessionId); + const upstream = await apiFetch( + target, + apiSecret, + `/sessions/${encodeURIComponent(sessionId)}/reply`, + { + method: "POST", + body: JSON.stringify(body) + } + ); + if (!upstream.ok) { + const text = await upstream.text().catch(() => ""); + throw new Error(text || `\u53D1\u9001\u5931\u8D25 (${upstream.status})`); + } + return { ok: true }; + } const requireUser = async (req, res, next) => { try { const session = req.userSession; @@ -8946,7 +9405,7 @@ function createTkmindProxy({ } } ]; - const proxySessionEvents = async (req, res, sessionId, { onAfterFinish } = {}) => { + const proxySessionEvents = async (req, res, sessionId, { onAfterFinish, onEvent } = {}) => { try { const pathname = `/sessions/${sessionId}/events`; const sessionTarget = await resolveTarget(sessionId); @@ -8994,7 +9453,7 @@ function createTkmindProxy({ } }); const source = Readable.fromWeb(upstream.body); - const linkSanitizer = createSessionEventSanitizer(req.currentUser); + const linkSanitizer = createSessionEventSanitizer(req.currentUser, { onEvent }); const keepalive = setInterval(() => { if (!res.writableEnded) res.write(": keepalive\n\n"); }, 2e4); @@ -9043,6 +9502,14 @@ function createTkmindProxy({ }); return; } + const isAgentRunPath = req.method === "POST" && pathname === "/agent/runs" || req.method === "GET" && /^\/agent\/runs\/[^/]+$/.test(pathname) || req.method === "GET" && /^\/agent\/runs\/[^/]+\/events$/.test(pathname); + if (isAgentRunPath) { + res.status(503).json({ + message: "Agent Run \u63A5\u53E3\u9700\u5728 Portal \u672C\u5730\u5904\u7406\uFF0C\u8BF7\u786E\u8BA4 server.mjs \u5DF2\u66F4\u65B0\u5E76\u91CD\u542F\u540E\u7AEF", + code: "AGENT_RUNS_NATIVE_REQUIRED" + }); + return; + } const policyState = await userAuth2.resolveUserPolicies(req.currentUser); const capabilityState = await userAuth2.resolveUserCapabilities(req.currentUser); const gate = evaluateProxyRequest(req.method, pathname, policyState.policies, { @@ -9058,6 +9525,13 @@ function createTkmindProxy({ } const isReplyPath = pathname.match(/^\/sessions\/[^/]+\/reply$/) && req.method === "POST"; const sessionMatch = pathname.match(/^\/sessions\/([^/]+)/); + if (isReplyPath) { + res.status(410).json({ + message: "\u804A\u5929\u63D0\u4EA4\u5165\u53E3\u5DF2\u7EDF\u4E00\u4E3A POST /agent/runs", + code: "AGENT_RUNS_REQUIRED" + }); + return; + } let baseBody = req.body; if (isReplyPath && !policyState.unrestricted) { baseBody = injectTaskRoutingHint( @@ -9144,13 +9618,14 @@ function createTkmindProxy({ proxySessionEvents, resolveTarget, startSessionForUser, + submitSessionReplyForUser, apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init), apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init) }; } // user-auth.mjs -import crypto5 from "node:crypto"; +import crypto6 from "node:crypto"; import fs8 from "node:fs"; import net from "node:net"; import path9 from "node:path"; @@ -9220,7 +9695,7 @@ function computeDeltaCostCents(previous, current, config = loadBillingConfig()) } // billing-recharge.mjs -import crypto4 from "node:crypto"; +import crypto5 from "node:crypto"; function loadRechargeConfig() { const tiers = (process.env.H5_RECHARGE_TIERS_CENTS ?? "500,1000,3000,5000,10000,20000").split(",").map((value) => Number(value.trim())).filter((value) => Number.isFinite(value) && value > 0); return { @@ -9245,7 +9720,7 @@ function buildInsufficientBalancePayload(balanceCents, config = loadRechargeConf } function createOutTradeNo() { const stamp = Date.now().toString(36).toUpperCase(); - const rand = crypto4.randomBytes(4).toString("hex").toUpperCase(); + const rand = crypto5.randomBytes(4).toString("hex").toUpperCase(); return `TK${stamp}${rand}`.slice(0, 32); } function mapOrderRow(row) { @@ -9340,7 +9815,7 @@ function createRechargeService(pool2, { userAuth: userAuth2, wechatPay, config = return { ok: false, message: "\u5DF2\u8D85\u8FC7\u4ECA\u65E5\u5145\u503C\u4E0A\u9650\uFF0C\u8BF7\u660E\u65E5\u518D\u8BD5" }; } const now = Date.now(); - const orderId = crypto4.randomUUID(); + const orderId = crypto5.randomUUID(); const outTradeNo = createOutTradeNo(); const expireAt = now + config.orderTtlMs; const description = `TKMind\u8D26\u6237\u5145\u503C\xA5${(amount / 100).toFixed(2)}`; @@ -9857,7 +10332,7 @@ var USER_COOKIE = "tkmind_user_session"; function safeEqual2(left, right) { const a = Buffer.from(left); const b = Buffer.from(right); - return a.length === b.length && crypto5.timingSafeEqual(a, b); + return a.length === b.length && crypto6.timingSafeEqual(a, b); } var PASSWORD_ALGORITHM_PBKDF2 = "pbkdf2-sha512"; var PASSWORD_ALGORITHM_ARGON2ID = "argon2id"; @@ -9866,7 +10341,7 @@ var ARGON2_PASSES = 3; var ARGON2_PARALLELISM = 1; var ARGON2_TAG_LENGTH = 32; function hashPasswordPbkdf2(password, salt) { - return crypto5.pbkdf2Sync(password, salt, 1e5, 64, "sha512").toString("hex"); + return crypto6.pbkdf2Sync(password, salt, 1e5, 64, "sha512").toString("hex"); } function hashPasswordArgon2id(password, salt) { return argon2HashRawSync(password, { @@ -9879,7 +10354,7 @@ function hashPasswordArgon2id(password, salt) { }).toString("hex"); } function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) { - const salt = crypto5.randomBytes(16).toString("hex"); + const salt = crypto6.randomBytes(16).toString("hex"); if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) { return { salt, @@ -9910,7 +10385,7 @@ function isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } function hashSessionToken(token) { - return crypto5.createHash("sha256").update(token).digest("hex"); + return crypto6.createHash("sha256").update(token).digest("hex"); } function createUserAuth(pool2, options = {}) { const usersRoot = path9.resolve(options.usersRoot ?? "/tmp/tkmind_go_users"); @@ -9945,7 +10420,7 @@ function createUserAuth(pool2, options = {}) { await pool2.query( `INSERT INTO h5_login_sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)`, - [crypto5.randomUUID(), userId, hashSessionToken(token), expiresAt, now] + [crypto6.randomUUID(), userId, hashSessionToken(token), expiresAt, now] ); return expiresAt; }; @@ -10127,7 +10602,7 @@ function createUserAuth(pool2, options = {}) { return { ok: false, message: "\u8BF7\u8F93\u5165\u6709\u6548\u90AE\u7BB1" }; } const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); - const userId = crypto5.randomUUID(); + const userId = crypto6.randomUUID(); const layout = await publishLayoutFor({ id: userId, username: normalized }); const workspaceRoot = layout.publishDir; const now = Date.now(); @@ -10241,7 +10716,7 @@ function createUserAuth(pool2, options = {}) { row.password_algorithm = nextPassword.passwordAlgorithm; } loginFailures.delete(failureKey); - const token = crypto5.randomBytes(32).toString("base64url"); + const token = crypto6.randomBytes(32).toString("base64url"); await storeSession(row.id, row.role, token, now); return { ok: true, token, user: publicUser(row) }; }; @@ -10537,7 +11012,7 @@ function createUserAuth(pool2, options = {}) { return { ok: false, message: "\u5BC6\u7801\u81F3\u5C11 6 \u4F4D" }; } const isAdmin = role === "admin"; - const userId = crypto5.randomUUID(); + const userId = crypto6.randomUUID(); const root = (await publishLayoutFor({ id: userId, username: normalized })).publishDir; const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); const now = Date.now(); @@ -10743,7 +11218,7 @@ function createUserAuth(pool2, options = {}) { (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at) VALUES (?, ?, 'web', 'space_purchase', ?, ?, ?, 'unread', NULL, ?, ?)`, [ - crypto5.randomUUID(), + crypto6.randomUUID(), userId, "\u7A7A\u95F4\u6269\u5BB9\u6210\u529F", `\u5DF2\u8D2D\u4E70 ${purchaseMb} MB \u7A7A\u95F4\uFF0C\u652F\u4ED8 \xA5${(costCents / 100).toFixed(2)}\u3002`, @@ -10818,7 +11293,7 @@ function createUserAuth(pool2, options = {}) { (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at) VALUES (?, ?, 'web', ?, ?, ?, ?, 'unread', NULL, ?, ?)`, [ - crypto5.randomUUID(), + crypto6.randomUUID(), userId, rechargeType, title, @@ -11643,7 +12118,7 @@ function createUserAuth(pool2, options = {}) { }; const PENDING_BIND_TTL_MS = 15 * 60 * 1e3; const issueUserSession = async (userId, role, now = Date.now()) => { - const token = crypto5.randomBytes(32).toString("base64url"); + const token = crypto6.randomBytes(32).toString("base64url"); await storeSession(userId, role, token, now); return token; }; @@ -11735,7 +12210,7 @@ function createUserAuth(pool2, options = {}) { status = "active", now = Date.now() }) => { - const id = crypto5.randomUUID(); + const id = crypto6.randomUUID(); await pool2.query( `INSERT INTO h5_wechat_agent_routes (id, user_id, app_id, openid, agent_session_id, status, created_at, updated_at) @@ -11822,7 +12297,7 @@ function createUserAuth(pool2, options = {}) { now = Date.now() }) => { if (!appId || !openid || !msgType) return null; - const id = crypto5.randomUUID(); + const id = crypto6.randomUUID(); await pool2.query( `INSERT INTO h5_wechat_mp_message_details (id, app_id, openid, user_id, msg_id, msg_type, display_text, agent_text, @@ -11878,7 +12353,7 @@ function createUserAuth(pool2, options = {}) { (id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ - crypto5.randomUUID(), + crypto6.randomUUID(), userId, appId, openid, @@ -11933,7 +12408,7 @@ function createUserAuth(pool2, options = {}) { now = Date.now() }) => { await pruneWechatPendingBinds(now); - const token = crypto5.randomBytes(24).toString("base64url"); + const token = crypto6.randomBytes(24).toString("base64url"); await pool2.query( `INSERT INTO h5_wechat_pending_binds (token, app_id, openid, unionid, nickname, avatar_url, return_to, @@ -11993,22 +12468,22 @@ function createUserAuth(pool2, options = {}) { }; const generateWechatUsername = async (openid) => { const cleaned = String(openid).replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); - const suffix = cleaned.slice(-8) || crypto5.randomBytes(4).toString("hex"); + const suffix = cleaned.slice(-8) || crypto6.randomBytes(4).toString("hex"); let candidate = `wx_${suffix}`.slice(0, 32); if (!isValidUsername(candidate)) { - candidate = `wx_${crypto5.randomBytes(4).toString("hex")}`; + candidate = `wx_${crypto6.randomBytes(4).toString("hex")}`; } for (let attempt = 0; attempt < 8; attempt += 1) { const [rows] = await pool2.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ candidate ]); if (!rows[0]) return candidate; - candidate = `wx_${suffix.slice(0, Math.max(1, 8 - attempt))}${crypto5.randomBytes(2).toString("hex")}`.slice( + candidate = `wx_${suffix.slice(0, Math.max(1, 8 - attempt))}${crypto6.randomBytes(2).toString("hex")}`.slice( 0, 32 ); } - return `wx_${crypto5.randomBytes(6).toString("hex")}`.slice(0, 32); + return `wx_${crypto6.randomBytes(6).toString("hex")}`.slice(0, 32); }; const registerViaWechat = async ({ appId, @@ -12019,9 +12494,9 @@ function createUserAuth(pool2, options = {}) { now = Date.now() }) => { const normalized = await generateWechatUsername(openid); - const randomPassword = crypto5.randomBytes(24).toString("base64url"); + const randomPassword = crypto6.randomBytes(24).toString("base64url"); const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword); - const userId = crypto5.randomUUID(); + const userId = crypto6.randomUUID(); const layout = await publishLayoutFor({ id: userId, username: normalized }); const workspaceRoot = layout.publishDir; const displayName = nickname?.trim() || "\u5FAE\u4FE1\u7528\u6237"; @@ -12061,7 +12536,7 @@ function createUserAuth(pool2, options = {}) { (id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ - crypto5.randomUUID(), + crypto6.randomUUID(), userId, appId, openid, @@ -12449,7 +12924,7 @@ function resolveCookieDomainForRequest(req) { } // wiki-auth.mjs -import crypto6 from "node:crypto"; +import crypto7 from "node:crypto"; import fs9 from "node:fs"; import path10 from "node:path"; var DB_DIR = ""; @@ -12478,7 +12953,7 @@ function writeUsers(data) { function safeEqual3(a, b) { const ba = Buffer.from(a); const bb = Buffer.from(b); - return ba.length === bb.length && crypto6.timingSafeEqual(ba, bb); + return ba.length === bb.length && crypto7.timingSafeEqual(ba, bb); } function createWikiAuth(dataDir) { initPaths(dataDir); @@ -12486,17 +12961,17 @@ function createWikiAuth(dataDir) { const sessions = /* @__PURE__ */ new Map(); const COOKIE_NAME = "wiki_session"; function hashPassword2(password, salt) { - return crypto6.pbkdf2Sync(password, salt, 1e5, 64, "sha512").toString("hex"); + return crypto7.pbkdf2Sync(password, salt, 1e5, 64, "sha512").toString("hex"); } function register(username, password, displayName) { const db = readUsers(); if (db.users.find((u) => u.username === username)) { return { ok: false, message: "\u7528\u6237\u540D\u5DF2\u5B58\u5728" }; } - const salt = crypto6.randomBytes(16).toString("hex"); + const salt = crypto7.randomBytes(16).toString("hex"); const hashed = hashPassword2(password, salt); const user = { - id: crypto6.randomUUID(), + id: crypto7.randomUUID(), username, displayName: displayName || username, salt, @@ -12515,7 +12990,7 @@ function createWikiAuth(dataDir) { if (!safeEqual3(hashed, user.hashedPassword)) { return { ok: false, message: "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" }; } - const token = crypto6.randomBytes(32).toString("base64url"); + const token = crypto7.randomBytes(32).toString("base64url"); sessions.set(token, { userId: user.id, username: user.username, expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1e3 }); return { ok: true, token, user: { id: user.id, username: user.username, displayName: user.displayName } }; } @@ -12571,7 +13046,7 @@ function createWikiAuth(dataDir) { const filePath = path10.join(userPagesDir, `${slug}.json`); const existing = fs9.existsSync(filePath) ? JSON.parse(fs9.readFileSync(filePath, "utf-8")) : null; const page = { - id: existing?.id || crypto6.randomUUID(), + id: existing?.id || crypto7.randomUUID(), slug, title: title || slug, content: content || "", @@ -13176,13 +13651,13 @@ function startWorkspaceThumbnailWatcher(publishRoot) { } // mindspace-workspace-sync.mjs -import crypto8 from "node:crypto"; +import crypto9 from "node:crypto"; import fs13 from "node:fs"; import fsPromises2 from "node:fs/promises"; import path17 from "node:path"; // mindspace-assets.mjs -import crypto7 from "node:crypto"; +import crypto8 from "node:crypto"; import fs12 from "node:fs/promises"; import path16 from "node:path"; @@ -14182,7 +14657,7 @@ function createAssetService(pool2, options = {}) { const h5Root = options.h5Root ? path16.resolve(options.h5Root) : null; const maxFileBytes = Number(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES); const uploadTtlMs = Number(options.uploadTtlMs ?? 30 * 60 * 1e3); - const idFactory = options.idFactory ?? (() => crypto7.randomUUID()); + const idFactory = options.idFactory ?? (() => crypto8.randomUUID()); const normalizeStoredImage = options.normalizeStoredImage ?? normalizeImageForStorage; const workspaceSync = createWorkspaceAssetSync({ pool: pool2, @@ -14420,7 +14895,7 @@ function createAssetService(pool2, options = {}) { if (error?.code !== "EEXIST") throw error; await fs12.writeFile(target, buffer); }); - const checksum = crypto7.createHash("sha256").update(buffer).digest("hex"); + const checksum = crypto8.createHash("sha256").update(buffer).digest("hex"); await pool2.query( `UPDATE h5_upload_sessions SET actual_size = ?, detected_mime_type = ?, checksum = ?, status = 'uploaded' @@ -14477,7 +14952,7 @@ function createAssetService(pool2, options = {}) { const storedMimeType = normalizedImage.mimeType; const storedUploadFilename = normalizedImage.filename; const storedSizeBytes = storedBuffer.length; - const storedChecksum = crypto7.createHash("sha256").update(storedBuffer).digest("hex"); + const storedChecksum = crypto8.createHash("sha256").update(storedBuffer).digest("hex"); const scan = runBasicFileScan(storedBuffer, { filename: storedUploadFilename, mimeType: storedMimeType @@ -14856,7 +15331,7 @@ function createAssetService(pool2, options = {}) { const storedMimeType = normalizedImage.mimeType; const storedFilenameInput = normalizedImage.filename; const storedSizeBytes = storedBuffer.length; - const storedChecksum = crypto7.createHash("sha256").update(storedBuffer).digest("hex"); + const storedChecksum = crypto8.createHash("sha256").update(storedBuffer).digest("hex"); const isImage = storedMimeType.startsWith("image/"); const effectiveCategoryCode = isImage ? "public" : categoryCode; const conn = await pool2.getConnection(); @@ -15266,7 +15741,7 @@ function createWorkspaceAssetSync({ pool: pool2, storageRoot, h5Root, maxFileByt }); const assetStatus = scan.scanStatus === "passed" ? "ready" : "quarantined"; const versionScanStatus = scan.scanStatus === "passed" ? "passed" : "blocked"; - const checksum = crypto8.createHash("sha256").update(buffer).digest("hex"); + const checksum = crypto9.createHash("sha256").update(buffer).digest("hex"); const assetId = idFactory(); const versionId = idFactory(); const finalStorageKey = buildWorkspaceStorageKey( @@ -15363,7 +15838,7 @@ function createWorkspaceAssetSync({ pool: pool2, storageRoot, h5Root, maxFileByt }); const assetStatus = scan.scanStatus === "passed" ? "ready" : "quarantined"; const versionScanStatus = scan.scanStatus === "passed" ? "passed" : "blocked"; - const checksum = crypto8.createHash("sha256").update(buffer).digest("hex"); + const checksum = crypto9.createHash("sha256").update(buffer).digest("hex"); const [versionRows] = await conn.query( `SELECT COALESCE(MAX(version_no), 0) AS max_version FROM h5_asset_versions @@ -15454,7 +15929,7 @@ function createWorkspaceAssetSync({ pool: pool2, storageRoot, h5Root, maxFileByt continue; } const buffer = await fsPromises2.readFile(file.absolutePath); - const checksum = crypto8.createHash("sha256").update(buffer).digest("hex"); + const checksum = crypto9.createHash("sha256").update(buffer).digest("hex"); if (deletedWorkspaceChecksums.get(file.filename)?.has(checksum)) { skipped += 1; continue; @@ -15559,10 +16034,10 @@ function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceByDirKey } // api-response.mjs -import crypto9 from "node:crypto"; +import crypto10 from "node:crypto"; function createRequestId(existing) { if (typeof existing === "string" && existing.trim()) return existing.trim(); - return `req_${crypto9.randomUUID()}`; + return `req_${crypto10.randomUUID()}`; } function attachRequestId(req, res, next) { const requestId = createRequestId(req.get("x-request-id")); @@ -15635,12 +16110,12 @@ function assertMindSpaceRoute(flags, route) { } // mindspace-pages.mjs -import crypto11 from "node:crypto"; +import crypto12 from "node:crypto"; import fs14 from "node:fs/promises"; import path18 from "node:path"; // mindspace-content-scan.mjs -import crypto10 from "node:crypto"; +import crypto11 from "node:crypto"; var RISK_ORDER = ["none", "low", "medium", "high", "critical"]; var HTML_TAG_PATTERN = /<[^>]+>/; var PRIVATE_URL_PATTERN = /(file:\/\/[^\s"'<>]+|\/api\/mindspace\/v1\/assets\/[a-z0-9-]+(?:\/[a-z-]+)?|\/users\/[^\s"'<>]+)/gi; @@ -15830,7 +16305,7 @@ function applyRule(content, rule, findings, redactions, options = {}) { if (!matches.length) return content; const blocking = options.allowHtmlActiveContent && ACKNOWLEDGEABLE_HTML_ACTIVE_TYPES.has(rule.type) ? false : rule.blocking; findings.push({ - id: crypto10.createHash("sha256").update(`${rule.type}:${matches[0][0]}`).digest("hex").slice(0, 24), + id: crypto11.createHash("sha256").update(`${rule.type}:${matches[0][0]}`).digest("hex").slice(0, 24), type: rule.type, label: rule.label, riskLevel: rule.riskLevel, @@ -16101,7 +16576,7 @@ function renderPublicationHtml(page) { function createPageService(pool2, options = {}) { const storageRoot = path18.resolve(options.storageRoot ?? path18.join(process.cwd(), "data", "mindspace")); const h5Root = options.h5Root ? path18.resolve(options.h5Root) : null; - const idFactory = options.idFactory ?? (() => crypto11.randomUUID()); + const idFactory = options.idFactory ?? (() => crypto12.randomUUID()); const resolveWorkspacePublishDir = async (userId) => { if (!h5Root) return null; const [rows] = await pool2.query( @@ -16230,7 +16705,7 @@ function createPageService(pool2, options = {}) { const contentAssetId = idFactory(); const assetVersionId = idFactory(); const pageVersionId = idFactory(); - const checksum = crypto11.createHash("sha256").update(normalized.content).digest("hex"); + const checksum = crypto12.createHash("sha256").update(normalized.content).digest("hex"); const stored = await writeVersionContent( userId, contentAssetId, @@ -17519,7 +17994,7 @@ import { Agent as Agent4, fetch as undiciFetch4 } from "undici"; import { jsonrepair } from "jsonrepair"; // llm-providers.mjs -import crypto12 from "node:crypto"; +import crypto13 from "node:crypto"; import fs15 from "node:fs"; import os from "node:os"; import path19 from "node:path"; @@ -17610,12 +18085,12 @@ var insecureDispatcher3 = new Agent3({ }); function resolveEncryptionKey(explicitKey) { const raw = explicitKey ?? process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY ?? "local-dev-secret"; - return crypto12.createHash("sha256").update(raw).digest(); + return crypto13.createHash("sha256").update(raw).digest(); } function encryptSecret(plaintext, encryptionKey) { const key = resolveEncryptionKey(encryptionKey); - const iv = crypto12.randomBytes(12); - const cipher = crypto12.createCipheriv("aes-256-gcm", key, iv); + const iv = crypto13.randomBytes(12); + const cipher = crypto13.createCipheriv("aes-256-gcm", key, iv); const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); return { ciphertext: encrypted.toString("base64"), @@ -17625,7 +18100,7 @@ function encryptSecret(plaintext, encryptionKey) { } function decryptSecret({ ciphertext, iv, tag }, encryptionKey) { const key = resolveEncryptionKey(encryptionKey); - const decipher = crypto12.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64")); + const decipher = crypto13.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64")); decipher.setAuthTag(Buffer.from(tag, "base64")); const plain = Buffer.concat([ decipher.update(Buffer.from(ciphertext, "base64")), @@ -17910,7 +18385,7 @@ function spawnDetachedExecutor(plan, { logDir = launchLogDir() } = {}) { const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); const logFile = path19.join( logDir, - `${plan.executor ?? "executor"}-${stamp}-${crypto12.randomUUID().slice(0, 8)}.log` + `${plan.executor ?? "executor"}-${stamp}-${crypto13.randomUUID().slice(0, 8)}.log` ); const fd = fs15.openSync(logFile, "a"); try { @@ -18683,7 +19158,7 @@ function createLlmProviderService(pool2, { apiTarget, apiTargets = [], apiSecret `INSERT INTO h5_llm_executor_bindings (id, executor, purpose, provider_key_id, model, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [crypto12.randomUUID(), normalizedExecutor, purpose, keyId, model, enabled ? 1 : 0, now, now] + [crypto13.randomUUID(), normalizedExecutor, purpose, keyId, model, enabled ? 1 : 0, now, now] ); } const row = await getExecutorBindingRow(normalizedExecutor, purpose); @@ -18891,7 +19366,7 @@ function createLlmProviderService(pool2, { apiTarget, apiTargets = [], apiSecret const shouldSelect = Number(countRows[0]?.total ?? 0) === 0; const encrypted = encryptSecret(apiKey, encryptionKey); const now = Date.now(); - const id = crypto12.randomUUID(); + const id = crypto13.randomUUID(); if (shouldSelect) await clearSelected(); await pool2.query( `INSERT INTO h5_llm_provider_keys @@ -19440,7 +19915,7 @@ async function suggestCoverMetaWithAi(pool2, { title, summary, html, instruction } // mindspace-publications.mjs -import crypto13 from "node:crypto"; +import crypto14 from "node:crypto"; import fs16 from "node:fs/promises"; import path20 from "node:path"; @@ -19540,7 +20015,7 @@ async function findAvailableSlug(pool2, userId, preferredSlug, excludePageId) { ); if (!rows[0]) return candidate; } - return `${normalized}-${crypto13.randomBytes(3).toString("hex")}`; + return `${normalized}-${crypto14.randomBytes(3).toString("hex")}`; } function normalizeAccessMode(value) { const mode = String(value ?? "public"); @@ -19566,16 +20041,16 @@ function normalizeExpiresAt(value, required) { return expiresAt; } function hashPassword(password) { - const salt = crypto13.randomBytes(16); - const hash = crypto13.scryptSync(password, salt, 32); + const salt = crypto14.randomBytes(16); + const hash = crypto14.scryptSync(password, salt, 32); return `scrypt$${salt.toString("hex")}$${hash.toString("hex")}`; } function verifyPassword2(password, encoded) { const [algorithm, saltHex, hashHex] = String(encoded ?? "").split("$"); if (algorithm !== "scrypt" || !saltHex || !hashHex) return false; const expected = Buffer.from(hashHex, "hex"); - const actual = crypto13.scryptSync(String(password ?? ""), Buffer.from(saltHex, "hex"), expected.length); - return expected.length === actual.length && crypto13.timingSafeEqual(expected, actual); + const actual = crypto14.scryptSync(String(password ?? ""), Buffer.from(saltHex, "hex"), expected.length); + return expected.length === actual.length && crypto14.timingSafeEqual(expected, actual); } function deviceType(userAgent) { const value = String(userAgent ?? ""); @@ -19736,7 +20211,7 @@ async function prepareHtmlPublishContent({ } function createPublicationService(pool2, options = {}) { const storageRoot = path20.resolve(options.storageRoot ?? path20.join(process.cwd(), "data", "mindspace")); - const idFactory = options.idFactory ?? (() => crypto13.randomUUID()); + const idFactory = options.idFactory ?? (() => crypto14.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) { @@ -19983,8 +20458,8 @@ ${publishContent}`, { const bundleAssetId = idFactory(); const assetVersionId = idFactory(); const publishId = idFactory(); - const privateToken = result.accessMode === "private_link" ? crypto13.randomBytes(24).toString("base64url") : null; - const tokenHash = privateToken ? crypto13.createHash("sha256").update(privateToken).digest("hex") : null; + const privateToken = result.accessMode === "private_link" ? crypto14.randomBytes(24).toString("base64url") : null; + const tokenHash = privateToken ? crypto14.createHash("sha256").update(privateToken).digest("hex") : null; const passwordHash = result.accessMode === "password" ? hashPassword(input.password) : null; const storageKey = path20.posix.join( "users", @@ -19996,7 +20471,7 @@ ${publishContent}`, { writtenPath = absoluteStoragePath2(storageKey); await fs16.mkdir(path20.dirname(writtenPath), { recursive: true }); await fs16.writeFile(writtenPath, html, { flag: "wx" }); - const checksum = crypto13.createHash("sha256").update(html).digest("hex"); + const checksum = crypto14.createHash("sha256").update(html).digest("hex"); const publicBaseUrl = resolvePublicBaseUrl(); const publicUrl2 = privateToken ? `/s/${privateToken}` : `${publicBaseUrl}/u/${encodeURIComponent(ownerSlug)}/pages/${result.urlSlug}`; await conn.query( @@ -20306,7 +20781,7 @@ ${publishContent}`, { return resolveRow(rows[0], viewerId, password, requestMeta); }; const resolvePrivateLink = async (token, viewerId, requestMeta) => { - const tokenHash = crypto13.createHash("sha256").update(String(token)).digest("hex"); + const tokenHash = crypto14.createHash("sha256").update(String(token)).digest("hex"); const [rows] = await pool2.query( `SELECT pr.*, u.id AS owner_id, av.storage_key FROM h5_publish_records pr @@ -20411,7 +20886,7 @@ ${publishContent}`, { } // plaza-posts.mjs -import crypto14 from "node:crypto"; +import crypto15 from "node:crypto"; // plaza-algorithm.mjs var DEFAULT_CONFIG = { @@ -20422,9 +20897,9 @@ var DEFAULT_CONFIG = { decay_base: 2, decay_exp: 1.5 }; -function computeHotScore(stats, config = DEFAULT_CONFIG, nowMs2 = Date.now()) { - const publishedAt = Number(stats.published_at ?? stats.publishedAt ?? nowMs2); - const ageHours = Math.max(0, (nowMs2 - publishedAt) / 36e5); +function computeHotScore(stats, config = DEFAULT_CONFIG, nowMs3 = Date.now()) { + const publishedAt = Number(stats.published_at ?? stats.publishedAt ?? nowMs3); + const ageHours = Math.max(0, (nowMs3 - publishedAt) / 36e5); const numerator = Number(stats.view_count ?? 0) * config.w_view + Number(stats.like_count ?? 0) * config.w_like + Number(stats.comment_count ?? 0) * config.w_comment + Number(stats.collect_count ?? 0) * config.w_collect; const denominator = Math.pow(ageHours + config.decay_base, config.decay_exp); if (denominator <= 0) return 0; @@ -20595,7 +21070,7 @@ function formatPostRow(row, { category, viewerReacted = null, publicationUrl = n }; } function createPlazaPostService(pool2, { - idFactory = () => crypto14.randomUUID(), + idFactory = () => crypto15.randomUUID(), loadViewerReactions = null, plazaRedis: plazaRedis2 = null, algorithmConfig = null, @@ -21074,7 +21549,7 @@ function mapPlazaError(error) { } // plaza-events.mjs -import crypto15 from "node:crypto"; +import crypto16 from "node:crypto"; var PROFILE_WINDOW_MS = 30 * 24 * 60 * 60 * 1e3; var PROFILE_CACHE_TTL_MS = 45e3; var PLAZA_EVENT_TYPES = /* @__PURE__ */ new Set([ @@ -21163,7 +21638,7 @@ function eventSignal(event) { } return weight; } -function createPlazaEventService(pool2, { idFactory = () => crypto15.randomUUID() } = {}) { +function createPlazaEventService(pool2, { idFactory = () => crypto16.randomUUID() } = {}) { const profileCache = /* @__PURE__ */ new Map(); const cacheKey = ({ userId, sessionId }) => userId ? `u:${userId}` : sessionId ? `s:${sessionId}` : "anon"; const invalidateProfileCache = ({ userId, sessionId }) => { @@ -21983,7 +22458,7 @@ function createPlazaRecommendService(pool2, { } // plaza-interactions.mjs -import crypto16 from "node:crypto"; +import crypto17 from "node:crypto"; var REACTION_TYPES = /* @__PURE__ */ new Set(["like", "collect", "share"]); var COUNTER_BY_TYPE = { like: "like_count", @@ -22011,7 +22486,7 @@ function formatCommentAuthor(row) { avatar_url: "" }; } -function createPlazaInteractionService(pool2, { idFactory = () => crypto16.randomUUID(), formatPostRow: formatPostRow2, plazaRedis: plazaRedis2 = null } = {}) { +function createPlazaInteractionService(pool2, { idFactory = () => crypto17.randomUUID(), formatPostRow: formatPostRow2, plazaRedis: plazaRedis2 = null } = {}) { const requirePublishedPost = async (postId, conn = pool2) => { const [rows] = await conn.query( `SELECT * FROM plaza_posts WHERE id = ? AND status = 'published' LIMIT 1`, @@ -22457,11 +22932,11 @@ function createPlazaInteractionService(pool2, { idFactory = () => crypto16.rando } // plaza-redis.mjs -import crypto17 from "node:crypto"; +import crypto18 from "node:crypto"; var SYNC_SET = "plaza:sync:post_ids"; var FEED_GEN_KEY = "plaza:feed:gen"; function hashIp(ip) { - return crypto17.createHash("sha256").update(String(ip ?? "unknown")).digest("hex").slice(0, 16); + return crypto18.createHash("sha256").update(String(ip ?? "unknown")).digest("hex").slice(0, 16); } function counterKey(postId, field) { return `plaza:post:${postId}:${field}`; @@ -22646,9 +23121,9 @@ async function writebackPublications(pool2) { } // plaza-seo.mjs -import crypto18 from "node:crypto"; +import crypto19 from "node:crypto"; function hashIp2(ip) { - return crypto18.createHash("sha256").update(String(ip ?? "unknown")).digest("hex").slice(0, 32); + return crypto19.createHash("sha256").update(String(ip ?? "unknown")).digest("hex").slice(0, 32); } function normalizeUtm(value, maxLen = 100) { return String(value ?? "").trim().slice(0, maxLen); @@ -22663,7 +23138,7 @@ function buildPostPublicUrl(postId, siteBase = process.env.PLAZA_PUBLIC_BASE ?? return `${base}/plaza/p/${postId}`; } function createPlazaSeoService(pool2, { - idFactory = () => crypto18.randomUUID(), + idFactory = () => crypto19.randomUUID(), siteBase = process.env.PLAZA_PUBLIC_BASE ?? "https://plaza.tkmind.cn", baiduToken = process.env.PLAZA_BAIDU_PUSH_TOKEN ?? "", baiduSite = process.env.PLAZA_BAIDU_SITE ?? "plaza.tkmind.cn" @@ -22771,7 +23246,7 @@ function createPlazaSeoService(pool2, { } // plaza-ops.mjs -import crypto19 from "node:crypto"; +import crypto20 from "node:crypto"; var REPORT_REASONS = /* @__PURE__ */ new Set(["spam", "violence", "porn", "political", "privacy", "other"]); var FEATURED_POSITIONS = /* @__PURE__ */ new Set(["homepage_banner", "category_top", "trending"]); var OPS_ROLE_RANK = { none: 0, reviewer: 1, editor: 2, ops_admin: 3 }; @@ -22787,7 +23262,7 @@ function hasOpsRole(role, minimum) { return (OPS_ROLE_RANK[role] ?? 0) >= (OPS_ROLE_RANK[minimum] ?? 0); } function createPlazaOpsService(pool2, { - idFactory = () => crypto19.randomUUID(), + idFactory = () => crypto20.randomUUID(), formatPostRow: formatPostRow2, reviewPost, invalidateFeedCaches = null @@ -23178,7 +23653,7 @@ function createPlazaOpsService(pool2, { } // word-filter.mjs -import crypto20 from "node:crypto"; +import crypto21 from "node:crypto"; function createWordFilterService(pool2) { async function listBlockedWords() { const [rows] = await pool2.query( @@ -23189,7 +23664,7 @@ function createWordFilterService(pool2) { async function createBlockedWord({ word, replacement, note }) { const trimmed = String(word ?? "").trim(); if (!trimmed) return { ok: false, message: "\u8BCD\u8BED\u4E0D\u80FD\u4E3A\u7A7A" }; - const id = crypto20.randomUUID(); + const id = crypto21.randomUUID(); const now = Date.now(); try { await pool2.query( @@ -23427,7 +23902,7 @@ function injectPlazaEmbedBootstrap(html) { } // mindspace-cleanup.mjs -import crypto21 from "node:crypto"; +import crypto22 from "node:crypto"; import fs17 from "node:fs/promises"; import path21 from "node:path"; var WORKSPACE_TEMP_SKIP = /* @__PURE__ */ new Set([ @@ -23438,7 +23913,7 @@ var WORKSPACE_TEMP_SKIP = /* @__PURE__ */ new Set([ ".goose" ]); function candidateId(kind, key) { - return `${kind}:${crypto21.createHash("sha256").update(key).digest("hex").slice(0, 24)}`; + return `${kind}:${crypto22.createHash("sha256").update(key).digest("hex").slice(0, 24)}`; } async function fileSize(targetPath) { try { @@ -23718,7 +24193,7 @@ function createCleanupService(pool2, options = {}) { } // mindspace-agent-jobs.mjs -import crypto22 from "node:crypto"; +import crypto23 from "node:crypto"; import path22 from "node:path"; var JOB_TYPES = /* @__PURE__ */ new Set(["generate_page", "analyze_asset", "summarize"]); var OUTPUT_TYPES = /* @__PURE__ */ new Set(["page_draft", "html_page", "markdown"]); @@ -23779,7 +24254,7 @@ function normalizeJobInput(input) { }; } function hashJobToken(token) { - return crypto22.createHash("sha256").update(String(token)).digest("hex"); + return crypto23.createHash("sha256").update(String(token)).digest("hex"); } function jsonValue(value, fallback) { if (value == null) return fallback; @@ -23838,10 +24313,10 @@ function verifyTokenHash(expectedHash, token) { const actualHash = hashJobToken(token); const expected = Buffer.from(String(expectedHash), "hex"); const actual = Buffer.from(actualHash, "hex"); - return expected.length === actual.length && crypto22.timingSafeEqual(expected, actual); + return expected.length === actual.length && crypto23.timingSafeEqual(expected, actual); } function createAgentJobService(pool2, options = {}) { - const idFactory = options.idFactory ?? (() => crypto22.randomUUID()); + const idFactory = options.idFactory ?? (() => crypto23.randomUUID()); const nowFactory = options.nowFactory ?? (() => Date.now()); const tokenTtlMs = Number(options.tokenTtlMs ?? 30 * 60 * 1e3); const maxOutputBytes = Number(options.maxOutputBytes ?? 2 * 1024 * 1024); @@ -24131,7 +24606,7 @@ function createAgentJobService(pool2, options = {}) { }; const finalizeClaim = async (conn, job) => { const jobId = job.id; - const token = crypto22.randomBytes(24).toString("base64url"); + const token = crypto23.randomBytes(24).toString("base64url"); const now = nowFactory(); await conn.query( `UPDATE h5_agent_jobs @@ -24392,7 +24867,7 @@ function createAgentJobService(pool2, options = {}) { } // mindspace-agent-runner.mjs -import crypto23 from "node:crypto"; +import crypto24 from "node:crypto"; import fs18 from "node:fs/promises"; import { Readable as Readable2 } from "node:stream"; import { Agent as Agent5, fetch as undiciFetch5 } from "undici"; @@ -24466,7 +24941,7 @@ function runnerError(message, code, details) { } function createUserMessage(text) { return { - id: crypto23.randomUUID(), + id: crypto24.randomUUID(), role: "user", created: Math.floor(Date.now() / 1e3), content: [{ type: "text", text }], @@ -24787,7 +25262,7 @@ function createMindSpaceAgentRunner({ if (relevant) prompt = `${relevant} ${prompt}`; - const requestId = crypto23.randomUUID(); + const requestId = crypto24.randomUUID(); const reply = await executeSessionReply2(apiFetch2, sessionId, requestId, prompt); const parsed = normalizeStructuredResult(extractJsonObject2(reply.text)); if (reply.tokenState) { @@ -25504,11 +25979,33 @@ function normalizePublicHtmlRelativePath(relativePath) { function isWriteLikeHtmlTool(name, args) { const action = String(args?.action ?? "").toLowerCase(); const normalizedName = String(name ?? "").trim(); - const writeLikeDeveloper = normalizedName === "developer" && action === "write" || normalizedName === "write" || normalizedName.endsWith("__write"); + const writeLikeDeveloper = normalizedName === "developer" && (action === "write" || action === "edit") || normalizedName === "write" || normalizedName.endsWith("__write"); const writeLikeSandbox = (normalizedName === "write_file" || normalizedName === "edit_file" || normalizedName.endsWith("__write_file") || normalizedName.endsWith("__edit_file")) && typeof args?.path === "string"; return writeLikeDeveloper && typeof args?.path === "string" || writeLikeSandbox; } -function extractPublicHtmlWriteArtifacts(messages = []) { +function isEditLikeHtmlTool(name, args) { + const normalizedName = String(name ?? "").trim(); + return (normalizedName === "edit_file" || normalizedName.endsWith("__edit_file") || normalizedName === "developer" && String(args?.action ?? "").toLowerCase() === "edit") && typeof args?.path === "string"; +} +function readPublicHtmlBaseline(publishDir, relativePath) { + const root = path24.resolve(String(publishDir ?? "")); + if (!root) return ""; + const destination = path24.resolve(root, relativePath); + if (destination !== root && !destination.startsWith(`${root}${path24.sep}`)) return ""; + if (!fs20.existsSync(destination) || !fs20.statSync(destination).isFile()) return ""; + try { + return fs20.readFileSync(destination, "utf8"); + } catch { + return ""; + } +} +function applyPublicHtmlEdit(existing, args) { + if (typeof args?.new_str !== "string") return null; + const oldStr = typeof args.old_str === "string" ? args.old_str : ""; + if (oldStr && !existing.includes(oldStr)) return null; + return oldStr ? existing.replace(oldStr, args.new_str) : args.new_str; +} +function extractPublicHtmlWriteArtifacts(messages = [], { publishDir = null } = {}) { const artifacts = /* @__PURE__ */ new Map(); for (const message of messages) { for (const item of message?.content ?? []) { @@ -25520,7 +26017,15 @@ function extractPublicHtmlWriteArtifacts(messages = []) { if (!candidate.toLowerCase().endsWith(".html")) continue; const relativePath = normalizePublicHtmlRelativePath(candidate); if (!relativePath) continue; - const content = typeof args.content === "string" ? args.content : typeof args.new_str === "string" && !args.old_str ? args.new_str : null; + let content = null; + if (typeof args.content === "string") { + content = args.content; + } else if (isEditLikeHtmlTool(toolCall?.name, args)) { + const baseline = artifacts.get(relativePath)?.content ?? readPublicHtmlBaseline(publishDir, relativePath); + content = applyPublicHtmlEdit(baseline, args); + } else if (typeof args.new_str === "string" && !args.old_str) { + content = args.new_str; + } if (content == null) continue; artifacts.set(relativePath, { relativePath, content }); } @@ -25547,7 +26052,7 @@ function materializeMissingPublicHtmlWrites({ messages, publishDir }) { if (!root) return { materialized: [], skipped: [] }; const materialized = []; const skipped = []; - for (const artifact of extractPublicHtmlWriteArtifacts(messages)) { + for (const artifact of extractPublicHtmlWriteArtifacts(messages, { publishDir: root })) { const destination = path24.resolve(root, artifact.relativePath); if (destination !== root && !destination.startsWith(`${root}${path24.sep}`)) { skipped.push(artifact.relativePath); @@ -25569,11 +26074,44 @@ function materializeMissingPublicHtmlWrites({ messages, publishDir }) { } return { materialized, skipped }; } -function hasRecentOwnPublicHtmlReference(messages, currentUser, { recentCount = 80 } = {}) { +function materializePublicHtmlWritesFromSessionEvent(event, { publishDir, recentCount = 20 } = {}) { + if (!event || !publishDir) { + return { materialized: [], skipped: [] }; + } + if (event.type === "Message" && event.message) { + return materializeMissingPublicHtmlWrites({ + messages: [event.message], + publishDir + }); + } + if (event.type === "UpdateConversation" && Array.isArray(event.conversation)) { + return materializeMissingPublicHtmlWrites({ + messages: event.conversation.slice(-Math.max(1, recentCount)), + publishDir + }); + } + return { materialized: [], skipped: [] }; +} +function messageHasPublicHtmlToolRequest(message, { publishDir = null } = {}) { + if (extractPublicHtmlWriteArtifacts([message], { publishDir }).length > 0) { + return true; + } + for (const item of message?.content ?? []) { + if (item?.type !== "toolRequest") continue; + const toolCall = item.toolCall?.value; + const args = toolCall?.arguments ?? {}; + if (!isWriteLikeHtmlTool(toolCall?.name, args)) continue; + const candidate = String(args.path ?? "").trim(); + if (!candidate.toLowerCase().endsWith(".html")) continue; + if (normalizePublicHtmlRelativePath(candidate)) return true; + } + return false; +} +function hasRecentOwnPublicHtmlReference(messages, currentUser, { recentCount = 80, publishDir = null } = {}) { if (!Array.isArray(messages) || messages.length === 0 || !currentUser?.id) return false; const recentMessages = messages.slice(-Math.max(1, recentCount)); for (const message of recentMessages) { - if (extractPublicHtmlWriteArtifacts([message]).length > 0) { + if (messageHasPublicHtmlToolRequest(message, { publishDir })) { return true; } const text = messageText(message); @@ -25596,7 +26134,7 @@ async function syncPublicHtmlAfterFinish({ publishDir, syncWorkspaceAssets } = {}) { - if (!hasRecentOwnPublicHtmlReference(messages, currentUser)) { + if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) { return { materialized: [], skipped: [], synced: false }; } const { materialized, skipped } = materializeMissingPublicHtmlWrites({ messages, publishDir }); @@ -26012,7 +26550,7 @@ function ensureThumbnailPng(svgAbsPath) { } // billing-subscription.mjs -import crypto24 from "node:crypto"; +import crypto25 from "node:crypto"; var PLAN_CATALOG = { free: { name: "\u514D\u8D39\u7248", @@ -26117,7 +26655,7 @@ function createSubscriptionService(pool2, { getPlanAsync = null } = {}) { WHERE user_id = ? AND status = 'active'`, [now, userId] ); - const id = crypto24.randomUUID(); + const id = crypto25.randomUUID(); await conn.query( `INSERT INTO h5_subscriptions (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, @@ -26201,7 +26739,7 @@ function createSubscriptionService(pool2, { getPlanAsync = null } = {}) { const conn = await pool2.getConnection(); try { await conn.beginTransaction(); - const newId = crypto24.randomUUID(); + const newId = crypto25.randomUUID(); await conn.query( `UPDATE h5_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`, [now, subId] @@ -26369,7 +26907,7 @@ function createSubscriptionService(pool2, { getPlanAsync = null } = {}) { WHERE user_id = ? AND status = 'active'`, [now, userId] ); - const id = crypto24.randomUUID(); + const id = crypto25.randomUUID(); const periodEnd = now + plan.periodDays * 24 * 60 * 60 * 1e3; await conn.query( `INSERT INTO h5_subscriptions @@ -26463,7 +27001,7 @@ function createSubscriptionService(pool2, { getPlanAsync = null } = {}) { `UPDATE h5_subscriptions SET status = 'expired', auto_renew = 0, updated_at = ? WHERE id = ?`, [now, sub.id] ); - const newId = crypto24.randomUUID(); + const newId = crypto25.randomUUID(); const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1e3; await conn.query( `INSERT INTO h5_subscriptions @@ -26703,7 +27241,7 @@ function createPlanCatalogService(pool2) { } // wechat-pay.mjs -import crypto25 from "node:crypto"; +import crypto26 from "node:crypto"; import fs23 from "node:fs"; import { fetch as fetch2 } from "undici"; var API_BASE = "https://api.mch.weixin.qq.com"; @@ -26782,12 +27320,12 @@ function loadWechatPayConfig() { }; } function randomNonce(length = 32) { - return crypto25.randomBytes(length).toString("hex").slice(0, length); + return crypto26.randomBytes(length).toString("hex").slice(0, length); } function signV2Params(params, apiKey) { const stringA = Object.keys(params).filter((key) => key !== "sign" && params[key] !== void 0 && params[key] !== "").sort().map((key) => `${key}=${params[key]}`).join("&"); const stringSignTemp = `${stringA}&key=${apiKey}`; - return crypto25.createHash("md5").update(stringSignTemp, "utf8").digest("hex").toUpperCase(); + return crypto26.createHash("md5").update(stringSignTemp, "utf8").digest("hex").toUpperCase(); } function buildXml(fields) { const parts = [""]; @@ -26847,7 +27385,7 @@ async function wechatV2Request(config, path29, fields) { return data; } function signMessage(message, privateKeyPem) { - return crypto25.createSign("RSA-SHA256").update(message).sign(privateKeyPem, "base64"); + return crypto26.createSign("RSA-SHA256").update(message).sign(privateKeyPem, "base64"); } function buildAuthorization({ mchId, serialNo, privateKey }, method, pathWithQuery, body) { const timestamp = String(Math.floor(Date.now() / 1e3)); @@ -27078,7 +27616,7 @@ ${bodyText} if (!config.platformCert) { throw new Error("\u672A\u914D\u7F6E\u5FAE\u4FE1\u652F\u4ED8\u5E73\u53F0\u8BC1\u4E66\uFF0C\u65E0\u6CD5\u9A8C\u7B7E"); } - const verified = crypto25.createVerify("RSA-SHA256").update(message).verify(config.platformCert, signature, "base64"); + const verified = crypto26.createVerify("RSA-SHA256").update(message).verify(config.platformCert, signature, "base64"); if (!verified) { throw new Error("\u5FAE\u4FE1\u652F\u4ED8\u56DE\u8C03\u9A8C\u7B7E\u5931\u8D25"); } @@ -27091,7 +27629,7 @@ ${bodyText} const cipherBuf = Buffer.from(resource.ciphertext, "base64"); const authTag = cipherBuf.subarray(cipherBuf.length - 16); const dataBuf = cipherBuf.subarray(0, cipherBuf.length - 16); - const decipher = crypto25.createDecipheriv( + const decipher = crypto26.createDecipheriv( "aes-256-gcm", Buffer.from(config.apiV3Key, "utf8"), Buffer.from(resource.nonce, "utf8") @@ -27142,7 +27680,7 @@ function createWechatPayClient(config) { var WECHAT_NOTIFY_SUCCESS_V2 = ""; // wechat-oauth.mjs -import crypto26 from "node:crypto"; +import crypto27 from "node:crypto"; import { fetch as fetch3 } from "undici"; var OAUTH_AUTHORIZE_URL = "https://open.weixin.qq.com/connect/oauth2/authorize"; var OAUTH_QRCONNECT_URL = "https://open.weixin.qq.com/connect/qrconnect"; @@ -27284,7 +27822,7 @@ function createWechatOAuthService(pool2, config, { userAuth: userAuth2 } = {}) { authMode = "mp", now = Date.now() } = {}) => { - const state = crypto26.randomBytes(24).toString("base64url"); + const state = crypto27.randomBytes(24).toString("base64url"); if (pool2) { await pruneExpiredStates(now); await pool2.query( @@ -27545,7 +28083,7 @@ function createWechatOAuthService(pool2, config, { userAuth: userAuth2 } = {}) { } // wechat-mp.mjs -import crypto28 from "node:crypto"; +import crypto29 from "node:crypto"; import fs25 from "node:fs"; import path27 from "node:path"; import { fetch as undiciFetch7 } from "undici"; @@ -27674,7 +28212,7 @@ function shouldUseScheduleAssistant(text) { } // wechat-media.mjs -import crypto27 from "node:crypto"; +import crypto28 from "node:crypto"; import fs24 from "node:fs"; import path26 from "node:path"; import { fileURLToPath as fileURLToPath5 } from "node:url"; @@ -27788,7 +28326,7 @@ async function persistWechatImage({ throw new Error(`\u6682\u4E0D\u652F\u6301\u7684\u56FE\u7247\u7C7B\u578B\uFF1A${contentType || "unknown"}`); } const timestamp = Date.now(); - const hash = crypto27.createHash("sha1").update(buffer).digest("hex").slice(0, 12); + const hash = crypto28.createHash("sha1").update(buffer).digest("hex").slice(0, 12); const fileBase = [appId || "wx", openid || "openid", msgId || timestamp, mediaId || hash].filter(Boolean).join("-").replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120); const filename = `${fileBase}.${resolved.extension}`; const absolutePath = path26.join(publishDir, filename); @@ -28053,7 +28591,7 @@ function deriveWechatEndpointFromUrl(sourceUrl, pathname) { } function createUserMessage2(text, metadata = {}) { return { - id: crypto28.randomUUID(), + id: crypto29.randomUUID(), role: "user", created: Math.floor(Date.now() / 1e3), content: [{ type: "text", text }], @@ -28972,7 +29510,7 @@ function loadWechatMpConfig(env = process.env) { }; } function sha1Hex(parts) { - return crypto28.createHash("sha1").update([...parts].sort().join("")).digest("hex"); + return crypto29.createHash("sha1").update([...parts].sort().join("")).digest("hex"); } function verifyWechatMpSignature({ token, timestamp, nonce, signature }) { return sha1Hex([token, timestamp, nonce]) === String(signature ?? ""); @@ -28990,7 +29528,7 @@ function decodeWechatMpAesKey(encodingAesKey) { function decryptWechatMpPayload({ encodingAesKey, appId, encrypted }) { const key = decodeWechatMpAesKey(encodingAesKey); const iv = key.subarray(0, 16); - const decipher = crypto28.createDecipheriv("aes-256-cbc", key, iv); + const decipher = crypto29.createDecipheriv("aes-256-cbc", key, iv); decipher.setAutoPadding(false); const cipherBuf = Buffer.from(String(encrypted ?? ""), "base64"); let decoded = Buffer.concat([decipher.update(cipherBuf), decipher.final()]); @@ -29175,9 +29713,9 @@ function createWechatMpService({ throw new Error("\u7B7E\u540D URL \u5FC5\u987B\u662F\u5B8C\u6574 http(s) \u5730\u5740"); } const ticket = await getJsapiTicket(); - const nonceStr = crypto28.randomBytes(12).toString("hex"); + const nonceStr = crypto29.randomBytes(12).toString("hex"); const timestamp = Math.floor(Date.now() / 1e3); - const signature = crypto28.createHash("sha1").update( + const signature = crypto29.createHash("sha1").update( [ `jsapi_ticket=${ticket}`, `noncestr=${nonceStr}`, @@ -29466,7 +30004,7 @@ function createWechatMpService({ }, config.progressDelayMs); progressTimer.unref?.(); } - const requestId = crypto28.randomUUID(); + const requestId = crypto29.randomUUID(); const guardScheduleReply = async (replyText) => { if (!scheduleService2 || !looksLikeScheduleConfirmation(replyText)) return replyText; const sourceMessageId = String(intent.msgId ?? "").trim(); @@ -29553,7 +30091,7 @@ function createWechatMpService({ }); await ensureSessionProvider(sessionId); await rememberWechatUserContext(sessionId, user); - const retryId = crypto28.randomUUID(); + const retryId = crypto29.randomUUID(); const retryStartedAt = Date.now(); const reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), @@ -29681,7 +30219,7 @@ function createWechatMpService({ return { ok: false, status: 403, body: "invalid signature" }; } const inbound = parseWechatMessage(String(bodyText ?? "")); - const rawXmlHash = crypto28.createHash("sha1").update(inbound.rawXml || "").digest("hex"); + const rawXmlHash = crypto29.createHash("sha1").update(inbound.rawXml || "").digest("hex"); const intent = normalizeWechatInboundIntent(inbound); intent.appId = config.appId; if (!inbound.fromUserName || !inbound.toUserName || !inbound.msgType) { @@ -30093,7 +30631,7 @@ function validateWechatShareSignatureUrl(pageUrl, { publicBaseUrl = "", requestH } // schedule-service.mjs -import crypto29 from "node:crypto"; +import crypto30 from "node:crypto"; // schedule-time.mjs var DEFAULT_TIMEZONE2 = "Asia/Shanghai"; @@ -30236,7 +30774,7 @@ function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE2) { // schedule-service.mjs var DEFAULT_TIMEZONE3 = "Asia/Shanghai"; -function nowMs() { +function nowMs2() { return Date.now(); } function rowToItem(row) { @@ -30339,7 +30877,7 @@ function rowToUserNotification(row) { updatedAt: Number(row.updated_at) }; } -function formatTodoDigest(items, { now = nowMs(), timezone = DEFAULT_TIMEZONE3 } = {}) { +function formatTodoDigest(items, { now = nowMs2(), timezone = DEFAULT_TIMEZONE3 } = {}) { const date = localDateLabel(now, timezone); if (!items.length) { return `${date} \u5F85\u529E\u8BB0\u5F55 @@ -30355,7 +30893,7 @@ function formatTodoDigest(items, { now = nowMs(), timezone = DEFAULT_TIMEZONE3 } } function createScheduleService(pool2, options = {}) { const defaultTimezone = normalizeTimezone(options.defaultTimezone || process.env.H5_DEFAULT_TIMEZONE); - const clock = options.clock || { now: nowMs }; + const clock = options.clock || { now: nowMs2 }; const createItem = async ({ userId, kind = "task", @@ -30377,7 +30915,7 @@ function createScheduleService(pool2, options = {}) { const cleanTitle = String(title ?? "").trim(); if (!cleanTitle) throw new Error("\u7F3A\u5C11\u5F85\u529E\u6807\u9898"); const safeKind = kind === "event" ? "event" : "task"; - const id = crypto29.randomUUID(); + const id = crypto30.randomUUID(); const ts = clock.now(); await pool2.query( `INSERT INTO h5_schedule_items @@ -30497,7 +31035,7 @@ function createScheduleService(pool2, options = {}) { [itemId, userId] ); if (!itemRows[0]) throw new Error("\u4E8B\u9879\u4E0D\u5B58\u5728\u6216\u65E0\u6743\u8BBF\u95EE"); - const id = crypto29.randomUUID(); + const id = crypto30.randomUUID(); const ts = clock.now(); await pool2.query( `INSERT INTO h5_schedule_reminders @@ -30592,7 +31130,7 @@ function createScheduleService(pool2, options = {}) { timezone: tz, now: clock.now() }); - const id = crypto29.randomUUID(); + const id = crypto30.randomUUID(); const ts = clock.now(); await pool2.query( `INSERT INTO h5_schedule_digest_subscriptions @@ -30661,7 +31199,7 @@ function createScheduleService(pool2, options = {}) { throw new Error("\u4F59\u989D\u9608\u503C\u65E0\u6548"); } const now = clock.now(); - const id = crypto29.randomUUID(); + const id = crypto30.randomUUID(); await pool2.query( `INSERT INTO h5_balance_alert_subscriptions (id, user_id, threshold_cents, channel, status, next_run_at, last_run_at, @@ -30803,7 +31341,7 @@ function createScheduleService(pool2, options = {}) { error_code, error_message, created_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`, [ - crypto29.randomUUID(), + crypto30.randomUUID(), subscriptionId, userId, channel, @@ -30843,7 +31381,7 @@ function createScheduleService(pool2, options = {}) { if (!title) throw new Error("\u7F3A\u5C11\u901A\u77E5\u6807\u9898"); if (!body) throw new Error("\u7F3A\u5C11\u901A\u77E5\u5185\u5BB9"); const now = clock.now(); - const id = crypto29.randomUUID(); + const id = crypto30.randomUUID(); await pool2.query( `INSERT INTO h5_user_notifications (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at) @@ -30947,7 +31485,7 @@ function createScheduleService(pool2, options = {}) { } // user-feedback.mjs -import crypto30 from "node:crypto"; +import crypto31 from "node:crypto"; var FEEDBACK_TYPES = /* @__PURE__ */ new Set(["bug", "feature", "other"]); var FEEDBACK_MAX_IMAGES = 5; var FEEDBACK_MAX_TITLE = 120; @@ -31069,7 +31607,7 @@ function createFeedbackService(pool2) { const images = normalizeImages(input.images); const context = normalizeContext(input.context); const now = Date.now(); - const id = crypto30.randomUUID(); + const id = crypto31.randomUUID(); await pool2.query( `INSERT INTO h5_user_feedback (id, user_id, type, title, description, contact, status, images_json, context_json, created_at, updated_at) @@ -31289,6 +31827,145 @@ function startScheduleReminderWorker({ }; } +// chat-skills.mjs +var PUBLISH_SKILL_NAME2 = "static-page-publish"; +var CHAT_SKILL_DEFINITIONS = [ + { + id: "web-search", + label: "\u67E5\u8D44\u6599", + icon: "web", + skillName: "web", + requiresSkill: "web", + prefillOnly: true, + promptKey: "web" + }, + { + id: "code-search", + label: "\u641C\u4EE3\u7801", + icon: "search", + skillName: "search", + requiresSkill: "search", + prefillOnly: true, + promptKey: "search" + }, + { + id: "form-collect", + label: "\u8868\u5355\u6536\u96C6", + icon: "form", + skillName: "form-builder", + requiresSkill: "form-builder", + prefillOnly: true, + promptKey: "form-builder" + }, + { + id: "table-view", + label: "\u6570\u636E\u8868\u683C", + icon: "table", + skillName: "table-viewer", + requiresSkill: "table-viewer", + prefillOnly: true, + promptKey: "table-viewer" + }, + { + id: "product-campaign-page", + label: "\u5546\u54C1\u9875", + icon: "page", + skillName: "product-campaign-page", + requiresSkill: "product-campaign-page", + promptKey: "product-campaign-page" + }, + { + id: "summarize", + label: "\u603B\u7ED3\u5185\u5BB9", + icon: "summary", + prefillOnly: true, + promptKey: "summarize" + }, + { + id: "analyze", + label: "\u6DF1\u5EA6\u5206\u6790", + icon: "analyze", + prefillOnly: true, + promptKey: "analyze" + }, + { + id: "generate-page", + label: "\u751F\u6210\u9875\u9762", + icon: "page", + skillName: PUBLISH_SKILL_NAME2, + requiresPublish: true, + promptKey: "generate-page" + } +]; +function buildChatSkillPrompt(promptKey, skillName) { + switch (promptKey) { + case "web": + return `\u8BF7\u4F7F\u7528 ${skillName ?? "web"} \u6280\u80FD\uFF1A\u5E2E\u6211\u641C\u7D22\u5E76\u67E5\u9605\u76F8\u5173\u8D44\u6599\uFF08\u4F18\u5148\u5B98\u65B9\u6587\u6863\uFF09\uFF0C\u5E76\u7ED9\u51FA\u4E2D\u6587\u6458\u8981\u4E0E\u6765\u6E90\u94FE\u63A5\u3002\u6211\u7684\u95EE\u9898\u662F\uFF1A`; + case "search": + return `\u8BF7\u4F7F\u7528 ${skillName ?? "search"} \u6280\u80FD\uFF1A\u5E2E\u6211\u5728\u5DE5\u4F5C\u533A\u4E2D\u67E5\u627E\u4EE3\u7801\u6216\u6587\u4EF6\u3002\u6211\u8981\u627E\u7684\u662F\uFF1A`; + case "form-builder": + return `\u8BF7\u4F7F\u7528 ${skillName ?? "form-builder"} \u6280\u80FD\uFF1A\u8BF7\u7528\u4EA4\u4E92\u5F0F\u8868\u5355\u6536\u96C6\u4EE5\u4E0B\u573A\u666F\u6240\u9700\u7684\u7ED3\u6784\u5316\u5B57\u6BB5\uFF08\u5B57\u6BB5\u4E0D\u8D85\u8FC7 8 \u4E2A\uFF09\uFF1A`; + case "table-viewer": + return `\u8BF7\u4F7F\u7528 ${skillName ?? "table-viewer"} \u6280\u80FD\uFF1A\u8BF7\u628A\u4EE5\u4E0B\u6570\u636E\u6574\u7406\u6210\u53EF\u6392\u5E8F\u3001\u53EF\u7B5B\u9009\u7684\u4EA4\u4E92\u5F0F\u8868\u683C\uFF1A`; + case "product-campaign-page": + return `\u8BF7\u4F7F\u7528 ${skillName ?? "product-campaign-page"} \u6280\u80FD\uFF1A\u5E2E\u6211\u5236\u4F5C\u4E00\u4E2A\u5546\u54C1\u5BA3\u4F20 / \u6D3B\u52A8\u9875\u3002\u8BF7\u5148\u8BE2\u95EE\u5546\u54C1\u94FE\u63A5\u3001\u9875\u9762\u4E3B\u9898\u3001\u662F\u5426\u6709\u5546\u54C1\u56FE\u7247\u6216\u54C1\u724C\u7D20\u6750\uFF1B\u4FE1\u606F\u8DB3\u591F\u540E\uFF0C\u751F\u6210\u5305\u542B\u8D2D\u4E70\u8DF3\u8F6C\u6309\u94AE\u7684\u9875\u9762\u65B9\u6848\u3002\u5982\u679C\u6211\u786E\u8BA4\u8981\u53D1\u5E03\u6210 H5 \u9875\u9762\uFF0C\u8BF7\u7EE7\u7EED\u4F7F\u7528 static-page-publish \u6280\u80FD\u751F\u6210\u53EF\u8BBF\u95EE\u94FE\u63A5\u3002`; + case "summarize": + return "\u8BF7\u603B\u7ED3\u4EE5\u4E0B\u5185\u5BB9\uFF0C\u63D0\u70BC\u6838\u5FC3\u7ED3\u8BBA\u3001\u91CD\u70B9\u4FE1\u606F\u548C\u53EF\u6267\u884C\u5EFA\u8BAE\uFF08\u6761\u7406\u6E05\u6670\u3001\u4E2D\u6587\u8F93\u51FA\uFF09\uFF1A"; + case "analyze": + return "\u8BF7\u6DF1\u5165\u5206\u6790\u4EE5\u4E0B\u5185\u5BB9\uFF0C\u7ED9\u51FA\u7ED3\u6784\u5316\u89E3\u8BFB\uFF1A\u80CC\u666F\u3001\u5173\u952E\u53D1\u73B0\u3001\u98CE\u9669\u6216\u673A\u4F1A\u3001\u4EE5\u53CA\u5EFA\u8BAE\u4E0B\u4E00\u6B65\uFF1A"; + case "generate-page": + return `\u8BF7\u4F7F\u7528 ${skillName ?? PUBLISH_SKILL_NAME2} \u6280\u80FD\uFF1A\u5728\u6211\u7684\u4E13\u5C5E MindSpace \u53D1\u5E03\u76EE\u5F55\u751F\u6210\u9759\u6001 HTML \u9875\u9762\uFF0C\u5E76\u7ED9\u51FA\u53EF\u516C\u7F51\u8BBF\u95EE\u7684\u5B8C\u6574\u94FE\u63A5\u3002\u5FC5\u987B\u5148 load_skill\uFF0C\u518D\u7528 write_file/edit_file \u5199\u5165 public/\u9875\u9762.html\uFF1B\u5B8C\u6210\u540E\u76F4\u63A5\u8FD4\u56DE Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 \`[\u9875\u9762\u6807\u9898](URL)\`\u3002\u7981\u6B62\u53EA\u7ED9\u672C\u5730\u8DEF\u5F84\uFF08\u5982 hello/index.html\uFF09\uFF0C\u7981\u6B62\u8BE2\u95EE\u662F\u5426\u8FD8\u8981\u53D1\u5E03\uFF0C\u9664\u975E\u5199\u6587\u4EF6\u5DE5\u5177\u5B9E\u9645\u5931\u8D25\u3002`; + default: + return ""; + } +} +function buildWebNewsSkillPrompt(skillName) { + return `\u8BF7\u4F7F\u7528 ${skillName ?? "web"} \u6280\u80FD\uFF1A\u5148\u641C\u7D22\u4ECA\u5929/\u6700\u65B0\u76F8\u5173\u7684\u65B0\u95FB\u4E0E\u70ED\u70B9\uFF0C\u4F18\u5148\u4E00\u624B\u6765\u6E90\u548C\u6743\u5A01\u5A92\u4F53\uFF0C\u6574\u7406 3-5 \u6761\u6700\u76F8\u5173\u7ED3\u679C\uFF0C\u6309\u65F6\u95F4\u6216\u70ED\u5EA6\u6392\u5E8F\uFF1B\u7136\u540E\u7ED9\u51FA\u4E2D\u6587\u6458\u8981\u3001\u5173\u952E\u4FE1\u606F\u3001\u4E8B\u4EF6\u80CC\u666F\u548C\u6765\u6E90\u94FE\u63A5\u3002\u6211\u7684\u95EE\u9898\u662F\uFF1A`; +} +function stripKnownChatSkillPrompt(text) { + let next = String(text ?? "").trimStart(); + const candidates = []; + for (const definition of CHAT_SKILL_DEFINITIONS) { + if (!definition.promptKey) continue; + candidates.push(buildChatSkillPrompt(definition.promptKey, definition.skillName)); + } + candidates.push(buildWebNewsSkillPrompt("web")); + candidates.sort((a, b) => b.length - a.length); + for (const prompt of candidates) { + if (prompt && next.startsWith(prompt)) { + next = next.slice(prompt.length); + break; + } + } + return next.trim(); +} + +// conversation-display.mjs +var TASK_ROUTING_HINT_RE = /^【TKMind 路由提示】[\s\S]*?\n\n/; +var MINDSPACE_CONTEXT_RE = /^\[MindSpace 上下文\][\s\S]*?\n\n/; +var USER_IDENTITY_BLOCK_RE = /^\[用户身份\][\s\S]*?\n\n/; +function stripUserIdentityPrefix(text) { + return String(text ?? "").replace(USER_IDENTITY_BLOCK_RE, "").trimStart(); +} +function stripTaskRoutingHint(text) { + return String(text ?? "").replace(TASK_ROUTING_HINT_RE, "").trimStart(); +} +function stripMindSpaceContextPrefix(text) { + let next = String(text ?? ""); + while (MINDSPACE_CONTEXT_RE.test(next)) { + next = next.replace(MINDSPACE_CONTEXT_RE, "").trimStart(); + } + return next; +} +function deriveUserFacingText(text) { + let next = String(text ?? ""); + next = stripUserIdentityPrefix(next); + next = stripTaskRoutingHint(next); + next = stripMindSpaceContextPrefix(next); + next = stripKnownChatSkillPrompt(next); + return next.trim(); +} + // session-snapshot.mjs var DERIVED_TITLE_MAX_LEN = 40; function isGeneratedSessionName(name) { @@ -31300,6 +31977,9 @@ function isDefaultSessionName(name) { } function messageSnippet(message) { const fromContent = (message?.content ?? []).filter((item) => item?.type === "text" && typeof item.text === "string").map((item) => item.text).join("").trim(); + if (message?.role === "user") { + return deriveUserFacingText(fromContent); + } if (fromContent) return fromContent; const displayText = message?.metadata?.displayText; return typeof displayText === "string" ? displayText.trim() : ""; @@ -31562,7 +32242,7 @@ function createSessionSnapshotService(pool2, options = {}) { } // conversation-memory.mjs -import crypto31 from "node:crypto"; +import crypto32 from "node:crypto"; import { fetch as undiciFetch8 } from "undici"; import { jsonrepair as jsonrepair3 } from "jsonrepair"; var MAX_MESSAGE_TEXT = 12e3; @@ -31581,7 +32261,7 @@ function resolveEncryptionKey3(explicitKey) { return explicitKey ?? process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY ?? "local-dev-secret"; } function stableId(...parts) { - return crypto31.createHash("sha256").update(parts.map((part) => String(part ?? "")).join("\0")).digest("hex"); + return crypto32.createHash("sha256").update(parts.map((part) => String(part ?? "")).join("\0")).digest("hex"); } function parseJsonObject3(text) { const source = String(text ?? "").trim(); @@ -31892,7 +32572,7 @@ function createConversationMemoryService(pool2, options = {}) { } // experience-service.mjs -import crypto32 from "node:crypto"; +import crypto33 from "node:crypto"; function createExperienceService(pool2, options = {}) { const now = options.now ?? (() => Date.now()); const halfLifeMs = Math.max( @@ -31927,7 +32607,7 @@ function createExperienceService(pool2, options = {}) { return []; } } - function rankRows(rows, queryTerms, nowMs2) { + function rankRows(rows, queryTerms, nowMs3) { const terms = queryTerms; return rows.map((row) => { const haystack = `${row.title} @@ -31936,7 +32616,7 @@ ${row.body}`.toLowerCase(); for (const term of terms) { if (haystack.includes(term)) keywordScore += 1; } - const ageMs = Math.max(0, nowMs2 - Number(row.updated_at)); + const ageMs = Math.max(0, nowMs3 - Number(row.updated_at)); const recency = Math.pow(0.5, ageMs / halfLifeMs); const score = keywordScore * recency; return { row, score }; @@ -31954,7 +32634,7 @@ ${row.body}`.toLowerCase(); const body = String(input?.body ?? "").trim(); if (!title) throw experienceError("\u7ECF\u9A8C\u6807\u9898\u4E0D\u80FD\u4E3A\u7A7A", "invalid_experience_input"); if (!body) throw experienceError("\u7ECF\u9A8C\u5185\u5BB9\u4E0D\u80FD\u4E3A\u7A7A", "invalid_experience_input"); - const id = crypto32.randomUUID(); + const id = crypto33.randomUUID(); const ts = now(); const scope = String(input?.scope ?? "global").trim() || "global"; const kind = String(input?.kind ?? "lesson").trim() || "lesson"; @@ -32182,6 +32862,7 @@ if (ACCESS_PASSWORD && !isDatabaseConfigured()) { } var userAuth = null; var tkmindProxy = null; +var agentRunGateway = null; var sessionSnapshotService = null; var conversationMemoryService = null; var mindSpace = null; @@ -32480,6 +33161,11 @@ async function bootstrapUserAuth() { return { buffer, mimeType: asset.mimeType }; } : null }); + agentRunGateway = createAgentRunGateway({ + pool: pool2, + userAuth, + tkmindProxy + }); wechatMpService = createWechatMpService({ config: WECHAT_MP_CONFIG, userAuth, @@ -33715,7 +34401,7 @@ function resolvePlazaSessionId(req, res) { const cookies = parseCookies(req.get("cookie")); let sessionId = cookies[PLAZA_SID_COOKIE]; if (!sessionId) { - sessionId = crypto34.randomUUID(); + sessionId = crypto35.randomUUID(); res.append( "Set-Cookie", `${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly` @@ -34581,7 +35267,7 @@ api.post("/mindspace/v1/pages/quick-share-from-chat", async (req, res) => { const sharedDir = path28.join(publishDir, PUBLIC_ZONE_DIR, "shared"); await fsPromises3.mkdir(sharedDir, { recursive: true }); const basename = bundle.resolvedHtml.filename.replace(/\.html$/i, ""); - const filename = `${basename}-${crypto34.randomUUID().slice(0, 8)}.html`; + const filename = `${basename}-${crypto35.randomUUID().slice(0, 8)}.html`; const sharedRelativePath = `${PUBLIC_ZONE_DIR}/shared/${filename}`; const sharedHtml = rewriteWorkspacePublicAssetReferences(localizedHtml, sharedRelativePath); const destPath = path28.join(sharedDir, filename); @@ -35503,6 +36189,46 @@ api.post("/agent/start", async (req, res, next) => { if (!userAuth || !tkmindProxy) return next(); return runHandlerChain(tkmindProxy.handlers["POST /agent/start"], req, res, next); }); +api.post("/agent/runs", async (req, res, next) => { + await userAuthReady; + if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); + return runHandlerChain( + [ + tkmindProxy.requireUser, + tkmindProxy.ensureChatAllowed, + createPostAgentRunsHandler({ userAuth, agentRunGateway }) + ], + req, + res, + next + ); +}); +api.get("/agent/runs/:runId", async (req, res, next) => { + await userAuthReady; + if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); + return runHandlerChain( + [ + tkmindProxy.requireUser, + createGetAgentRunHandler({ agentRunGateway }) + ], + req, + res, + next + ); +}); +api.get("/agent/runs/:runId/events", async (req, res, next) => { + await userAuthReady; + if (!userAuth || !tkmindProxy || !agentRunGateway) return next(); + return runHandlerChain( + [ + tkmindProxy.requireUser, + createAgentRunEventsHandler({ agentRunGateway }) + ], + req, + res, + next + ); +}); api.post("/agent/resume", async (req, res, next) => { await userAuthReady; if (!userAuth || !tkmindProxy) return next(); @@ -35527,9 +36253,10 @@ api.get("/sessions/:sessionId", async (req, res, next) => { if (sessionSnapshotService?.isEnabled()) { const snapshot = await sessionSnapshotService.get(sessionId); if (snapshot) { - const mcMatch = hintMc == null || snapshot.meta.synced_msg_count === hintMc; - const uaMatch = hintUa == null || snapshot.meta.source_updated_at === hintUa; - if (mcMatch && uaMatch) { + const canUseSnapshotCache = hintMc != null && hintUa != null; + const mcMatch = snapshot.meta.synced_msg_count === hintMc; + const uaMatch = snapshot.meta.source_updated_at === hintUa; + if (canUseSnapshotCache && mcMatch && uaMatch) { const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks( snapshot.messages, req.currentUser @@ -35608,6 +36335,10 @@ api.get("/sessions/:sessionId/events", async (req, res, next) => { if (!owns) { return res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); } + const publishDir = resolvePublishDir(__dirname5, { id: req.currentUser.id }); + const syncPublicHtmlDuringStream = (event) => { + materializePublicHtmlWritesFromSessionEvent(event, { publishDir }); + }; const onAfterFinish = async (sid, uid) => { const apiFetchFn = async (pathname, init) => { const target = await tkmindProxy.resolveTarget(sid); @@ -35631,12 +36362,15 @@ api.get("/sessions/:sessionId/events", async (req, res, next) => { await syncPublicHtmlAfterFinish({ messages, currentUser: req.currentUser, - publishDir: resolvePublishDir(__dirname5, { id: uid }), + publishDir, syncWorkspaceAssets: WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets ? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options) : null }); await syncUserGeneratedPages(uid); }; - return tkmindProxy.proxySessionEvents(req, res, sessionId, { onAfterFinish }); + return tkmindProxy.proxySessionEvents(req, res, sessionId, { + onAfterFinish, + onEvent: syncPublicHtmlDuringStream + }); }); api.use(async (req, res, next) => { await userAuthReady; @@ -35644,49 +36378,28 @@ api.use(async (req, res, next) => { if (isNativeH5ApiPath(req.path)) { return sendError(res, req, 404, "not_found", `\u63A5\u53E3\u4E0D\u5B58\u5728\uFF1A${req.method} ${req.path}`); } + if (req.method === "GET" && /^\/agent\/runs\/[^/]+\/events$/.test(req.path)) { + return res.status(503).json({ + message: "Agent Run SSE \u63A5\u53E3\u9700\u5728 Portal \u672C\u5730\u5904\u7406\uFF0C\u8BF7\u786E\u8BA4 server.mjs \u5DF2\u66F4\u65B0\u5E76\u91CD\u542F\u540E\u7AEF", + code: "AGENT_RUNS_NATIVE_REQUIRED" + }); + } const sessionMatch = req.path.match(/^\/sessions\/([^/]+)/); if (sessionMatch) { const sessionId = sessionMatch[1]; if (req.path.endsWith("/events") && req.method === "GET") { return next(); } + if (req.path.endsWith("/reply") && req.method === "POST") { + return res.status(410).json({ + message: "\u804A\u5929\u63D0\u4EA4\u5165\u53E3\u5DF2\u7EDF\u4E00\u4E3A POST /agent/runs", + code: "AGENT_RUNS_REQUIRED" + }); + } const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); if (!owns) { return res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); } - if (req.path.endsWith("/reply") && req.method === "POST") { - const gate = await userAuth.canUseChat(req.currentUser.id); - if (!gate.ok) { - return res.status(402).json({ - message: gate.message, - code: gate.code, - balanceCents: gate.balanceCents, - minRechargeCents: gate.minRechargeCents, - suggestedTiers: gate.suggestedTiers - }); - } - try { - await tkmindProxy.reconcileSessionPolicyForUser(req.currentUser.id, sessionId); - } catch (err) { - console.warn( - "Session policy sync before reply failed:", - err instanceof Error ? err.message : err - ); - return res.status(500).json({ - message: err instanceof Error ? `\u4F1A\u8BDD\u7B56\u7565\u540C\u6B65\u5931\u8D25\uFF1A${err.message}` : "\u4F1A\u8BDD\u7B56\u7565\u540C\u6B65\u5931\u8D25" - }); - } - if (llmProviderService) { - try { - await tkmindProxy.applySessionLlmProvider(sessionId); - } catch (err) { - console.warn( - "LLM provider sync before reply skipped:", - err instanceof Error ? err.message : err - ); - } - } - } } if (req.method === "POST" && req.path === "/agent/resume" && req.body?.session_id) { const owns = await userAuth.ownsSession(req.currentUser.id, req.body.session_id); @@ -35740,7 +36453,7 @@ function publishedPageCsp(html, { embed = false, raw = false, wechatShare = fals return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'"; } var PUBLIC_FILE_SHARE_SCRIPT = `(function(){var root=document.querySelector('[data-mindspace-public-share]');if(!root)return;var button=root.querySelector('button');var status=root.querySelector('small');var timer=null;function setStatus(text,err){if(!status)return;status.textContent=text||'';status.classList.toggle('is-error',!!err);if(timer)clearTimeout(timer);if(text)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},2200);}function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('\u590D\u5236\u5931\u8D25');}async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}button&&button.addEventListener('click',async function(){var url=location.href.split('#')[0];var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('\u5DF2\u6253\u5F00\u5206\u4EAB');return;}await copyText(url);setStatus('\u94FE\u63A5\u5DF2\u590D\u5236');}catch(e){setStatus(e&&e.message?e.message:'\u5206\u4EAB\u5931\u8D25',true);}});})();`; -var PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto34.createHash("sha256").update(PUBLIC_FILE_SHARE_SCRIPT).digest("base64"); +var PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto35.createHash("sha256").update(PUBLIC_FILE_SHARE_SCRIPT).digest("base64"); function injectPublicFileShareButton(html) { const source = String(html ?? ""); if (!source || source.includes("data-mindspace-public-share")) { diff --git a/mindspace-html-download-links.mjs b/mindspace-html-download-links.mjs new file mode 100644 index 0000000..fee7187 --- /dev/null +++ b/mindspace-html-download-links.mjs @@ -0,0 +1,157 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { buildPublicUrl } from './user-publish.mjs'; + +export const DOWNLOADABLE_FILE_PATTERN = /\.(?:docx?|pdf|xlsx?|pptx?|zip|csv|txt|md)$/i; + +const URL_ATTR_PATTERN = /(\b(?:href|src)\s*=\s*["'])([^"']+)(["'])/gi; + +export function isRelativeDownloadReference(value) { + const trimmed = String(value ?? '').trim(); + if (!trimmed) return false; + const pathPart = trimmed.split('#')[0].split('?')[0]; + if (/^([a-z][a-z0-9+.-]*:|\/|#|data:|mailto:|javascript:)/i.test(pathPart)) return false; + return DOWNLOADABLE_FILE_PATTERN.test(pathPart); +} + +export function splitReferenceParts(relativeRef) { + const value = String(relativeRef ?? ''); + const match = value.match(/^([^#?]+)(.*)$/); + return { + pathPart: match?.[1] ?? value, + suffix: match?.[2] ?? '', + }; +} + +export function resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef) { + const { pathPart } = splitReferenceParts(relativeRef); + const ref = String(pathPart ?? '').replace(/^\/+/, ''); + if (!ref) return ref; + if (/^(?:public|oa|private)\//.test(ref)) return ref; + const htmlPath = String(htmlRelativePath ?? 'public/index.html').replace(/^\/+/, ''); + const htmlDir = path.posix.dirname(htmlPath); + if (!htmlDir || htmlDir === '.') return `public/${ref}`; + return path.posix.join(htmlDir, ref).replace(/\\/g, '/'); +} + +export function buildWorkspaceDownloadLinkIndex(assets = []) { + const byBasename = new Map(); + const byPath = new Map(); + for (const asset of assets) { + const id = asset.id; + const filename = String(asset.original_filename ?? asset.originalFilename ?? '').replace(/\\/g, '/'); + if (!id || !filename) continue; + byPath.set(filename, id); + byBasename.set(path.posix.basename(filename), id); + if (filename.startsWith('public/')) { + byPath.set(filename.slice('public/'.length), id); + } + } + return { byBasename, byPath }; +} + +export function lookupAssetIdForWorkspacePath(index, workspaceRelativePath) { + const normalized = String(workspaceRelativePath ?? '').replace(/\\/g, '/'); + if (!normalized || !index) return null; + return ( + index.byPath.get(normalized) + ?? index.byPath.get(`public/${normalized}`) + ?? index.byBasename.get(path.posix.basename(normalized)) + ?? null + ); +} + +export function buildAssetDownloadUrl(assetId) { + return `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download`; +} + +export function buildPublicWorkspaceFileUrl(publicBaseUrl, publishKey, workspaceRelativePath) { + const clean = String(workspaceRelativePath ?? '').replace(/^\/+/, ''); + return buildPublicUrl(publicBaseUrl, publishKey, clean); +} + +function workspaceFileExists(publishDir, workspaceRelativePath) { + if (!publishDir || !workspaceRelativePath) return false; + const absolutePath = path.join(publishDir, ...String(workspaceRelativePath).split('/')); + try { + return fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile(); + } catch { + return false; + } +} + +export function resolveDownloadTargetUrl({ + relativeRef, + htmlRelativePath = 'public/index.html', + publishDir = null, + linkIndex = null, + publicBaseUrl = '', + publishKey = null, + preferAssetDownload = true, +}) { + const { suffix } = splitReferenceParts(relativeRef); + const workspacePath = resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef); + const assetId = lookupAssetIdForWorkspacePath(linkIndex, workspacePath); + const publicCandidates = [ + workspacePath, + path.posix.join('public', path.posix.basename(workspacePath)), + ]; + + if (preferAssetDownload && assetId) { + return `${buildAssetDownloadUrl(assetId)}${suffix}`; + } + + if (publishDir && publishKey) { + for (const candidate of publicCandidates) { + if (workspaceFileExists(publishDir, candidate)) { + return `${buildPublicWorkspaceFileUrl(publicBaseUrl, publishKey, candidate)}${suffix}`; + } + } + } + + if (assetId) { + return `${buildAssetDownloadUrl(assetId)}${suffix}`; + } + + return null; +} + +export function rewriteRelativeDownloadLinks(html, options = {}) { + const source = String(html ?? ''); + if (!source) return { html: source, count: 0 }; + + let count = 0; + const rewritten = source.replace(URL_ATTR_PATTERN, (full, prefix, url, suffix) => { + if (!isRelativeDownloadReference(url)) return full; + const resolved = resolveDownloadTargetUrl({ ...options, relativeRef: url }); + if (!resolved) return full; + count += 1; + return `${prefix}${resolved}${suffix}`; + }); + + return { html: rewritten, count }; +} + +export async function loadWorkspaceDownloadLinkIndex(pool, userId) { + const [rows] = await pool.query( + `SELECT id, original_filename + FROM h5_assets + WHERE user_id = ? AND status <> 'deleted'`, + [userId], + ); + return buildWorkspaceDownloadLinkIndex(rows); +} + +export async function prepareHtmlDownloadLinks(pool, userId, html, options = {}) { + const linkIndex = options.linkIndex ?? (await loadWorkspaceDownloadLinkIndex(pool, userId)); + return rewriteRelativeDownloadLinks(html, { ...options, linkIndex }); +} + +export const downloadLinkInternals = { + isRelativeDownloadReference, + resolveWorkspaceRelativeFilePath, + buildWorkspaceDownloadLinkIndex, + lookupAssetIdForWorkspacePath, + resolveDownloadTargetUrl, + rewriteRelativeDownloadLinks, +}; diff --git a/mindspace-html-download-links.test.mjs b/mindspace-html-download-links.test.mjs new file mode 100644 index 0000000..816e3b7 --- /dev/null +++ b/mindspace-html-download-links.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { downloadLinkInternals } from './mindspace-html-download-links.mjs'; + +const { + isRelativeDownloadReference, + resolveWorkspaceRelativeFilePath, + buildWorkspaceDownloadLinkIndex, + resolveDownloadTargetUrl, + rewriteRelativeDownloadLinks, +} = downloadLinkInternals; + +test('isRelativeDownloadReference accepts sibling docx links only', () => { + assert.equal(isRelativeDownloadReference('半导体行业趋势分析报告.docx'), true); + assert.equal(isRelativeDownloadReference('public/report.docx'), true); + assert.equal(isRelativeDownloadReference('/MindSpace/u/public/a.docx'), false); + assert.equal(isRelativeDownloadReference('https://example.com/a.pdf'), false); + assert.equal(isRelativeDownloadReference('#summary'), false); +}); + +test('resolveWorkspaceRelativeFilePath resolves from public html directory', () => { + assert.equal( + resolveWorkspaceRelativeFilePath('public/index.html', '半导体行业趋势分析报告.docx'), + 'public/半导体行业趋势分析报告.docx', + ); + assert.equal( + resolveWorkspaceRelativeFilePath('public/report.html', 'assets/data.csv'), + 'public/assets/data.csv', + ); +}); + +test('resolveDownloadTargetUrl prefers asset download API in preview mode', () => { + const linkIndex = buildWorkspaceDownloadLinkIndex([ + { id: 'asset-1', original_filename: 'public/半导体行业趋势分析报告.docx' }, + ]); + const url = resolveDownloadTargetUrl({ + relativeRef: '半导体行业趋势分析报告.docx', + htmlRelativePath: 'public/index.html', + linkIndex, + preferAssetDownload: true, + }); + assert.equal(url, '/api/mindspace/v1/assets/asset-1/download'); +}); + +test('rewriteRelativeDownloadLinks rewrites href and src download attributes', () => { + const html = [ + '下载', + '摘要', + ].join(''); + const linkIndex = buildWorkspaceDownloadLinkIndex([ + { id: 'asset-1', original_filename: '半导体行业趋势分析报告.docx' }, + ]); + const { html: rewritten, count } = rewriteRelativeDownloadLinks(html, { + htmlRelativePath: 'public/index.html', + linkIndex, + preferAssetDownload: true, + }); + assert.equal(count, 1); + assert.match(rewritten, /href="\/api\/mindspace\/v1\/assets\/asset-1\/download"/); + assert.match(rewritten, /href="#summary"/); +}); diff --git a/mindspace-page-purge.mjs b/mindspace-page-purge.mjs new file mode 100644 index 0000000..1673b3b --- /dev/null +++ b/mindspace-page-purge.mjs @@ -0,0 +1,87 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { + isRelativeDownloadReference, + resolveWorkspaceRelativeFilePath, + splitReferenceParts, +} from './mindspace-html-download-links.mjs'; +import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; + +const PRIVATE_ASSET_URL_PATTERN = + /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; + +const URL_ATTR_PATTERN = /(\b(?:href|src)\s*=\s*["'])([^"']+)(["'])/gi; + +export function extractAssetIdsFromHtml(html) { + return [ + ...new Set( + [...String(html ?? '').matchAll(PRIVATE_ASSET_URL_PATTERN)] + .map((match) => match[1]) + .filter(Boolean), + ), + ]; +} + +export function extractRelativeDownloadRefs(html) { + const refs = new Set(); + for (const match of String(html ?? '').matchAll(URL_ATTR_PATTERN)) { + const url = match[2]; + if (isRelativeDownloadReference(url)) { + refs.add(splitReferenceParts(url).pathPart); + } + } + return [...refs]; +} + +export function collectWorkspacePurgeTargets(htmlRelativePath, html) { + const targets = new Set(); + const normalizedHtmlPath = String(htmlRelativePath ?? '').replace(/^\/+/, ''); + if (normalizedHtmlPath) { + targets.add(normalizedHtmlPath); + if (normalizedHtmlPath.toLowerCase().endsWith('.html')) { + targets.add(workspaceThumbnailRelativePath(normalizedHtmlPath)); + const pngThumb = normalizedHtmlPath.replace(/\.html$/i, '.thumbnail.png'); + if (pngThumb !== normalizedHtmlPath) targets.add(pngThumb); + } + } + for (const ref of extractRelativeDownloadRefs(html)) { + targets.add(resolveWorkspaceRelativeFilePath(normalizedHtmlPath || 'public/index.html', ref)); + } + return [...targets].filter(Boolean); +} + +export async function purgeWorkspacePageArtifacts({ + publishDir, + htmlRelativePath = null, + html = '', +} = {}) { + if (!publishDir) return { removed: [], skipped: [] }; + const resolvedRoot = path.resolve(publishDir); + const removed = []; + const skipped = []; + + for (const relativeTarget of collectWorkspacePurgeTargets(htmlRelativePath, html)) { + const absolutePath = path.resolve(publishDir, ...relativeTarget.split('/')); + if ( + absolutePath !== resolvedRoot && + !absolutePath.startsWith(`${resolvedRoot}${path.sep}`) + ) { + skipped.push(relativeTarget); + continue; + } + try { + await fs.unlink(absolutePath); + removed.push(relativeTarget); + } catch (error) { + if (error?.code !== 'ENOENT') skipped.push(relativeTarget); + } + } + + return { removed, skipped }; +} + +export const pagePurgeInternals = { + extractAssetIdsFromHtml, + extractRelativeDownloadRefs, + collectWorkspacePurgeTargets, +}; diff --git a/mindspace-page-purge.test.mjs b/mindspace-page-purge.test.mjs new file mode 100644 index 0000000..3d11097 --- /dev/null +++ b/mindspace-page-purge.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { pagePurgeInternals } from './mindspace-page-purge.mjs'; + +const { extractAssetIdsFromHtml, collectWorkspacePurgeTargets } = pagePurgeInternals; + +test('extractAssetIdsFromHtml collects mindspace asset download urls', () => { + const html = + '' + + 'link'; + assert.deepEqual(extractAssetIdsFromHtml(html).sort(), ['asset-a', 'asset-b']); +}); + +test('collectWorkspacePurgeTargets includes html, thumbnails, and sibling downloads', () => { + const html = [ + '下载', + '', + ].join(''); + const targets = collectWorkspacePurgeTargets('public/report.html', html); + assert.ok(targets.includes('public/report.html')); + assert.ok(targets.includes('public/report.thumbnail.svg')); + assert.ok(targets.includes('public/半导体行业趋势分析报告.docx')); +}); diff --git a/mindspace-page-sync.mjs b/mindspace-page-sync.mjs index 6ee96f0..e35a2c5 100644 --- a/mindspace-page-sync.mjs +++ b/mindspace-page-sync.mjs @@ -35,6 +35,23 @@ function extractHtmlSummary(content) { return match?.[1]?.trim().slice(0, 1000) ?? ''; } +async function findPageByWorkspaceRelativePath(pool, userId, relativePath) { + const [rows] = await pool.query( + `SELECT p.id, p.updated_at + FROM h5_page_records p + JOIN h5_page_versions pv ON pv.id = p.current_version_id + WHERE p.user_id = ? + AND p.status <> 'deleted' + AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ? + ORDER BY p.updated_at DESC, p.id DESC + LIMIT 1`, + [userId, relativePath], + ); + const row = rows[0]; + if (!row) return null; + return { id: row.id, updatedAt: Number(row.updated_at ?? 0) }; +} + async function loadIndexedWorkspacePages(pool, userId) { const [rows] = await pool.query( `SELECT p.id, p.updated_at, p.source_asset_id, pv.source_snapshot_json @@ -99,6 +116,7 @@ async function listPublishPublicHtmlFiles(publishDir) { } async function upsertWorkspaceHtmlPage({ + pool, pageService, userId, indexedPages, @@ -109,10 +127,18 @@ async function upsertWorkspaceHtmlPage({ }) { const title = extractHtmlTitle(content) || titleFromRelativePath(relativePath); const summary = extractHtmlSummary(content); - const existing = + let existing = indexedPages.get(relativePath) ?? (assetId ? indexedPages.get(`asset:${assetId}`) : null); + if (!existing && pool) { + existing = await findPageByWorkspaceRelativePath(pool, userId, relativePath); + if (existing) { + indexedPages.set(relativePath, existing); + if (assetId) indexedPages.set(`asset:${assetId}`, existing); + } + } + const pageInput = { title, summary, @@ -186,6 +212,7 @@ export async function syncGeneratedPagesFromPublicAssets({ continue; } const result = await upsertWorkspaceHtmlPage({ + pool, pageService, userId, indexedPages, @@ -237,6 +264,7 @@ export async function syncGeneratedPagesFromPublicAssets({ const { path: assetPath } = await assetService.readAsset(userId, asset.id); const content = await fs.readFile(assetPath, 'utf8'); const result = await upsertWorkspaceHtmlPage({ + pool, pageService, userId, indexedPages, diff --git a/mindspace-page-sync.test.mjs b/mindspace-page-sync.test.mjs index b947444..33c1207 100644 --- a/mindspace-page-sync.test.mjs +++ b/mindspace-page-sync.test.mjs @@ -129,3 +129,50 @@ test('syncGeneratedPagesFromPublicAssets creates pages for unlinked public html assert.equal(result.created, 1); assert.equal(created[0].source.assetId, 'asset-1'); }); + +test('syncGeneratedPagesFromPublicAssets reuses db page when memory index is empty', async () => { + const publishDir = await fs.mkdtemp(path.join(os.tmpdir(), 'page-sync-dedupe-')); + const publicDir = path.join(publishDir, 'public'); + await fs.mkdir(publicDir, { recursive: true }); + await fs.writeFile( + path.join(publicDir, 'demo.html'), + 'Demo PageHi', + 'utf8', + ); + + const created = []; + const updated = []; + const pool = { + async query(sql) { + if (sql.includes('JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json')) { + return [[{ id: 'page-existing', updated_at: 100 }]]; + } + if (sql.includes('FROM h5_page_records p')) return [[]]; + if (sql.includes('FROM h5_assets a')) return [[]]; + return [[]]; + }, + }; + const pageService = { + async createFromChat() { + created.push(true); + return { id: 'page-new' }; + }, + async updatePage(userId, pageId) { + updated.push({ userId, pageId }); + return { id: pageId }; + }, + }; + + const result = await syncGeneratedPagesFromPublicAssets({ + pool, + pageService, + assetService: null, + userId: 'user-1', + publishDir, + }); + + assert.equal(result.created, 0); + assert.equal(created.length, 0); + assert.equal(updated.length, 1); + assert.equal(updated[0].pageId, 'page-existing'); +}); diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index f787d06..b1fbefb 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -12,6 +12,8 @@ import { bufferToImageDataUri, } from './mindspace-thumbnails.mjs'; import { resolvePublishDir } from './user-publish.mjs'; +import { prepareHtmlDownloadLinks } from './mindspace-html-download-links.mjs'; +import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.mjs'; import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs'; import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs'; @@ -87,8 +89,8 @@ function extensionForMime(mimeType, filename = '') { return 'bin'; } -function pageResponse(row) { - return { +function pageResponse(row, { includeContent = true } = {}) { + const response = { id: row.id, categoryId: row.category_id, categoryCode: row.category_code, @@ -107,10 +109,21 @@ function pageResponse(row) { publicationUrl: row.pub_public_url ?? null, currentVersionId: row.current_version_id, versionNo: asNumber(row.version_no), - content: row.content, createdAt: asNumber(row.created_at), updatedAt: asNumber(row.updated_at), }; + if (includeContent) { + response.content = row.content; + } + return response; +} + +const LIST_PAGES_MAX_LIMIT = 100; + +function normalizeListPageFilters(filters = {}) { + const limit = Math.min(Math.max(Number(filters.limit) || LIST_PAGES_MAX_LIMIT, 1), LIST_PAGES_MAX_LIMIT); + const offset = Math.max(Number(filters.offset) || 0, 0); + return { limit, offset }; } function escapeHtml(value) { @@ -563,17 +576,35 @@ export function createPageService(pool, options = {}) { clauses.push(`p.status = ?`); params.push(filters.status); } - const [rows] = await pool.query( - `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url - FROM h5_page_records p + if (filters.categoryCode) { + clauses.push(`c.category_code = ?`); + params.push(filters.categoryCode); + } + const where = clauses.join(' AND '); + const { limit, offset } = normalizeListPageFilters(filters); + const baseFrom = `FROM h5_page_records p JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id - LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online' - WHERE ${clauses.join(' AND ')} - ORDER BY p.created_at DESC, p.updated_at DESC LIMIT 100`, - params, - ); - return rows.map(pageResponse); + LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'`; + const [[countRows], [rows]] = await Promise.all([ + pool.query(`SELECT COUNT(*) AS total ${baseFrom} WHERE ${where}`, params), + pool.query( + `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url + ${baseFrom} + WHERE ${where} + ORDER BY p.created_at DESC, p.updated_at DESC + LIMIT ? OFFSET ?`, + [...params, limit, offset], + ), + ]); + const total = asNumber(countRows[0]?.total); + return { + items: rows.map((row) => pageResponse(row, { includeContent: false })), + total, + limit, + offset, + hasMore: offset + rows.length < total, + }; }; async function getPage(userId, pageId) { @@ -688,6 +719,47 @@ export function createPageService(pool, options = {}) { }; }; + const loadPageWorkspaceContext = async (userId, pageId) => { + const [rows] = await pool.query( + `SELECT pv.source_snapshot_json + FROM h5_page_records p + JOIN h5_page_versions pv ON pv.id = p.current_version_id + WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted' + LIMIT 1`, + [pageId, userId], + ); + let snapshot = {}; + try { + snapshot = JSON.parse(rows[0]?.source_snapshot_json ?? '{}'); + } catch { + snapshot = {}; + } + const workspaceHtmlRelativePath = snapshot.relative_path ?? 'public/index.html'; + const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null; + return { + workspaceHtmlRelativePath, + workspacePublishDir, + publishKey: userId, + }; + }; + + const finalizeHtmlPreview = async (userId, pageId, html) => { + const shell = renderHtmlPreview(html); + try { + const ctx = await loadPageWorkspaceContext(userId, pageId); + const { html: rewritten } = await prepareHtmlDownloadLinks(pool, userId, shell, { + htmlRelativePath: ctx.workspaceHtmlRelativePath, + publishDir: ctx.workspacePublishDir, + publishKey: ctx.publishKey, + publicBaseUrl: '', + preferAssetDownload: true, + }); + return rewritten; + } catch { + return shell; + } + }; + const localizePrivateResources = async (userId, pageId, input = {}) => { const page = await getPage(userId, pageId); if (input.pageVersionId && input.pageVersionId !== page.currentVersionId) { @@ -795,7 +867,7 @@ export function createPageService(pool, options = {}) { }; }; - const collectLinkedAssets = async (conn, userId, pageId) => { + const collectLinkedAssets = async (conn, userId, pageId, options = {}) => { const [versions] = await conn.query( `SELECT pv.content_asset_id, pv.bundle_asset_id FROM h5_page_versions pv @@ -807,6 +879,12 @@ export function createPageService(pool, options = {}) { if (version.content_asset_id) assetKind.set(version.content_asset_id, 'content'); if (version.bundle_asset_id) assetKind.set(version.bundle_asset_id, 'bundle'); } + if (options.sourceAssetId) { + assetKind.set(options.sourceAssetId, 'source'); + } + for (const assetId of options.embeddedAssetIds ?? []) { + if (assetId) assetKind.set(assetId, 'embedded'); + } const assetIds = [...assetKind.keys()]; if (assetIds.length === 0) { return { assets: [], totalBytes: 0, assetIds: [] }; @@ -830,6 +908,34 @@ export function createPageService(pool, options = {}) { }; }; + const listPlazaPostsForPage = async (conn, userId, pageId) => { + const [rows] = await conn.query( + `SELECT pp.id, pp.title, pp.status, pp.publication_id + FROM plaza_posts pp + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pr.page_id = ? AND pr.user_id = ? AND pp.status != 'hidden' + ORDER BY pp.published_at DESC, pp.updated_at DESC`, + [pageId, userId], + ); + return rows.map((row) => ({ + id: row.id, + title: row.title, + status: row.status, + publicationId: row.publication_id, + })); + }; + + const hidePlazaPostsForPage = async (conn, userId, pageId, now) => { + const [result] = await conn.query( + `UPDATE plaza_posts pp + JOIN h5_publish_records pr ON pr.id = pp.publication_id + SET pp.status = 'hidden', pp.updated_at = ? + WHERE pr.page_id = ? AND pr.user_id = ? AND pp.status != 'hidden'`, + [now, pageId, userId], + ); + return asNumber(result.affectedRows); + }; + const offlineOnlinePublications = async (conn, userId, pageId, now) => { const [rows] = await conn.query( `SELECT id, page_version_id, access_mode @@ -906,6 +1012,7 @@ export function createPageService(pool, options = {}) { ); const page = rows[0]; if (!page) throw pageError('页面不存在', 'page_not_found'); + const pageDetail = await getPage(userId, pageId).catch(() => null); const conn = await pool.getConnection(); try { @@ -913,7 +1020,13 @@ export function createPageService(pool, options = {}) { `SELECT COUNT(*) AS version_count FROM h5_page_versions WHERE page_id = ?`, [pageId], ); - const { assets, totalBytes } = await collectLinkedAssets(conn, userId, pageId); + const { assets, totalBytes } = await collectLinkedAssets(conn, userId, pageId, { + sourceAssetId: page.source_asset_id, + embeddedAssetIds: + pageDetail?.contentFormat === 'html' + ? extractAssetIdsFromHtml(pageDetail.content ?? '') + : [], + }); const [publications] = await conn.query( `SELECT id, status, public_url, access_mode, view_count FROM h5_publish_records @@ -929,6 +1042,7 @@ export function createPageService(pool, options = {}) { ); const onlinePublication = publications.find((item) => item.status === 'online') ?? null; const offlinePublicationCount = publications.filter((item) => item.status !== 'online').length; + const plazaPosts = await listPlazaPostsForPage(conn, userId, pageId); const preview = { page: { id: page.id, @@ -945,10 +1059,11 @@ export function createPageService(pool, options = {}) { } : null, offlinePublicationCount, + plazaPosts, linkedAssets: assets, linkedAssetBytes: totalBytes, linkedAgentJobCount: asNumber(agentJobs[0]?.job_count), - preservesSourceAsset: Boolean(page.source_asset_id), + preservesSourceAsset: false, }; return { ...preview, summaryLines: buildPageDeleteSummary(preview) }; } finally { @@ -956,27 +1071,54 @@ export function createPageService(pool, options = {}) { } }; - const deletePage = async (userId, pageId) => { + const deletePage = async (userId, pageId, options = {}) => { + const removeFromPlaza = Boolean(options.removeFromPlaza); + const page = await getPage(userId, pageId); + let workspaceHtmlRelativePath = null; + try { + const [rows] = await pool.query( + `SELECT pv.source_snapshot_json + FROM h5_page_records p + JOIN h5_page_versions pv ON pv.id = p.current_version_id + WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted' + LIMIT 1`, + [pageId, userId], + ); + const snapshot = JSON.parse(rows[0]?.source_snapshot_json ?? '{}'); + workspaceHtmlRelativePath = snapshot.relative_path ?? null; + } catch { + workspaceHtmlRelativePath = null; + } + const embeddedAssetIds = + page.contentFormat === 'html' ? extractAssetIdsFromHtml(page.content ?? '') : []; + const conn = await pool.getConnection(); + let result; try { await conn.beginTransaction(); const [pages] = await conn.query( - `SELECT id, space_id, title + `SELECT id, space_id, title, source_asset_id FROM h5_page_records WHERE id = ? AND user_id = ? AND status <> 'deleted' LIMIT 1 FOR UPDATE`, [pageId, userId], ); - const page = pages[0]; - if (!page) throw pageError('页面不存在', 'page_not_found'); + const pageRow = pages[0]; + if (!pageRow) throw pageError('页面不存在', 'page_not_found'); const now = Date.now(); - const { assetIds } = await collectLinkedAssets(conn, userId, pageId); + const { assetIds } = await collectLinkedAssets(conn, userId, pageId, { + sourceAssetId: pageRow.source_asset_id, + embeddedAssetIds, + }); const offlinedPublicationCount = await offlineOnlinePublications(conn, userId, pageId, now); + const hiddenPlazaPostCount = removeFromPlaza + ? await hidePlazaPostsForPage(conn, userId, pageId, now) + : 0; const { deletedAssetCount, freedBytes } = await softDeleteLinkedAssets( conn, userId, - page.space_id, + pageRow.space_id, assetIds, now, ); @@ -997,16 +1139,17 @@ export function createPageService(pool, options = {}) { await conn.query( `UPDATE h5_page_records SET status = 'deleted', deleted_at = ?, updated_at = ?, - current_publish_id = NULL, visibility = 'private' + current_publish_id = NULL, visibility = 'private', source_asset_id = NULL WHERE id = ? AND user_id = ?`, [now, now, pageId, userId], ); await conn.commit(); - return { + result = { deleted: true, pageId, - title: page.title, + title: pageRow.title, offlinedPublicationCount, + hiddenPlazaPostCount, deletedAssetCount, freedBytes, clearedAgentJobCount, @@ -1017,6 +1160,15 @@ export function createPageService(pool, options = {}) { } finally { conn.release(); } + + const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null; + const purgeResult = await purgeWorkspacePageArtifacts({ + publishDir: workspacePublishDir, + htmlRelativePath: workspaceHtmlRelativePath, + html: page.content ?? '', + }).catch(() => ({ removed: [], skipped: [] })); + + return { ...result, workspaceFilesRemoved: purgeResult.removed ?? [] }; }; const loadPageThumbnailContext = async (userId, pageId) => { @@ -1177,7 +1329,7 @@ export function createPageService(pool, options = {}) { const page = await getPage(userId, pageId); const html = page.contentFormat === 'html' - ? renderHtmlPreview(page.content) + ? await finalizeHtmlPreview(userId, pageId, page.content) : renderPreviewHtml(page); return { html, contentFormat: page.contentFormat }; }, @@ -1189,7 +1341,7 @@ export function createPageService(pool, options = {}) { const templateId = input.templateId ?? page.templateId; const html = page.contentFormat === 'html' - ? renderHtmlPreview(content) + ? await finalizeHtmlPreview(userId, pageId, content) : renderPreviewHtml({ title, summary, @@ -1222,6 +1374,7 @@ export function createPageService(pool, options = {}) { export const pageInternals = { escapeHtml, normalizePageInput, + normalizeListPageFilters, renderContent, renderPreviewHtml, renderHtmlPreview, @@ -1294,6 +1447,11 @@ export function buildPageDeleteSummary(preview) { if (preview.linkedAgentJobCount > 0) { lines.push(`${preview.linkedAgentJobCount} 个 Agent 任务结果关联将被清除`); } + if (preview.plazaPosts?.length > 0) { + lines.push( + `该页面在广场有 ${preview.plazaPosts.length} 个帖子;可在下方选择是否一并从广场移除`, + ); + } if (preview.preservesSourceAsset) { lines.push('原始上传资料不会被删除'); } diff --git a/mindspace-pages.test.mjs b/mindspace-pages.test.mjs index 59c4717..5404dbb 100644 --- a/mindspace-pages.test.mjs +++ b/mindspace-pages.test.mjs @@ -63,6 +63,17 @@ test('renderPublicationHtml serves html pages without escaping markup', () => { assert.doesNotMatch(html, /MINDSPACE/); }); +test('normalizeListPageFilters clamps limit and offset', () => { + assert.deepEqual(pageInternals.normalizeListPageFilters({ limit: 999, offset: -5 }), { + limit: 100, + offset: 0, + }); + assert.deepEqual(pageInternals.normalizeListPageFilters({ limit: 15, offset: 30 }), { + limit: 15, + offset: 30, + }); +}); + test('preview rendering escapes active HTML and emits a restrictive CSP', () => { const html = pageInternals.renderPreviewHtml({ title: '', @@ -94,6 +105,7 @@ test('buildPageDeleteSummary lists cascade delete impact', () => { page: { title: '麻婆豆腐', versionCount: 2 }, onlinePublication: { publicUrl: '/u/john/pages/mapo' }, offlinePublicationCount: 1, + plazaPosts: [{ id: 'post-1', title: '广场帖', status: 'published', publicationId: 'pub-1' }], linkedAssets: [{ id: 'a1' }, { id: 'a2' }], linkedAssetBytes: 2048, linkedAgentJobCount: 1, @@ -101,5 +113,6 @@ test('buildPageDeleteSummary lists cascade delete impact', () => { }); assert.match(lines[0], /麻婆豆腐/); assert.ok(lines.some((line) => line.includes('在线公开链接'))); + assert.ok(lines.some((line) => line.includes('广场'))); assert.ok(lines.some((line) => line.includes('原始上传资料不会被删除'))); }); diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs index e29ac0c..985793c 100644 --- a/mindspace-publications.mjs +++ b/mindspace-publications.mjs @@ -5,7 +5,11 @@ import { localizeGoogleFontsCss } from './mindspace-html-localize.mjs'; import { replacePrivateResourceReferences, scanContent } from './mindspace-content-scan.mjs'; import { pageInternals } from './mindspace-pages.mjs'; import { loadMindSpaceConfig } from './mindspace-config.mjs'; -import { buildPublicUrl, resolvePublicBaseUrl } from './user-publish.mjs'; +import { buildPublicUrl, resolvePublicBaseUrl, resolvePublishDir } from './user-publish.mjs'; +import { + loadWorkspaceDownloadLinkIndex, + rewriteRelativeDownloadLinks, +} from './mindspace-html-download-links.mjs'; import { createImgproxySigner } from './imgproxy-signer.mjs'; const SCANNER_VERSION = 'mindspace-content-v1'; @@ -247,6 +251,7 @@ async function prepareHtmlPublishContent({ htmlRelativePath = `public/${urlSlug}.html`, absoluteStoragePath, imgproxySigner = null, + h5Root = null, }) { let publishContent = (await localizeGoogleFontsCss(html)).html; publishContent = replaceImgproxyStandardImageReferences( @@ -261,6 +266,16 @@ async function prepareHtmlPublishContent({ imgproxySigner, }); publishContent = rewriteWorkspacePublicAssetReferences(publishContent, htmlRelativePath); + const publishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null; + const linkIndex = await loadWorkspaceDownloadLinkIndex(pool, userId); + publishContent = rewriteRelativeDownloadLinks(publishContent, { + htmlRelativePath, + publishDir, + linkIndex, + publishKey: userId, + publicBaseUrl: resolvePublicBaseUrl(), + preferAssetDownload: false, + }).html; return replacePrivateResourceReferences( publishContent, buildPublicationThumbnailFallback(ownerSlug, urlSlug), @@ -269,6 +284,7 @@ async function prepareHtmlPublishContent({ export function createPublicationService(pool, options = {}) { const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace')); + const h5Root = options.h5Root ?? null; const idFactory = options.idFactory ?? (() => crypto.randomUUID()); const publicPageLimitFallback = Number(options.publicPageLimit ?? 5); @@ -378,6 +394,7 @@ export function createPublicationService(pool, options = {}) { ownerSlug, urlSlug, absoluteStoragePath, + h5Root, imgproxySigner: imgproxySigner ? { buildUrl: (path, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path, preset) } : null, }); }; diff --git a/mindspace-publications.test.mjs b/mindspace-publications.test.mjs index df01b52..425aced 100644 --- a/mindspace-publications.test.mjs +++ b/mindspace-publications.test.mjs @@ -158,6 +158,9 @@ test('prepareHtmlPublishContent inlines owned private image assets before scanni const pool = { async query(sql, params) { + if (sql.includes('original_filename')) { + return [[]]; + } assert.match(sql, /h5_assets/); assert.equal(params[0], 'user-1'); assert.deepEqual(params[1], ['asset-1']); diff --git a/package.json b/package.json index d069c2e..c4184cd 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "enrich:plaza": "node scripts/enrich-plaza-posts.mjs", "build": "vite build", "build:portal-runtime": "node scripts/build-portal-runtime.mjs", - "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs", + "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs", "verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs", "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", "verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs", diff --git a/scripts/purge-user-pages.mjs b/scripts/purge-user-pages.mjs new file mode 100644 index 0000000..281503b --- /dev/null +++ b/scripts/purge-user-pages.mjs @@ -0,0 +1,266 @@ +#!/usr/bin/env node +/** + * Delete all MindSpace pages for one user (uses the same cascade as the API). + * + * Usage: + * node scripts/purge-user-pages.mjs --username=john --dry-run + * node scripts/purge-user-pages.mjs --username=john --yes + * node scripts/purge-user-pages.mjs --user-id=... --yes --remove-from-plaza + */ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { createDbPool } from '../db.mjs'; +import { createPageService } from '../mindspace-pages.mjs'; +import { resolvePublishDir } from '../user-publish.mjs'; +import { loadH5Environment } from './load-env.mjs'; + +const PUBLIC_HTML_SKIP_DIRS = new Set([ + '.tmp-images', + 'assets', + 'images', + 'shared', + 'node_modules', +]); + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const root = path.join(scriptDir, '..'); +loadH5Environment(scriptDir); + +function readArg(name) { + const prefix = `--${name}=`; + const hit = process.argv.find((arg) => arg.startsWith(prefix)); + return hit ? hit.slice(prefix.length).trim() : null; +} + +const dryRun = process.argv.includes('--dry-run'); +const confirmed = process.argv.includes('--yes'); +const removeFromPlaza = process.argv.includes('--remove-from-plaza'); +const purgeSources = process.argv.includes('--purge-sources'); +const untilEmpty = process.argv.includes('--until-empty'); +const username = readArg('username'); +const userIdArg = readArg('user-id'); +const batchSize = Math.max(1, Number(readArg('batch-size') ?? 100)); + +const storageRoot = process.env.MINDSPACE_STORAGE_ROOT + ? path.resolve(process.env.MINDSPACE_STORAGE_ROOT) + : path.join(root, 'data', 'mindspace'); + +async function resolveUserId(pool) { + if (userIdArg) return userIdArg; + if (!username) { + throw new Error('请指定 --username=... 或 --user-id=...'); + } + const [rows] = await pool.query( + `SELECT id, username, email FROM h5_users WHERE username = ? LIMIT 1`, + [username], + ); + const row = rows[0]; + if (!row) throw new Error(`用户不存在: ${username}`); + return row.id; +} + +async function countRemainingPages(pool, userId) { + const [rows] = await pool.query( + `SELECT COUNT(*) AS count FROM h5_page_records WHERE user_id = ? AND status <> 'deleted'`, + [userId], + ); + return Number(rows[0]?.count ?? 0); +} + +async function listWorkspaceHtmlFiles(publishDir) { + const files = []; + async function walk(currentDir, relativeDir = '') { + let entries; + try { + entries = await fs.readdir(currentDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isDirectory()) { + if (PUBLIC_HTML_SKIP_DIRS.has(entry.name)) continue; + await walk(path.join(currentDir, entry.name), path.posix.join(relativeDir, entry.name)); + continue; + } + if (!entry.name.toLowerCase().endsWith('.html')) continue; + files.push(path.join(currentDir, entry.name)); + } + } + await walk(publishDir); + return files; +} + +async function purgeSyncSources(pool, userId, h5Root) { + const now = Date.now(); + const publishDir = resolvePublishDir(h5Root, { id: userId }); + const htmlFiles = await listWorkspaceHtmlFiles(publishDir); + let removedFiles = 0; + for (const filePath of htmlFiles) { + await fs.unlink(filePath).catch(() => {}); + removedFiles += 1; + const thumbPath = filePath.replace(/\.html$/i, '.thumbnail.svg'); + await fs.unlink(thumbPath).catch(() => {}); + } + + const [assetResult] = await pool.query( + `UPDATE h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + SET a.status = 'deleted', a.deleted_at = ?, a.updated_at = ? + WHERE a.user_id = ? + AND c.category_code = 'public' + AND a.mime_type = 'text/html' + AND a.status <> 'deleted'`, + [now, now, userId], + ); + + const [plazaResult] = await pool.query( + `UPDATE plaza_posts SET status = 'hidden', updated_at = ? + WHERE user_id = ? AND status != 'hidden'`, + [now, userId], + ); + + return { + removedFiles, + deletedPublicHtmlAssets: Number(assetResult.affectedRows ?? 0), + hiddenPlazaPosts: Number(plazaResult.affectedRows ?? 0), + }; +} + +async function listAllPageIds(pool, userId) { + const ids = []; + let offset = 0; + while (true) { + const [rows] = await pool.query( + `SELECT id FROM h5_page_records + WHERE user_id = ? AND status <> 'deleted' + ORDER BY created_at ASC + LIMIT ? OFFSET ?`, + [userId, batchSize, offset], + ); + if (rows.length === 0) break; + ids.push(...rows.map((row) => row.id)); + if (rows.length < batchSize) break; + offset += rows.length; + } + return ids; +} + +const pool = createDbPool(); +const pageService = createPageService(pool, { h5Root: root, storageRoot }); + +try { + const userId = await resolveUserId(pool); + const [userRows] = await pool.query( + `SELECT username, email FROM h5_users WHERE id = ? LIMIT 1`, + [userId], + ); + const user = userRows[0]; + const pageIds = await listAllPageIds(pool, userId); + + const [plazaRows] = await pool.query( + `SELECT COUNT(*) AS count + FROM plaza_posts pp + JOIN h5_publish_records pr ON pr.id = pp.publication_id + WHERE pr.user_id = ? AND pp.status != 'hidden'`, + [userId], + ); + const plazaPostCount = Number(plazaRows[0]?.count ?? 0); + + console.log(`用户: ${user?.username ?? userId} (${user?.email ?? 'unknown'})`); + console.log(`待删除页面: ${pageIds.length}`); + console.log(`广场帖子: ${plazaPostCount}(${removeFromPlaza || purgeSources ? '将一并隐藏' : '保留'})`); + if (purgeSources) console.log('将清理工作区 public HTML 与公开 HTML 资产,避免同步复活页面'); + if (untilEmpty) console.log('将循环删除直到账户下无剩余页面'); + + if (pageIds.length === 0) { + console.log('没有可删除的页面。'); + process.exit(0); + } + + if (dryRun) { + if (purgeSources) { + const publishDir = resolvePublishDir(root, { id: userId }); + const htmlFiles = await listWorkspaceHtmlFiles(publishDir); + console.log(`[dry-run] 工作区 HTML 文件: ${htmlFiles.length}`); + } + console.log('[dry-run] 未执行删除。'); + process.exit(0); + } + + if (!confirmed) { + console.error('危险操作:请加 --yes 确认删除。可先 --dry-run 查看数量。'); + process.exit(1); + } + + let deleted = 0; + let failed = 0; + let freedBytes = 0; + let hiddenPlazaPosts = 0; + let removedFiles = 0; + let deletedPublicHtmlAssets = 0; + const started = Date.now(); + const maxRounds = untilEmpty ? 20 : 1; + + for (let round = 1; round <= maxRounds; round += 1) { + if (purgeSources) { + const sourceResult = await purgeSyncSources(pool, userId, root); + removedFiles += sourceResult.removedFiles; + deletedPublicHtmlAssets += sourceResult.deletedPublicHtmlAssets; + hiddenPlazaPosts += sourceResult.hiddenPlazaPosts; + if (round === 1) { + console.log( + `已清理同步来源:HTML 文件 ${sourceResult.removedFiles},公开 HTML 资产 ${sourceResult.deletedPublicHtmlAssets},广场帖 ${sourceResult.hiddenPlazaPosts}`, + ); + } + } + + const roundPageIds = round === 1 ? pageIds : await listAllPageIds(pool, userId); + if (roundPageIds.length === 0) break; + if (round > 1) { + console.log(`第 ${round} 轮:发现 ${roundPageIds.length} 个复活/残留页面,继续删除…`); + } + + for (const pageId of roundPageIds) { + try { + const result = await pageService.deletePage(userId, pageId, { removeFromPlaza }); + deleted += 1; + freedBytes += Number(result.freedBytes ?? 0); + hiddenPlazaPosts += Number(result.hiddenPlazaPostCount ?? 0); + if (deleted % 25 === 0) { + const elapsedSec = ((Date.now() - started) / 1000).toFixed(1); + console.log( + `[${deleted}] 已删除 ${deleted} 页,失败 ${failed},释放 ${(freedBytes / (1024 * 1024)).toFixed(1)} MB,用时 ${elapsedSec}s`, + ); + } + } catch (error) { + failed += 1; + const message = error instanceof Error ? error.message : String(error); + console.error(`删除失败 ${pageId}: ${message}`); + } + } + + if (!untilEmpty) break; + const remainingAfterRound = await countRemainingPages(pool, userId); + if (remainingAfterRound === 0) break; + if (round === maxRounds) { + console.warn(`已达最大 ${maxRounds} 轮,仍有 ${remainingAfterRound} 页未删除`); + } + } + + const remaining = await countRemainingPages(pool, userId); + const elapsedSec = ((Date.now() - started) / 1000).toFixed(1); + + console.log('---'); + console.log(`完成:成功 ${deleted},失败 ${failed},剩余 ${remaining} 页`); + console.log(`释放空间约 ${(freedBytes / (1024 * 1024)).toFixed(1)} MB`); + if (purgeSources) { + console.log(`清理 HTML 文件 ${removedFiles},公开 HTML 资产 ${deletedPublicHtmlAssets}`); + } + if (removeFromPlaza || purgeSources) console.log(`隐藏广场帖 ${hiddenPlazaPosts} 条`); + console.log(`总用时 ${elapsedSec}s`); + + process.exit(failed > 0 || remaining > 0 ? 1 : 0); +} finally { + await pool.end(); +} diff --git a/server.mjs b/server.mjs index 54e3b19..267044b 100644 --- a/server.mjs +++ b/server.mjs @@ -300,6 +300,7 @@ async function bootstrapUserAuth() { }, }); mindSpacePublications = createPublicationService(pool, { + h5Root: __dirname, storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'), publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5), @@ -2664,21 +2665,35 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) { const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']); +const pageSyncInFlight = new Map(); + async function syncUserGeneratedPages(userId) { if (!mindSpacePages || !authPool || !userId) return; - await syncGeneratedPagesFromPublicAssets({ - pool: authPool, - pageService: mindSpacePages, - assetService: mindSpaceAssets, - userId, - publishDir: resolvePublishDir(__dirname, { id: userId }), - syncWorkspaceAssets: - mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED - ? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options) - : null, - }).catch((error) => { - console.warn('[MindSpace] page sync failed:', error?.message ?? error); - }); + + let inFlight = pageSyncInFlight.get(userId); + if (!inFlight) { + inFlight = syncGeneratedPagesFromPublicAssets({ + pool: authPool, + pageService: mindSpacePages, + assetService: mindSpaceAssets, + userId, + publishDir: resolvePublishDir(__dirname, { id: userId }), + syncWorkspaceAssets: + mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED + ? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options) + : null, + }) + .catch((error) => { + console.warn('[MindSpace] page sync failed:', error?.message ?? error); + }) + .finally(() => { + if (pageSyncInFlight.get(userId) === inFlight) { + pageSyncInFlight.delete(userId); + } + }); + pageSyncInFlight.set(userId, inFlight); + } + await inFlight; } async function resolveChatSaveBundle(user, h5Root, input = {}) { @@ -3083,10 +3098,24 @@ api.get('/mindspace/v1/pages', async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' }); try { await syncUserGeneratedPages(req.currentUser.id); - const pages = await mindSpacePages.listPages(req.currentUser.id, { + const limit = Number.parseInt(String(req.query.limit ?? ''), 10); + const offset = Number.parseInt(String(req.query.offset ?? ''), 10); + const result = await mindSpacePages.listPages(req.currentUser.id, { status: typeof req.query.status === 'string' ? req.query.status : undefined, + categoryCode: typeof req.query.category_code === 'string' ? req.query.category_code : undefined, + ...(Number.isFinite(limit) ? { limit } : {}), + ...(Number.isFinite(offset) ? { offset } : {}), + }); + return res.json({ + data: result.items, + page: { + total: result.total, + limit: result.limit, + offset: result.offset, + has_more: result.hasMore, + next_cursor: null, + }, }); - return res.json({ data: pages, page: { next_cursor: null, has_more: false } }); } catch (error) { return mindSpaceError(res, req, error); } @@ -3109,7 +3138,14 @@ api.get('/mindspace/v1/pages/:pageId', async (req, res) => { api.delete('/mindspace/v1/pages/:pageId', async (req, res) => { if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return; try { - const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId); + const removeFromPlaza = + String(req.query?.remove_from_plaza ?? req.body?.remove_from_plaza ?? '').toLowerCase() === + 'true' || + req.query?.remove_from_plaza === '1' || + req.body?.remove_from_plaza === true; + const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId, { + removeFromPlaza, + }); const quota = await mindSpace.getQuota(req.currentUser.id); await mindSpaceAudit?.write({ userId: req.currentUser.id, @@ -3119,6 +3155,7 @@ api.delete('/mindspace/v1/pages/:pageId', async (req, res) => { ip: req.ip, detail: { offlinedPublicationCount: result.offlinedPublicationCount, + hiddenPlazaPostCount: result.hiddenPlazaPostCount, deletedAssetCount: result.deletedAssetCount, freedBytes: result.freedBytes, }, @@ -4434,7 +4471,7 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
- +
-