diff --git a/.runtime/portal/RUNBOOK.txt b/.runtime/portal/RUNBOOK.txt index 7127d3d..35616b6 100644 --- a/.runtime/portal/RUNBOOK.txt +++ b/.runtime/portal/RUNBOOK.txt @@ -13,10 +13,12 @@ Required persisted items to inherit on the host: Bundled alongside server.mjs (required for sandbox-fs MCP): mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps) +Platform agent skills (synced into user workspaces): + skills/ Key runtime differences must stay in .env, not in the artifact: DATABASE_URL / MYSQL_* H5_PUBLIC_BASE_URL - TKMIND_API_TARGET / TKMIND_API_TARGET_1 + TKMIND_API_TARGETS / TKMIND_API_TARGET / TKMIND_API_TARGET_1 H5_USERS_ROOT / MINDSPACE_STORAGE_ROOT / MEMIND_SHARED_PUBLISH_ROOT Deployment and operations transport: diff --git a/.runtime/portal/mindspace-sandbox-mcp.mjs b/.runtime/portal/mindspace-sandbox-mcp.mjs index 4a499ba..4080aa9 100755 --- a/.runtime/portal/mindspace-sandbox-mcp.mjs +++ b/.runtime/portal/mindspace-sandbox-mcp.mjs @@ -884,6 +884,9 @@ function resolveSandboxed(p) { if (resolved !== SANDBOX && !resolved.startsWith(SANDBOX + path.sep)) { throw Object.assign(new Error(`\u8DEF\u5F84\u8D8A\u754C\uFF1A${p} \u4E0D\u5728\u5F53\u524D\u5DE5\u4F5C\u533A\u5185`), { code: "EACCES" }); } + if (resolved === PRIVATE_DATA_DIR || resolved.startsWith(PRIVATE_DATA_DIR + path.sep)) { + throw Object.assign(new Error("\u7981\u6B62\u76F4\u63A5\u8BBF\u95EE\u79C1\u6709\u6570\u636E\u76EE\u5F55\uFF0C\u8BF7\u4F7F\u7528 private_data_* \u5DE5\u5177"), { code: "EACCES" }); + } return resolved; } var ALL_TOOLS = [ diff --git a/.runtime/portal/schema.sql b/.runtime/portal/schema.sql index 20460a4..9b885a0 100644 --- a/.runtime/portal/schema.sql +++ b/.runtime/portal/schema.sql @@ -373,6 +373,7 @@ CREATE TABLE IF NOT EXISTS h5_usage_records ( cost_cents BIGINT NOT NULL, balance_after_cents BIGINT NOT NULL, created_at BIGINT NOT NULL, + UNIQUE KEY uniq_h5_usage_request_id (request_id), KEY idx_h5_usage_user_time (user_id, created_at), CONSTRAINT fk_h5_usage_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/.runtime/portal/scripts/run-memind-portal-prod.sh b/.runtime/portal/scripts/run-memind-portal-prod.sh index 9ee3f83..703366e 100755 --- a/.runtime/portal/scripts/run-memind-portal-prod.sh +++ b/.runtime/portal/scripts/run-memind-portal-prod.sh @@ -14,8 +14,11 @@ fi export NODE_ENV=production export H5_PORT="${H5_PORT:-8081}" export H5_PUBLIC_BASE_URL="${H5_PUBLIC_BASE_URL:-https://m.tkmind.cn}" +export TKMIND_API_TARGETS="${TKMIND_API_TARGETS:-https://127.0.0.1:18006,https://127.0.0.1:18007,https://127.0.0.1:18008,https://127.0.0.1:18009}" export TKMIND_API_TARGET="${TKMIND_API_TARGET:-https://127.0.0.1:18006}" export TKMIND_API_TARGET_1="${TKMIND_API_TARGET_1:-https://127.0.0.1:18007}" +export GOOSED_MCP_NODE_PATH="${GOOSED_MCP_NODE_PATH:-/usr/local/bin/node}" +export GOOSED_MCP_SERVER_PATH="${GOOSED_MCP_SERVER_PATH:-/opt/portal/mindspace-sandbox-mcp.mjs}" NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}" if [[ ! -x "${NODE_BIN}" ]]; then diff --git a/.runtime/portal/server.mjs b/.runtime/portal/server.mjs index b5adb9a..96b146b 100644 --- a/.runtime/portal/server.mjs +++ b/.runtime/portal/server.mjs @@ -1,11 +1,5424 @@ import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url); +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { + get: (a, b) => (typeof require !== "undefined" ? require : a)[b] +}) : x)(function(x) { + if (typeof require !== "undefined") return require.apply(this, arguments); + throw Error('Dynamic require of "' + x + '" is not supported'); +}); +var __esm = (fn, res) => function __init() { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; +}; +var __commonJS = (cb, mod) => function __require2() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; +}; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); + +// node_modules/.pnpm/postgres-array@2.0.0/node_modules/postgres-array/index.js +var require_postgres_array = __commonJS({ + "node_modules/.pnpm/postgres-array@2.0.0/node_modules/postgres-array/index.js"(exports) { + "use strict"; + exports.parse = function(source, transform) { + return new ArrayParser(source, transform).parse(); + }; + var ArrayParser = class _ArrayParser { + constructor(source, transform) { + this.source = source; + this.transform = transform || identity; + this.position = 0; + this.entries = []; + this.recorded = []; + this.dimension = 0; + } + isEof() { + return this.position >= this.source.length; + } + nextCharacter() { + var character = this.source[this.position++]; + if (character === "\\") { + return { + value: this.source[this.position++], + escaped: true + }; + } + return { + value: character, + escaped: false + }; + } + record(character) { + this.recorded.push(character); + } + newEntry(includeEmpty) { + var entry; + if (this.recorded.length > 0 || includeEmpty) { + entry = this.recorded.join(""); + if (entry === "NULL" && !includeEmpty) { + entry = null; + } + if (entry !== null) entry = this.transform(entry); + this.entries.push(entry); + this.recorded = []; + } + } + consumeDimensions() { + if (this.source[0] === "[") { + while (!this.isEof()) { + var char = this.nextCharacter(); + if (char.value === "=") break; + } + } + } + parse(nested) { + var character, parser, quote; + this.consumeDimensions(); + while (!this.isEof()) { + character = this.nextCharacter(); + if (character.value === "{" && !quote) { + this.dimension++; + if (this.dimension > 1) { + parser = new _ArrayParser(this.source.substr(this.position - 1), this.transform); + this.entries.push(parser.parse(true)); + this.position += parser.position - 2; + } + } else if (character.value === "}" && !quote) { + this.dimension--; + if (!this.dimension) { + this.newEntry(); + if (nested) return this.entries; + } + } else if (character.value === '"' && !character.escaped) { + if (quote) this.newEntry(true); + quote = !quote; + } else if (character.value === "," && !quote) { + this.newEntry(); + } else { + this.record(character.value); + } + } + if (this.dimension !== 0) { + throw new Error("array dimension not balanced"); + } + return this.entries; + } + }; + function identity(value) { + return value; + } + } +}); + +// node_modules/.pnpm/pg-types@2.2.0/node_modules/pg-types/lib/arrayParser.js +var require_arrayParser = __commonJS({ + "node_modules/.pnpm/pg-types@2.2.0/node_modules/pg-types/lib/arrayParser.js"(exports, module) { + var array = require_postgres_array(); + module.exports = { + create: function(source, transform) { + return { + parse: function() { + return array.parse(source, transform); + } + }; + } + }; + } +}); + +// node_modules/.pnpm/postgres-date@1.0.7/node_modules/postgres-date/index.js +var require_postgres_date = __commonJS({ + "node_modules/.pnpm/postgres-date@1.0.7/node_modules/postgres-date/index.js"(exports, module) { + "use strict"; + var DATE_TIME = /(\d{1,})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(\.\d{1,})?.*?( BC)?$/; + var DATE = /^(\d{1,})-(\d{2})-(\d{2})( BC)?$/; + var TIME_ZONE = /([Z+-])(\d{2})?:?(\d{2})?:?(\d{2})?/; + var INFINITY = /^-?infinity$/; + module.exports = function parseDate(isoDate) { + if (INFINITY.test(isoDate)) { + return Number(isoDate.replace("i", "I")); + } + var matches = DATE_TIME.exec(isoDate); + if (!matches) { + return getDate(isoDate) || null; + } + var isBC = !!matches[8]; + var year = parseInt(matches[1], 10); + if (isBC) { + year = bcYearToNegativeYear(year); + } + var month = parseInt(matches[2], 10) - 1; + var day = matches[3]; + var hour = parseInt(matches[4], 10); + var minute = parseInt(matches[5], 10); + var second = parseInt(matches[6], 10); + var ms = matches[7]; + ms = ms ? 1e3 * parseFloat(ms) : 0; + var date; + var offset = timeZoneOffset(isoDate); + if (offset != null) { + date = new Date(Date.UTC(year, month, day, hour, minute, second, ms)); + if (is0To99(year)) { + date.setUTCFullYear(year); + } + if (offset !== 0) { + date.setTime(date.getTime() - offset); + } + } else { + date = new Date(year, month, day, hour, minute, second, ms); + if (is0To99(year)) { + date.setFullYear(year); + } + } + return date; + }; + function getDate(isoDate) { + var matches = DATE.exec(isoDate); + if (!matches) { + return; + } + var year = parseInt(matches[1], 10); + var isBC = !!matches[4]; + if (isBC) { + year = bcYearToNegativeYear(year); + } + var month = parseInt(matches[2], 10) - 1; + var day = matches[3]; + var date = new Date(year, month, day); + if (is0To99(year)) { + date.setFullYear(year); + } + return date; + } + function timeZoneOffset(isoDate) { + if (isoDate.endsWith("+00")) { + return 0; + } + var zone = TIME_ZONE.exec(isoDate.split(" ")[1]); + if (!zone) return; + var type = zone[1]; + if (type === "Z") { + return 0; + } + var sign = type === "-" ? -1 : 1; + var offset = parseInt(zone[2], 10) * 3600 + parseInt(zone[3] || 0, 10) * 60 + parseInt(zone[4] || 0, 10); + return offset * sign * 1e3; + } + function bcYearToNegativeYear(year) { + return -(year - 1); + } + function is0To99(num) { + return num >= 0 && num < 100; + } + } +}); + +// node_modules/.pnpm/xtend@4.0.2/node_modules/xtend/mutable.js +var require_mutable = __commonJS({ + "node_modules/.pnpm/xtend@4.0.2/node_modules/xtend/mutable.js"(exports, module) { + module.exports = extend; + var hasOwnProperty = Object.prototype.hasOwnProperty; + function extend(target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + for (var key in source) { + if (hasOwnProperty.call(source, key)) { + target[key] = source[key]; + } + } + } + return target; + } + } +}); + +// node_modules/.pnpm/postgres-interval@1.2.0/node_modules/postgres-interval/index.js +var require_postgres_interval = __commonJS({ + "node_modules/.pnpm/postgres-interval@1.2.0/node_modules/postgres-interval/index.js"(exports, module) { + "use strict"; + var extend = require_mutable(); + module.exports = PostgresInterval; + function PostgresInterval(raw) { + if (!(this instanceof PostgresInterval)) { + return new PostgresInterval(raw); + } + extend(this, parse(raw)); + } + var properties = ["seconds", "minutes", "hours", "days", "months", "years"]; + PostgresInterval.prototype.toPostgres = function() { + var filtered = properties.filter(this.hasOwnProperty, this); + if (this.milliseconds && filtered.indexOf("seconds") < 0) { + filtered.push("seconds"); + } + if (filtered.length === 0) return "0"; + return filtered.map(function(property) { + var value = this[property] || 0; + if (property === "seconds" && this.milliseconds) { + value = (value + this.milliseconds / 1e3).toFixed(6).replace(/\.?0+$/, ""); + } + return value + " " + property; + }, this).join(" "); + }; + var propertiesISOEquivalent = { + years: "Y", + months: "M", + days: "D", + hours: "H", + minutes: "M", + seconds: "S" + }; + var dateProperties = ["years", "months", "days"]; + var timeProperties = ["hours", "minutes", "seconds"]; + PostgresInterval.prototype.toISOString = PostgresInterval.prototype.toISO = function() { + var datePart = dateProperties.map(buildProperty, this).join(""); + var timePart = timeProperties.map(buildProperty, this).join(""); + return "P" + datePart + "T" + timePart; + function buildProperty(property) { + var value = this[property] || 0; + if (property === "seconds" && this.milliseconds) { + value = (value + this.milliseconds / 1e3).toFixed(6).replace(/0+$/, ""); + } + return value + propertiesISOEquivalent[property]; + } + }; + var NUMBER = "([+-]?\\d+)"; + var YEAR = NUMBER + "\\s+years?"; + var MONTH = NUMBER + "\\s+mons?"; + var DAY = NUMBER + "\\s+days?"; + var TIME = "([+-])?([\\d]*):(\\d\\d):(\\d\\d)\\.?(\\d{1,6})?"; + var INTERVAL = new RegExp([YEAR, MONTH, DAY, TIME].map(function(regexString) { + return "(" + regexString + ")?"; + }).join("\\s*")); + var positions = { + years: 2, + months: 4, + days: 6, + hours: 9, + minutes: 10, + seconds: 11, + milliseconds: 12 + }; + var negatives = ["hours", "minutes", "seconds", "milliseconds"]; + function parseMilliseconds(fraction) { + var microseconds = fraction + "000000".slice(fraction.length); + return parseInt(microseconds, 10) / 1e3; + } + function parse(interval) { + if (!interval) return {}; + var matches = INTERVAL.exec(interval); + var isNegative = matches[8] === "-"; + return Object.keys(positions).reduce(function(parsed, property) { + var position = positions[property]; + var value = matches[position]; + if (!value) return parsed; + value = property === "milliseconds" ? parseMilliseconds(value) : parseInt(value, 10); + if (!value) return parsed; + if (isNegative && ~negatives.indexOf(property)) { + value *= -1; + } + parsed[property] = value; + return parsed; + }, {}); + } + } +}); + +// node_modules/.pnpm/postgres-bytea@1.0.1/node_modules/postgres-bytea/index.js +var require_postgres_bytea = __commonJS({ + "node_modules/.pnpm/postgres-bytea@1.0.1/node_modules/postgres-bytea/index.js"(exports, module) { + "use strict"; + var bufferFrom = Buffer.from || Buffer; + module.exports = function parseBytea(input) { + if (/^\\x/.test(input)) { + return bufferFrom(input.substr(2), "hex"); + } + var output = ""; + var i = 0; + while (i < input.length) { + if (input[i] !== "\\") { + output += input[i]; + ++i; + } else { + if (/[0-7]{3}/.test(input.substr(i + 1, 3))) { + output += String.fromCharCode(parseInt(input.substr(i + 1, 3), 8)); + i += 4; + } else { + var backslashes = 1; + while (i + backslashes < input.length && input[i + backslashes] === "\\") { + backslashes++; + } + for (var k = 0; k < Math.floor(backslashes / 2); ++k) { + output += "\\"; + } + i += Math.floor(backslashes / 2) * 2; + } + } + } + return bufferFrom(output, "binary"); + }; + } +}); + +// node_modules/.pnpm/pg-types@2.2.0/node_modules/pg-types/lib/textParsers.js +var require_textParsers = __commonJS({ + "node_modules/.pnpm/pg-types@2.2.0/node_modules/pg-types/lib/textParsers.js"(exports, module) { + var array = require_postgres_array(); + var arrayParser = require_arrayParser(); + var parseDate = require_postgres_date(); + var parseInterval = require_postgres_interval(); + var parseByteA = require_postgres_bytea(); + function allowNull(fn) { + return function nullAllowed(value) { + if (value === null) return value; + return fn(value); + }; + } + function parseBool(value) { + if (value === null) return value; + return value === "TRUE" || value === "t" || value === "true" || value === "y" || value === "yes" || value === "on" || value === "1"; + } + function parseBoolArray(value) { + if (!value) return null; + return array.parse(value, parseBool); + } + function parseBaseTenInt(string) { + return parseInt(string, 10); + } + function parseIntegerArray(value) { + if (!value) return null; + return array.parse(value, allowNull(parseBaseTenInt)); + } + function parseBigIntegerArray(value) { + if (!value) return null; + return array.parse(value, allowNull(function(entry) { + return parseBigInteger(entry).trim(); + })); + } + var parsePointArray = function(value) { + if (!value) { + return null; + } + var p = arrayParser.create(value, function(entry) { + if (entry !== null) { + entry = parsePoint(entry); + } + return entry; + }); + return p.parse(); + }; + var parseFloatArray = function(value) { + if (!value) { + return null; + } + var p = arrayParser.create(value, function(entry) { + if (entry !== null) { + entry = parseFloat(entry); + } + return entry; + }); + return p.parse(); + }; + var parseStringArray = function(value) { + if (!value) { + return null; + } + var p = arrayParser.create(value); + return p.parse(); + }; + var parseDateArray = function(value) { + if (!value) { + return null; + } + var p = arrayParser.create(value, function(entry) { + if (entry !== null) { + entry = parseDate(entry); + } + return entry; + }); + return p.parse(); + }; + var parseIntervalArray = function(value) { + if (!value) { + return null; + } + var p = arrayParser.create(value, function(entry) { + if (entry !== null) { + entry = parseInterval(entry); + } + return entry; + }); + return p.parse(); + }; + var parseByteAArray = function(value) { + if (!value) { + return null; + } + return array.parse(value, allowNull(parseByteA)); + }; + var parseInteger = function(value) { + return parseInt(value, 10); + }; + var parseBigInteger = function(value) { + var valStr = String(value); + if (/^\d+$/.test(valStr)) { + return valStr; + } + return value; + }; + var parseJsonArray = function(value) { + if (!value) { + return null; + } + return array.parse(value, allowNull(JSON.parse)); + }; + var parsePoint = function(value) { + if (value[0] !== "(") { + return null; + } + value = value.substring(1, value.length - 1).split(","); + return { + x: parseFloat(value[0]), + y: parseFloat(value[1]) + }; + }; + var parseCircle = function(value) { + if (value[0] !== "<" && value[1] !== "(") { + return null; + } + var point = "("; + var radius = ""; + var pointParsed = false; + for (var i = 2; i < value.length - 1; i++) { + if (!pointParsed) { + point += value[i]; + } + if (value[i] === ")") { + pointParsed = true; + continue; + } else if (!pointParsed) { + continue; + } + if (value[i] === ",") { + continue; + } + radius += value[i]; + } + var result = parsePoint(point); + result.radius = parseFloat(radius); + return result; + }; + var init = function(register) { + register(20, parseBigInteger); + register(21, parseInteger); + register(23, parseInteger); + register(26, parseInteger); + register(700, parseFloat); + register(701, parseFloat); + register(16, parseBool); + register(1082, parseDate); + register(1114, parseDate); + register(1184, parseDate); + register(600, parsePoint); + register(651, parseStringArray); + register(718, parseCircle); + register(1e3, parseBoolArray); + register(1001, parseByteAArray); + register(1005, parseIntegerArray); + register(1007, parseIntegerArray); + register(1028, parseIntegerArray); + register(1016, parseBigIntegerArray); + register(1017, parsePointArray); + register(1021, parseFloatArray); + register(1022, parseFloatArray); + register(1231, parseFloatArray); + register(1014, parseStringArray); + register(1015, parseStringArray); + register(1008, parseStringArray); + register(1009, parseStringArray); + register(1040, parseStringArray); + register(1041, parseStringArray); + register(1115, parseDateArray); + register(1182, parseDateArray); + register(1185, parseDateArray); + register(1186, parseInterval); + register(1187, parseIntervalArray); + register(17, parseByteA); + register(114, JSON.parse.bind(JSON)); + register(3802, JSON.parse.bind(JSON)); + register(199, parseJsonArray); + register(3807, parseJsonArray); + register(3907, parseStringArray); + register(2951, parseStringArray); + register(791, parseStringArray); + register(1183, parseStringArray); + register(1270, parseStringArray); + }; + module.exports = { + init + }; + } +}); + +// node_modules/.pnpm/pg-int8@1.0.1/node_modules/pg-int8/index.js +var require_pg_int8 = __commonJS({ + "node_modules/.pnpm/pg-int8@1.0.1/node_modules/pg-int8/index.js"(exports, module) { + "use strict"; + var BASE = 1e6; + function readInt8(buffer) { + var high = buffer.readInt32BE(0); + var low = buffer.readUInt32BE(4); + var sign = ""; + if (high < 0) { + high = ~high + (low === 0); + low = ~low + 1 >>> 0; + sign = "-"; + } + var result = ""; + var carry; + var t; + var digits; + var pad; + var l; + var i; + { + carry = high % BASE; + high = high / BASE >>> 0; + t = 4294967296 * carry + low; + low = t / BASE >>> 0; + digits = "" + (t - BASE * low); + if (low === 0 && high === 0) { + return sign + digits + result; + } + pad = ""; + l = 6 - digits.length; + for (i = 0; i < l; i++) { + pad += "0"; + } + result = pad + digits + result; + } + { + carry = high % BASE; + high = high / BASE >>> 0; + t = 4294967296 * carry + low; + low = t / BASE >>> 0; + digits = "" + (t - BASE * low); + if (low === 0 && high === 0) { + return sign + digits + result; + } + pad = ""; + l = 6 - digits.length; + for (i = 0; i < l; i++) { + pad += "0"; + } + result = pad + digits + result; + } + { + carry = high % BASE; + high = high / BASE >>> 0; + t = 4294967296 * carry + low; + low = t / BASE >>> 0; + digits = "" + (t - BASE * low); + if (low === 0 && high === 0) { + return sign + digits + result; + } + pad = ""; + l = 6 - digits.length; + for (i = 0; i < l; i++) { + pad += "0"; + } + result = pad + digits + result; + } + { + carry = high % BASE; + t = 4294967296 * carry + low; + digits = "" + t % BASE; + return sign + digits + result; + } + } + module.exports = readInt8; + } +}); + +// node_modules/.pnpm/pg-types@2.2.0/node_modules/pg-types/lib/binaryParsers.js +var require_binaryParsers = __commonJS({ + "node_modules/.pnpm/pg-types@2.2.0/node_modules/pg-types/lib/binaryParsers.js"(exports, module) { + var parseInt64 = require_pg_int8(); + var parseBits = function(data, bits, offset, invert, callback) { + offset = offset || 0; + invert = invert || false; + callback = callback || function(lastValue, newValue, bits2) { + return lastValue * Math.pow(2, bits2) + newValue; + }; + var offsetBytes = offset >> 3; + var inv = function(value) { + if (invert) { + return ~value & 255; + } + return value; + }; + var mask = 255; + var firstBits = 8 - offset % 8; + if (bits < firstBits) { + mask = 255 << 8 - bits & 255; + firstBits = bits; + } + if (offset) { + mask = mask >> offset % 8; + } + var result = 0; + if (offset % 8 + bits >= 8) { + result = callback(0, inv(data[offsetBytes]) & mask, firstBits); + } + var bytes = bits + offset >> 3; + for (var i = offsetBytes + 1; i < bytes; i++) { + result = callback(result, inv(data[i]), 8); + } + var lastBits = (bits + offset) % 8; + if (lastBits > 0) { + result = callback(result, inv(data[bytes]) >> 8 - lastBits, lastBits); + } + return result; + }; + var parseFloatFromBits = function(data, precisionBits, exponentBits) { + var bias = Math.pow(2, exponentBits - 1) - 1; + var sign = parseBits(data, 1); + var exponent = parseBits(data, exponentBits, 1); + if (exponent === 0) { + return 0; + } + var precisionBitsCounter = 1; + var parsePrecisionBits = function(lastValue, newValue, bits) { + if (lastValue === 0) { + lastValue = 1; + } + for (var i = 1; i <= bits; i++) { + precisionBitsCounter /= 2; + if ((newValue & 1 << bits - i) > 0) { + lastValue += precisionBitsCounter; + } + } + return lastValue; + }; + var mantissa = parseBits(data, precisionBits, exponentBits + 1, false, parsePrecisionBits); + if (exponent == Math.pow(2, exponentBits + 1) - 1) { + if (mantissa === 0) { + return sign === 0 ? Infinity : -Infinity; + } + return NaN; + } + return (sign === 0 ? 1 : -1) * Math.pow(2, exponent - bias) * mantissa; + }; + var parseInt16 = function(value) { + if (parseBits(value, 1) == 1) { + return -1 * (parseBits(value, 15, 1, true) + 1); + } + return parseBits(value, 15, 1); + }; + var parseInt32 = function(value) { + if (parseBits(value, 1) == 1) { + return -1 * (parseBits(value, 31, 1, true) + 1); + } + return parseBits(value, 31, 1); + }; + var parseFloat32 = function(value) { + return parseFloatFromBits(value, 23, 8); + }; + var parseFloat64 = function(value) { + return parseFloatFromBits(value, 52, 11); + }; + var parseNumeric = function(value) { + var sign = parseBits(value, 16, 32); + if (sign == 49152) { + return NaN; + } + var weight = Math.pow(1e4, parseBits(value, 16, 16)); + var result = 0; + var digits = []; + var ndigits = parseBits(value, 16); + for (var i = 0; i < ndigits; i++) { + result += parseBits(value, 16, 64 + 16 * i) * weight; + weight /= 1e4; + } + var scale = Math.pow(10, parseBits(value, 16, 48)); + return (sign === 0 ? 1 : -1) * Math.round(result * scale) / scale; + }; + var parseDate = function(isUTC, value) { + var sign = parseBits(value, 1); + var rawValue = parseBits(value, 63, 1); + var result = new Date((sign === 0 ? 1 : -1) * rawValue / 1e3 + 9466848e5); + if (!isUTC) { + result.setTime(result.getTime() + result.getTimezoneOffset() * 6e4); + } + result.usec = rawValue % 1e3; + result.getMicroSeconds = function() { + return this.usec; + }; + result.setMicroSeconds = function(value2) { + this.usec = value2; + }; + result.getUTCMicroSeconds = function() { + return this.usec; + }; + return result; + }; + var parseArray = function(value) { + var dim = parseBits(value, 32); + var flags = parseBits(value, 32, 32); + var elementType = parseBits(value, 32, 64); + var offset = 96; + var dims = []; + for (var i = 0; i < dim; i++) { + dims[i] = parseBits(value, 32, offset); + offset += 32; + offset += 32; + } + var parseElement = function(elementType2) { + var length = parseBits(value, 32, offset); + offset += 32; + if (length == 4294967295) { + return null; + } + var result; + if (elementType2 == 23 || elementType2 == 20) { + result = parseBits(value, length * 8, offset); + offset += length * 8; + return result; + } else if (elementType2 == 25) { + result = value.toString(this.encoding, offset >> 3, (offset += length << 3) >> 3); + return result; + } else { + console.log("ERROR: ElementType not implemented: " + elementType2); + } + }; + var parse = function(dimension, elementType2) { + var array = []; + var i2; + if (dimension.length > 1) { + var count = dimension.shift(); + for (i2 = 0; i2 < count; i2++) { + array[i2] = parse(dimension, elementType2); + } + dimension.unshift(count); + } else { + for (i2 = 0; i2 < dimension[0]; i2++) { + array[i2] = parseElement(elementType2); + } + } + return array; + }; + return parse(dims, elementType); + }; + var parseText = function(value) { + return value.toString("utf8"); + }; + var parseBool = function(value) { + if (value === null) return null; + return parseBits(value, 8) > 0; + }; + var init = function(register) { + register(20, parseInt64); + register(21, parseInt16); + register(23, parseInt32); + register(26, parseInt32); + register(1700, parseNumeric); + register(700, parseFloat32); + register(701, parseFloat64); + register(16, parseBool); + register(1114, parseDate.bind(null, false)); + register(1184, parseDate.bind(null, true)); + register(1e3, parseArray); + register(1007, parseArray); + register(1016, parseArray); + register(1008, parseArray); + register(1009, parseArray); + register(25, parseText); + }; + module.exports = { + init + }; + } +}); + +// node_modules/.pnpm/pg-types@2.2.0/node_modules/pg-types/lib/builtins.js +var require_builtins = __commonJS({ + "node_modules/.pnpm/pg-types@2.2.0/node_modules/pg-types/lib/builtins.js"(exports, module) { + module.exports = { + BOOL: 16, + BYTEA: 17, + CHAR: 18, + INT8: 20, + INT2: 21, + INT4: 23, + REGPROC: 24, + TEXT: 25, + OID: 26, + TID: 27, + XID: 28, + CID: 29, + JSON: 114, + XML: 142, + PG_NODE_TREE: 194, + SMGR: 210, + PATH: 602, + POLYGON: 604, + CIDR: 650, + FLOAT4: 700, + FLOAT8: 701, + ABSTIME: 702, + RELTIME: 703, + TINTERVAL: 704, + CIRCLE: 718, + MACADDR8: 774, + MONEY: 790, + MACADDR: 829, + INET: 869, + ACLITEM: 1033, + BPCHAR: 1042, + VARCHAR: 1043, + DATE: 1082, + TIME: 1083, + TIMESTAMP: 1114, + TIMESTAMPTZ: 1184, + INTERVAL: 1186, + TIMETZ: 1266, + BIT: 1560, + VARBIT: 1562, + NUMERIC: 1700, + REFCURSOR: 1790, + REGPROCEDURE: 2202, + REGOPER: 2203, + REGOPERATOR: 2204, + REGCLASS: 2205, + REGTYPE: 2206, + UUID: 2950, + TXID_SNAPSHOT: 2970, + PG_LSN: 3220, + PG_NDISTINCT: 3361, + PG_DEPENDENCIES: 3402, + TSVECTOR: 3614, + TSQUERY: 3615, + GTSVECTOR: 3642, + REGCONFIG: 3734, + REGDICTIONARY: 3769, + JSONB: 3802, + REGNAMESPACE: 4089, + REGROLE: 4096 + }; + } +}); + +// node_modules/.pnpm/pg-types@2.2.0/node_modules/pg-types/index.js +var require_pg_types = __commonJS({ + "node_modules/.pnpm/pg-types@2.2.0/node_modules/pg-types/index.js"(exports) { + var textParsers = require_textParsers(); + var binaryParsers = require_binaryParsers(); + var arrayParser = require_arrayParser(); + var builtinTypes = require_builtins(); + exports.getTypeParser = getTypeParser; + exports.setTypeParser = setTypeParser; + exports.arrayParser = arrayParser; + exports.builtins = builtinTypes; + var typeParsers = { + text: {}, + binary: {} + }; + function noParse(val) { + return String(val); + } + function getTypeParser(oid, format) { + format = format || "text"; + if (!typeParsers[format]) { + return noParse; + } + return typeParsers[format][oid] || noParse; + } + function setTypeParser(oid, format, parseFn) { + if (typeof format == "function") { + parseFn = format; + format = "text"; + } + typeParsers[format][oid] = parseFn; + } + textParsers.init(function(oid, converter) { + typeParsers.text[oid] = converter; + }); + binaryParsers.init(function(oid, converter) { + typeParsers.binary[oid] = converter; + }); + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/defaults.js +var require_defaults = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/defaults.js"(exports, module) { + "use strict"; + var user; + try { + user = process.platform === "win32" ? process.env.USERNAME : process.env.USER; + } catch { + } + module.exports = { + // database host. defaults to localhost + host: "localhost", + // database user's name + user, + // name of database to connect + database: void 0, + // database user's password + password: null, + // a Postgres connection string to be used instead of setting individual connection items + // NOTE: Setting this value will cause it to override any other value (such as database or user) defined + // in the defaults object. + connectionString: void 0, + // database port + port: 5432, + // number of rows to return at a time from a prepared statement's + // portal. 0 will return all rows at once + rows: 0, + // binary result mode + binary: false, + // Connection pool options - see https://github.com/brianc/node-pg-pool + // number of connections to use in connection pool + // 0 will disable connection pooling + max: 10, + // max milliseconds a client can go unused before it is removed + // from the pool and destroyed + idleTimeoutMillis: 3e4, + client_encoding: "", + ssl: false, + // SSL negotiation style: 'postgres' (traditional SSLRequest) or 'direct' + sslnegotiation: void 0, + application_name: void 0, + fallback_application_name: void 0, + options: void 0, + parseInputDatesAsUTC: false, + // max milliseconds any query using this connection will execute for before timing out in error. + // false=unlimited + statement_timeout: false, + // Abort any statement that waits longer than the specified duration in milliseconds while attempting to acquire a lock. + // false=unlimited + lock_timeout: false, + // Terminate any session with an open transaction that has been idle for longer than the specified duration in milliseconds + // false=unlimited + idle_in_transaction_session_timeout: false, + // max milliseconds to wait for query to complete (client side) + query_timeout: false, + connect_timeout: 0, + keepalives: 1, + keepalives_idle: 0 + }; + var pgTypes = require_pg_types(); + var parseBigInteger = pgTypes.getTypeParser(20, "text"); + var parseBigIntegerArray = pgTypes.getTypeParser(1016, "text"); + module.exports.__defineSetter__("parseInt8", function(val) { + pgTypes.setTypeParser(20, "text", val ? pgTypes.getTypeParser(23, "text") : parseBigInteger); + pgTypes.setTypeParser(1016, "text", val ? pgTypes.getTypeParser(1007, "text") : parseBigIntegerArray); + }); + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/utils.js +var require_utils = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/utils.js"(exports, module) { + "use strict"; + var defaults2 = require_defaults(); + var { isDate } = __require("util/types"); + function escapeElement(elementRepresentation) { + const escaped = elementRepresentation.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return '"' + escaped + '"'; + } + function arrayString(val) { + let result = "{"; + for (let i = 0; i < val.length; i++) { + if (i > 0) { + result += ","; + } + let item = val[i]; + if (item == null) { + result += "NULL"; + } else if (Array.isArray(item)) { + result += arrayString(item); + } else if (ArrayBuffer.isView(item)) { + if (!(item instanceof Buffer)) { + item = Buffer.from(item.buffer, item.byteOffset, item.byteLength); + } + result += "\\\\x" + item.toString("hex"); + } else { + result += escapeElement(prepareValue(item)); + } + } + result += "}"; + return result; + } + var prepareValue = function(val, seen) { + if (val == null) { + return null; + } + if (typeof val === "object") { + if (val instanceof Buffer) { + return val; + } + if (ArrayBuffer.isView(val)) { + return Buffer.from(val.buffer, val.byteOffset, val.byteLength); + } + if (isDate(val)) { + if (defaults2.parseInputDatesAsUTC) { + return dateToStringUTC(val); + } else { + return dateToString(val); + } + } + if (Array.isArray(val)) { + return arrayString(val); + } + return prepareObject(val, seen); + } + return val.toString(); + }; + function prepareObject(val, seen) { + if (val && typeof val.toPostgres === "function") { + seen = seen || []; + if (seen.indexOf(val) !== -1) { + throw new Error('circular reference detected while preparing "' + val + '" for query'); + } + seen.push(val); + return prepareValue(val.toPostgres(prepareValue), seen); + } + return JSON.stringify(val); + } + function dateToString(date) { + let offset = -date.getTimezoneOffset(); + let year = date.getFullYear(); + const isBCYear = year < 1; + if (isBCYear) year = Math.abs(year) + 1; + let ret = String(year).padStart(4, "0") + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0") + "T" + String(date.getHours()).padStart(2, "0") + ":" + String(date.getMinutes()).padStart(2, "0") + ":" + String(date.getSeconds()).padStart(2, "0") + "." + String(date.getMilliseconds()).padStart(3, "0"); + if (offset < 0) { + ret += "-"; + offset *= -1; + } else { + ret += "+"; + } + ret += String(Math.floor(offset / 60)).padStart(2, "0") + ":" + String(offset % 60).padStart(2, "0"); + if (isBCYear) ret += " BC"; + return ret; + } + function dateToStringUTC(date) { + let year = date.getUTCFullYear(); + const isBCYear = year < 1; + if (isBCYear) year = Math.abs(year) + 1; + let ret = String(year).padStart(4, "0") + "-" + String(date.getUTCMonth() + 1).padStart(2, "0") + "-" + String(date.getUTCDate()).padStart(2, "0") + "T" + String(date.getUTCHours()).padStart(2, "0") + ":" + String(date.getUTCMinutes()).padStart(2, "0") + ":" + String(date.getUTCSeconds()).padStart(2, "0") + "." + String(date.getUTCMilliseconds()).padStart(3, "0"); + ret += "+00:00"; + if (isBCYear) ret += " BC"; + return ret; + } + function normalizeQueryConfig(config, values, callback) { + config = typeof config === "string" ? { text: config } : config; + if (values) { + if (typeof values === "function") { + config.callback = values; + } else { + config.values = values; + } + } + if (callback) { + config.callback = callback; + } + return config; + } + var escapeIdentifier2 = function(str) { + return '"' + str.replace(/"/g, '""') + '"'; + }; + var escapeLiteral2 = function(str) { + let hasBackslash = false; + let escaped = "'"; + if (str == null) { + return "''"; + } + if (typeof str !== "string") { + return "''"; + } + for (let i = 0; i < str.length; i++) { + const c = str[i]; + if (c === "'") { + escaped += c + c; + } else if (c === "\\") { + escaped += c + c; + hasBackslash = true; + } else { + escaped += c; + } + } + escaped += "'"; + if (hasBackslash === true) { + escaped = " E" + escaped; + } + return escaped; + }; + module.exports = { + prepareValue: function prepareValueWrapper(value) { + return prepareValue(value); + }, + normalizeQueryConfig, + escapeIdentifier: escapeIdentifier2, + escapeLiteral: escapeLiteral2 + }; + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/crypto/utils.js +var require_utils2 = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/crypto/utils.js"(exports, module) { + var nodeCrypto = __require("crypto"); + module.exports = { + postgresMd5PasswordHash, + randomBytes, + deriveKey, + sha256, + hashByName, + hmacSha256, + md5 + }; + var webCrypto = nodeCrypto.webcrypto || globalThis.crypto; + var subtleCrypto = webCrypto.subtle; + var textEncoder = new TextEncoder(); + function randomBytes(length) { + return webCrypto.getRandomValues(Buffer.alloc(length)); + } + async function md5(string) { + try { + return nodeCrypto.createHash("md5").update(string, "utf-8").digest("hex"); + } catch (e) { + const data = typeof string === "string" ? textEncoder.encode(string) : string; + const hash = await subtleCrypto.digest("MD5", data); + return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join(""); + } + } + async function postgresMd5PasswordHash(user, password, salt) { + const inner = await md5(password + user); + const outer = await md5(Buffer.concat([Buffer.from(inner), salt])); + return "md5" + outer; + } + async function sha256(text) { + return await subtleCrypto.digest("SHA-256", text); + } + async function hashByName(hashName, text) { + return await subtleCrypto.digest(hashName, text); + } + async function hmacSha256(keyBuffer, msg) { + const key = await subtleCrypto.importKey("raw", keyBuffer, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + return await subtleCrypto.sign("HMAC", key, textEncoder.encode(msg)); + } + async function deriveKey(password, salt, iterations) { + const key = await subtleCrypto.importKey("raw", textEncoder.encode(password), "PBKDF2", false, ["deriveBits"]); + const params = { name: "PBKDF2", hash: "SHA-256", salt, iterations }; + return await subtleCrypto.deriveBits(params, key, 32 * 8, ["deriveBits"]); + } + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/crypto/cert-signatures.js +var require_cert_signatures = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/crypto/cert-signatures.js"(exports, module) { + function x509Error(msg, cert) { + return new Error("SASL channel binding: " + msg + " when parsing public certificate " + cert.toString("base64")); + } + function readASN1Length(data, index) { + let length = data[index++]; + if (length < 128) return { length, index }; + const lengthBytes = length & 127; + if (lengthBytes > 4) throw x509Error("bad length", data); + length = 0; + for (let i = 0; i < lengthBytes; i++) { + length = length << 8 | data[index++]; + } + return { length, index }; + } + function readASN1OID(data, index) { + if (data[index++] !== 6) throw x509Error("non-OID data", data); + const { length: OIDLength, index: indexAfterOIDLength } = readASN1Length(data, index); + index = indexAfterOIDLength; + const lastIndex = index + OIDLength; + const byte1 = data[index++]; + let oid = (byte1 / 40 >> 0) + "." + byte1 % 40; + while (index < lastIndex) { + let value = 0; + while (index < lastIndex) { + const nextByte = data[index++]; + value = value << 7 | nextByte & 127; + if (nextByte < 128) break; + } + oid += "." + value; + } + return { oid, index }; + } + function expectASN1Seq(data, index) { + if (data[index++] !== 48) throw x509Error("non-sequence data", data); + return readASN1Length(data, index); + } + function signatureAlgorithmHashFromCertificate(data, index) { + if (index === void 0) index = 0; + index = expectASN1Seq(data, index).index; + const { length: certInfoLength, index: indexAfterCertInfoLength } = expectASN1Seq(data, index); + index = indexAfterCertInfoLength + certInfoLength; + index = expectASN1Seq(data, index).index; + const { oid, index: indexAfterOID } = readASN1OID(data, index); + switch (oid) { + // RSA + case "1.2.840.113549.1.1.4": + return "MD5"; + case "1.2.840.113549.1.1.5": + return "SHA-1"; + case "1.2.840.113549.1.1.11": + return "SHA-256"; + case "1.2.840.113549.1.1.12": + return "SHA-384"; + case "1.2.840.113549.1.1.13": + return "SHA-512"; + case "1.2.840.113549.1.1.14": + return "SHA-224"; + case "1.2.840.113549.1.1.15": + return "SHA512-224"; + case "1.2.840.113549.1.1.16": + return "SHA512-256"; + // ECDSA + case "1.2.840.10045.4.1": + return "SHA-1"; + case "1.2.840.10045.4.3.1": + return "SHA-224"; + case "1.2.840.10045.4.3.2": + return "SHA-256"; + case "1.2.840.10045.4.3.3": + return "SHA-384"; + case "1.2.840.10045.4.3.4": + return "SHA-512"; + // RSASSA-PSS: hash is indicated separately + case "1.2.840.113549.1.1.10": { + index = indexAfterOID; + index = expectASN1Seq(data, index).index; + if (data[index++] !== 160) throw x509Error("non-tag data", data); + index = readASN1Length(data, index).index; + index = expectASN1Seq(data, index).index; + const { oid: hashOID } = readASN1OID(data, index); + switch (hashOID) { + // standalone hash OIDs + case "1.2.840.113549.2.5": + return "MD5"; + case "1.3.14.3.2.26": + return "SHA-1"; + case "2.16.840.1.101.3.4.2.1": + return "SHA-256"; + case "2.16.840.1.101.3.4.2.2": + return "SHA-384"; + case "2.16.840.1.101.3.4.2.3": + return "SHA-512"; + } + throw x509Error("unknown hash OID " + hashOID, data); + } + // Ed25519 -- see https: return//github.com/openssl/openssl/issues/15477 + case "1.3.101.110": + case "1.3.101.112": + return "SHA-512"; + // Ed448 -- still not in pg 17.2 (if supported, digest would be SHAKE256 x 64 bytes) + case "1.3.101.111": + case "1.3.101.113": + throw x509Error("Ed448 certificate channel binding is not currently supported by Postgres"); + } + throw x509Error("unknown OID " + oid, data); + } + module.exports = { signatureAlgorithmHashFromCertificate }; + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/crypto/sasl.js +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 { signatureAlgorithmHashFromCertificate } = require_cert_signatures(); + function saslprep(password) { + const nonAsciiSpace = /[\u00A0\u1680\u2000-\u200B\u202F\u205F\u3000]/g; + const mappedToNothing = /[\u00AD\u034F\u1806\u180B\u180C\u180D\u200C\u200D\u2060\uFE00-\uFE0F\uFEFF]/g; + return password.replace(nonAsciiSpace, " ").replace(mappedToNothing, "").normalize("NFKC"); + } + var DEFAULT_MAX_SCRAM_ITERATIONS = 1e5; + function startSession(mechanisms, stream, scramMaxIterations = DEFAULT_MAX_SCRAM_ITERATIONS) { + const candidates = ["SCRAM-SHA-256"]; + if (stream) candidates.unshift("SCRAM-SHA-256-PLUS"); + const mechanism = candidates.find((candidate) => mechanisms.includes(candidate)); + if (!mechanism) { + throw new Error("SASL: Only mechanism(s) " + candidates.join(" and ") + " are supported"); + } + 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 gs2Header = mechanism === "SCRAM-SHA-256-PLUS" ? "p=tls-server-end-point" : stream ? "y" : "n"; + return { + mechanism, + clientNonce, + response: gs2Header + ",,n=*,r=" + clientNonce, + message: "SASLInitialResponse", + scramMaxIterations + }; + } + async function continueSession(session, password, serverData, stream) { + if (session.message !== "SASLInitialResponse") { + throw new Error("SASL: Last message was not SASLInitialResponse"); + } + if (typeof password !== "string") { + throw new Error("SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string"); + } + if (password === "") { + throw new Error("SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a non-empty string"); + } + if (typeof serverData !== "string") { + throw new Error("SASL: SCRAM-SERVER-FIRST-MESSAGE: serverData must be a string"); + } + const sv = parseServerFirstMessage(serverData); + if (!sv.nonce.startsWith(session.clientNonce)) { + throw new Error("SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce does not start with client nonce"); + } else if (sv.nonce.length === session.clientNonce.length) { + throw new Error("SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce is too short"); + } + const scramMaxIterations = typeof session.scramMaxIterations === "number" ? session.scramMaxIterations : DEFAULT_MAX_SCRAM_ITERATIONS; + if (scramMaxIterations !== 0 && sv.iteration > scramMaxIterations) { + throw new Error( + "SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration count " + sv.iteration + " exceeds scramMaxIterations of " + scramMaxIterations + ); + } + const clientFirstMessageBare = "n=*,r=" + session.clientNonce; + const serverFirstMessage = "r=" + sv.nonce + ",s=" + sv.salt + ",i=" + sv.iteration; + let channelBinding = stream ? "eSws" : "biws"; + if (session.mechanism === "SCRAM-SHA-256-PLUS") { + 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 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 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); + session.message = "SASLResponse"; + session.serverSignature = Buffer.from(serverSignatureBytes).toString("base64"); + session.response = clientFinalMessageWithoutProof + ",p=" + clientProof; + } + function finalizeSession(session, serverData) { + if (session.message !== "SASLResponse") { + throw new Error("SASL: Last message was not SASLResponse"); + } + if (typeof serverData !== "string") { + throw new Error("SASL: SCRAM-SERVER-FINAL-MESSAGE: serverData must be a string"); + } + const { serverSignature } = parseServerFinalMessage(serverData); + if (serverSignature !== session.serverSignature) { + throw new Error("SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature does not match"); + } + } + function isPrintableChars(text) { + if (typeof text !== "string") { + throw new TypeError("SASL: text must be a string"); + } + return text.split("").map((_, i) => text.charCodeAt(i)).every((c) => c >= 33 && c <= 43 || c >= 45 && c <= 126); + } + function isBase64(text) { + return /^(?:[a-zA-Z0-9+/]{4})*(?:[a-zA-Z0-9+/]{2}==|[a-zA-Z0-9+/]{3}=)?$/.test(text); + } + function parseAttributePairs(text) { + if (typeof text !== "string") { + throw new TypeError("SASL: attribute pairs text must be a string"); + } + return new Map( + text.split(",").map((attrValue) => { + if (!/^.=/.test(attrValue)) { + throw new Error("SASL: Invalid attribute pair entry"); + } + const name = attrValue[0]; + const value = attrValue.substring(2); + return [name, value]; + }) + ); + } + function parseServerFirstMessage(data) { + const attrPairs = parseAttributePairs(data); + const nonce = attrPairs.get("r"); + if (!nonce) { + throw new Error("SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce missing"); + } else if (!isPrintableChars(nonce)) { + throw new Error("SASL: SCRAM-SERVER-FIRST-MESSAGE: nonce must only contain printable characters"); + } + const salt = attrPairs.get("s"); + if (!salt) { + throw new Error("SASL: SCRAM-SERVER-FIRST-MESSAGE: salt missing"); + } else if (!isBase64(salt)) { + throw new Error("SASL: SCRAM-SERVER-FIRST-MESSAGE: salt must be base64"); + } + const iterationText = attrPairs.get("i"); + if (!iterationText) { + throw new Error("SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration missing"); + } else if (!/^[1-9][0-9]*$/.test(iterationText)) { + throw new Error("SASL: SCRAM-SERVER-FIRST-MESSAGE: invalid iteration count"); + } + const iteration = parseInt(iterationText, 10); + return { + nonce, + salt, + iteration + }; + } + function parseServerFinalMessage(serverData) { + const attrPairs = parseAttributePairs(serverData); + const error = attrPairs.get("e"); + const serverSignature = attrPairs.get("v"); + if (error) { + throw new Error(`SASL: SCRAM-SERVER-FINAL-MESSAGE: server returned error: "${error}"`); + } + if (!serverSignature) { + throw new Error("SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature is missing"); + } else if (!isBase64(serverSignature)) { + throw new Error("SASL: SCRAM-SERVER-FINAL-MESSAGE: server signature must be base64"); + } + return { + serverSignature + }; + } + function xorBuffers(a, b) { + if (!Buffer.isBuffer(a)) { + throw new TypeError("first argument must be a Buffer"); + } + if (!Buffer.isBuffer(b)) { + throw new TypeError("second argument must be a Buffer"); + } + if (a.length !== b.length) { + throw new Error("Buffer lengths must match"); + } + if (a.length === 0) { + throw new Error("Buffers cannot be empty"); + } + return Buffer.from(a.map((_, i) => a[i] ^ b[i])); + } + module.exports = { + startSession, + continueSession, + finalizeSession, + DEFAULT_MAX_SCRAM_ITERATIONS + }; + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/type-overrides.js +var require_type_overrides = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/type-overrides.js"(exports, module) { + "use strict"; + var types2 = require_pg_types(); + function TypeOverrides2(userTypes) { + this._types = userTypes || types2; + this.text = {}; + this.binary = {}; + } + TypeOverrides2.prototype.getOverrides = function(format) { + switch (format) { + case "text": + return this.text; + case "binary": + return this.binary; + default: + return {}; + } + }; + TypeOverrides2.prototype.setTypeParser = function(oid, format, parseFn) { + if (typeof format === "function") { + parseFn = format; + format = "text"; + } + this.getOverrides(format)[oid] = parseFn; + }; + TypeOverrides2.prototype.getTypeParser = function(oid, format) { + format = format || "text"; + return this.getOverrides(format)[oid] || this._types.getTypeParser(oid, format); + }; + module.exports = TypeOverrides2; + } +}); + +// node_modules/.pnpm/pg-connection-string@2.14.0/node_modules/pg-connection-string/index.js +var require_pg_connection_string = __commonJS({ + "node_modules/.pnpm/pg-connection-string@2.14.0/node_modules/pg-connection-string/index.js"(exports, module) { + "use strict"; + function parse(str, options = {}) { + if (str.charAt(0) === "/") { + const config2 = str.split(" "); + return { host: config2[0], database: config2[1] }; + } + const config = /* @__PURE__ */ Object.create(null); + let result; + let dummyHost = false; + if (/ |%[^a-f0-9]|%[a-f0-9][^a-f0-9]/i.test(str)) { + str = encodeURI(str).replace(/%25(\d\d)/g, "%$1"); + } + try { + try { + result = new URL(str, "postgres://base"); + } catch (e) { + result = new URL(str.replace("@/", "@___DUMMY___/"), "postgres://base"); + dummyHost = true; + } + } catch (err) { + err.input && (err.input = "*****REDACTED*****"); + throw err; + } + for (const entry of result.searchParams.entries()) { + config[entry[0]] = entry[1]; + } + config.user = config.user || decodeURIComponent(result.username); + config.password = config.password || decodeURIComponent(result.password); + if (result.protocol == "socket:") { + config.host = decodeURI(result.pathname); + config.database = result.searchParams.get("db"); + config.client_encoding = result.searchParams.get("encoding"); + return config; + } + const hostname = dummyHost ? "" : result.hostname; + if (!config.host) { + config.host = decodeURIComponent(hostname); + } else if (hostname && /^%2f/i.test(hostname)) { + result.pathname = hostname + result.pathname; + } + if (!config.port) { + config.port = result.port; + } + const pathname = result.pathname.slice(1) || null; + config.database = pathname ? decodeURI(pathname) : null; + if (config.ssl === "true" || config.ssl === "1") { + config.ssl = true; + } + if (config.ssl === "0") { + config.ssl = false; + } + if (config.sslcert || config.sslkey || config.sslrootcert || config.sslmode) { + config.ssl = {}; + } + if (config.sslnegotiation === "direct" && config.ssl === void 0) { + config.ssl = true; + } + const fs27 = config.sslcert || config.sslkey || config.sslrootcert ? __require("fs") : null; + if (config.sslcert) { + config.ssl.cert = fs27.readFileSync(config.sslcert).toString(); + } + if (config.sslkey) { + config.ssl.key = fs27.readFileSync(config.sslkey).toString(); + } + if (config.sslrootcert) { + config.ssl.ca = fs27.readFileSync(config.sslrootcert).toString(); + } + if (options.useLibpqCompat && config.uselibpqcompat) { + throw new Error("Both useLibpqCompat and uselibpqcompat are set. Please use only one of them."); + } + if (config.uselibpqcompat === "true" || options.useLibpqCompat) { + switch (config.sslmode) { + case "disable": { + config.ssl = false; + break; + } + case "prefer": { + config.ssl.rejectUnauthorized = false; + break; + } + case "require": { + if (config.sslrootcert) { + config.ssl.checkServerIdentity = function() { + }; + } else { + config.ssl.rejectUnauthorized = false; + } + break; + } + case "verify-ca": { + if (!config.ssl.ca) { + throw new Error( + "SECURITY WARNING: Using sslmode=verify-ca requires specifying a CA with sslrootcert. If a public CA is used, verify-ca allows connections to a server that somebody else may have registered with the CA, making you vulnerable to Man-in-the-Middle attacks. Either specify a custom CA certificate with sslrootcert parameter or use sslmode=verify-full for proper security." + ); + } + config.ssl.checkServerIdentity = function() { + }; + break; + } + case "verify-full": { + break; + } + } + } else { + switch (config.sslmode) { + case "disable": { + config.ssl = false; + break; + } + case "prefer": + case "require": + case "verify-ca": + case "verify-full": { + if (config.sslmode !== "verify-full") { + deprecatedSslModeWarning(config.sslmode); + } + break; + } + case "no-verify": { + config.ssl.rejectUnauthorized = false; + break; + } + } + } + return config; + } + function toConnectionOptions(sslConfig) { + const connectionOptions = Object.entries(sslConfig).reduce((c, [key, value]) => { + if (value !== void 0 && value !== null) { + c[key] = value; + } + return c; + }, /* @__PURE__ */ Object.create(null)); + return connectionOptions; + } + function toClientConfig(config) { + const poolConfig = Object.entries(config).reduce((c, [key, value]) => { + if (key === "ssl") { + const sslConfig = value; + if (typeof sslConfig === "boolean") { + c[key] = sslConfig; + } + if (typeof sslConfig === "object") { + c[key] = toConnectionOptions(sslConfig); + } + } else if (value !== void 0 && value !== null) { + if (key === "port") { + if (value !== "") { + const v = parseInt(value, 10); + if (isNaN(v)) { + throw new Error(`Invalid ${key}: ${value}`); + } + c[key] = v; + } + } else { + c[key] = value; + } + } + return c; + }, /* @__PURE__ */ Object.create(null)); + return poolConfig; + } + function parseIntoClientConfig(str) { + return toClientConfig(parse(str)); + } + function deprecatedSslModeWarning(sslmode) { + if (!deprecatedSslModeWarning.warned && typeof process !== "undefined" && process.emitWarning) { + deprecatedSslModeWarning.warned = true; + process.emitWarning(`SECURITY WARNING: The SSL modes 'prefer', 'require', and 'verify-ca' are treated as aliases for 'verify-full'. +In the next major version (pg-connection-string v3.0.0 and pg v9.0.0), these modes will adopt standard libpq semantics, which have weaker security guarantees. + +To prepare for this change: +- If you want the current behavior, explicitly use 'sslmode=verify-full' +- If you want libpq compatibility now, use 'uselibpqcompat=true&sslmode=${sslmode}' + +See https://www.postgresql.org/docs/current/libpq-ssl.html for libpq SSL mode definitions.`); + } + } + module.exports = parse; + parse.parse = parse; + parse.toClientConfig = toClientConfig; + parse.parseIntoClientConfig = parseIntoClientConfig; + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/connection-parameters.js +var require_connection_parameters = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/connection-parameters.js"(exports, module) { + "use strict"; + var dns = __require("dns"); + var defaults2 = require_defaults(); + var parse = require_pg_connection_string().parse; + var val = function(key, config, envVar) { + if (config[key]) { + return config[key]; + } + if (envVar === void 0) { + envVar = process.env["PG" + key.toUpperCase()]; + } else if (envVar === false) { + } else { + envVar = process.env[envVar]; + } + return envVar || defaults2[key]; + }; + var readSSLConfigFromEnvironment = function() { + switch (process.env.PGSSLMODE) { + case "disable": + return false; + case "prefer": + case "require": + case "verify-ca": + case "verify-full": + return true; + case "no-verify": + return { rejectUnauthorized: false }; + } + return defaults2.ssl; + }; + var quoteParamValue = function(value) { + return "'" + ("" + value).replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'"; + }; + var add = function(params, config, paramName) { + const value = config[paramName]; + if (value !== void 0 && value !== null) { + params.push(paramName + "=" + quoteParamValue(value)); + } + }; + var ConnectionParameters = class { + constructor(config) { + config = typeof config === "string" ? parse(config) : config || {}; + if (config.connectionString) { + config = Object.assign({}, config, parse(config.connectionString)); + } + this.user = val("user", config); + this.database = val("database", config); + if (this.database === void 0) { + this.database = this.user; + } + this.port = parseInt(val("port", config), 10); + this.host = val("host", config); + Object.defineProperty(this, "password", { + configurable: true, + enumerable: false, + writable: true, + value: val("password", config) + }); + this.binary = val("binary", config); + this.options = val("options", config); + this.ssl = typeof config.ssl === "undefined" ? readSSLConfigFromEnvironment() : config.ssl; + if (typeof this.ssl === "string") { + if (this.ssl === "true") { + this.ssl = true; + } + } + if (this.ssl === "no-verify") { + this.ssl = { rejectUnauthorized: false }; + } + if (this.ssl && this.ssl.key) { + Object.defineProperty(this.ssl, "key", { + enumerable: false + }); + } + this.sslnegotiation = val("sslnegotiation", config, "PGSSLNEGOTIATION"); + if (this.sslnegotiation !== void 0 && this.sslnegotiation !== "postgres" && this.sslnegotiation !== "direct") { + throw new Error( + `Invalid sslnegotiation value: "${this.sslnegotiation}". Valid values are "postgres" and "direct".` + ); + } + if (this.sslnegotiation === "direct" && !this.ssl) { + throw new Error("sslnegotiation=direct requires SSL to be enabled"); + } + this.client_encoding = val("client_encoding", config); + this.replication = val("replication", config); + this.isDomainSocket = !(this.host || "").indexOf("/"); + this.application_name = val("application_name", config, "PGAPPNAME"); + this.fallback_application_name = val("fallback_application_name", config, false); + this.statement_timeout = val("statement_timeout", config, false); + this.lock_timeout = val("lock_timeout", config, false); + this.idle_in_transaction_session_timeout = val("idle_in_transaction_session_timeout", config, false); + this.query_timeout = val("query_timeout", config, false); + if (config.connectionTimeoutMillis === void 0) { + this.connect_timeout = process.env.PGCONNECT_TIMEOUT || 0; + } else { + this.connect_timeout = Math.floor(config.connectionTimeoutMillis / 1e3); + } + if (config.keepAlive === false) { + this.keepalives = 0; + } else if (config.keepAlive === true) { + this.keepalives = 1; + } + if (typeof config.keepAliveInitialDelayMillis === "number") { + this.keepalives_idle = Math.floor(config.keepAliveInitialDelayMillis / 1e3); + } + } + getLibpqConnectionString(cb) { + const params = []; + add(params, this, "user"); + add(params, this, "password"); + add(params, this, "port"); + add(params, this, "application_name"); + add(params, this, "fallback_application_name"); + add(params, this, "connect_timeout"); + add(params, this, "options"); + const ssl = typeof this.ssl === "object" ? this.ssl : this.ssl ? { sslmode: this.ssl } : {}; + add(params, ssl, "sslmode"); + add(params, ssl, "sslca"); + add(params, ssl, "sslkey"); + add(params, ssl, "sslcert"); + add(params, ssl, "sslrootcert"); + add(params, this, "sslnegotiation"); + if (this.database) { + params.push("dbname=" + quoteParamValue(this.database)); + } + if (this.replication) { + params.push("replication=" + quoteParamValue(this.replication)); + } + if (this.host) { + params.push("host=" + quoteParamValue(this.host)); + } + if (this.isDomainSocket) { + return cb(null, params.join(" ")); + } + if (this.client_encoding) { + params.push("client_encoding=" + quoteParamValue(this.client_encoding)); + } + dns.lookup(this.host, function(err, address) { + if (err) return cb(err, null); + params.push("hostaddr=" + quoteParamValue(address)); + return cb(null, params.join(" ")); + }); + } + }; + module.exports = ConnectionParameters; + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/result.js +var require_result = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/result.js"(exports, module) { + "use strict"; + var types2 = require_pg_types(); + var matchRegexp = /^([A-Za-z]+)(?: (\d+))?(?: (\d+))?/; + var Result2 = class { + constructor(rowMode, types3) { + this.command = null; + this.rowCount = null; + this.oid = null; + this.rows = []; + this.fields = []; + this._parsers = void 0; + this._types = types3; + this.RowCtor = null; + this.rowAsArray = rowMode === "array"; + if (this.rowAsArray) { + this.parseRow = this._parseRowAsArray; + } + this._prebuiltEmptyResultObject = null; + } + // adds a command complete message + addCommandComplete(msg) { + let match; + if (msg.text) { + match = matchRegexp.exec(msg.text); + } else { + match = matchRegexp.exec(msg.command); + } + if (match) { + this.command = match[1]; + if (match[3]) { + this.oid = parseInt(match[2], 10); + this.rowCount = parseInt(match[3], 10); + } else if (match[2]) { + this.rowCount = parseInt(match[2], 10); + } + } + } + _parseRowAsArray(rowData) { + const row = new Array(rowData.length); + for (let i = 0, len = rowData.length; i < len; i++) { + const rawValue = rowData[i]; + if (rawValue !== null) { + row[i] = this._parsers[i](rawValue); + } else { + row[i] = null; + } + } + return row; + } + parseRow(rowData) { + const row = { ...this._prebuiltEmptyResultObject }; + for (let i = 0, len = rowData.length; i < len; i++) { + const rawValue = rowData[i]; + const field = this.fields[i].name; + if (rawValue !== null) { + const v = this.fields[i].format === "binary" ? Buffer.from(rawValue) : rawValue; + row[field] = this._parsers[i](v); + } else { + row[field] = null; + } + } + return row; + } + addRow(row) { + this.rows.push(row); + } + addFields(fieldDescriptions) { + this.fields = fieldDescriptions; + if (this.fields.length) { + this._parsers = new Array(fieldDescriptions.length); + } + const row = /* @__PURE__ */ Object.create(null); + for (let i = 0; i < fieldDescriptions.length; i++) { + const desc = fieldDescriptions[i]; + row[desc.name] = null; + if (this._types) { + this._parsers[i] = this._types.getTypeParser(desc.dataTypeID, desc.format || "text"); + } else { + this._parsers[i] = types2.getTypeParser(desc.dataTypeID, desc.format || "text"); + } + } + this._prebuiltEmptyResultObject = { ...row }; + } + }; + module.exports = Result2; + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/query.js +var require_query = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/query.js"(exports, module) { + "use strict"; + var { EventEmitter } = __require("events"); + var Result2 = require_result(); + var utils = require_utils(); + var Query2 = class extends EventEmitter { + constructor(config, values, callback) { + super(); + config = utils.normalizeQueryConfig(config, values, callback); + this.text = config.text; + this.values = config.values; + this.rows = config.rows; + this.types = config.types; + this.name = config.name; + this.queryMode = config.queryMode; + this.binary = config.binary; + this.portal = config.portal || ""; + this.callback = config.callback; + this._rowMode = config.rowMode; + if (process.domain && config.callback) { + this.callback = process.domain.bind(config.callback); + } + this._result = new Result2(this._rowMode, this.types); + this._results = this._result; + this._canceledDueToError = false; + } + requiresPreparation() { + if (this.queryMode === "extended") { + return true; + } + if (this.name) { + return true; + } + if (this.rows) { + return true; + } + if (!this.text) { + return false; + } + if (!this.values) { + return false; + } + return this.values.length > 0; + } + _checkForMultirow() { + if (this._result.command) { + if (!Array.isArray(this._results)) { + this._results = [this._result]; + } + this._result = new Result2(this._rowMode, this._result._types); + this._results.push(this._result); + } + } + // associates row metadata from the supplied + // message with this query object + // metadata used when parsing row results + handleRowDescription(msg) { + this._checkForMultirow(); + this._result.addFields(msg.fields); + this._accumulateRows = this.callback || !this.listeners("row").length; + } + handleDataRow(msg) { + let row; + if (this._canceledDueToError) { + return; + } + try { + row = this._result.parseRow(msg.fields); + } catch (err) { + this._canceledDueToError = err; + return; + } + this.emit("row", row, this._result); + if (this._accumulateRows) { + this._result.addRow(row); + } + } + handleCommandComplete(msg, connection) { + this._checkForMultirow(); + this._result.addCommandComplete(msg); + if (this.rows) { + connection.sync(); + } + } + // if a named prepared statement is created with empty query text + // the backend will send an emptyQuery message but *not* a command complete message + // since we pipeline sync immediately after execute we don't need to do anything here + // unless we have rows specified, in which case we did not pipeline the initial sync call + handleEmptyQuery(connection) { + if (this.rows) { + connection.sync(); + } + } + handleError(err, connection) { + if (this._canceledDueToError) { + err = this._canceledDueToError; + this._canceledDueToError = false; + } + if (this.callback) { + return this.callback(err); + } + this.emit("error", err); + } + handleReadyForQuery(con) { + if (this._canceledDueToError) { + return this.handleError(this._canceledDueToError, con); + } + if (this.callback) { + try { + this.callback(null, this._results); + } catch (err) { + process.nextTick(() => { + throw err; + }); + } + } + this.emit("end", this._results); + } + submit(connection) { + if (typeof this.text !== "string" && typeof this.name !== "string") { + return new Error("A query must have either text or a name. Supplying neither is unsupported."); + } + const previous = connection.parsedStatements[this.name]; + if (this.text && previous && this.text !== previous) { + return new Error(`Prepared statements must be unique - '${this.name}' was used for a different statement`); + } + if (this.values && !Array.isArray(this.values)) { + return new Error("Query values must be an array"); + } + if (this.requiresPreparation()) { + connection.stream.cork && connection.stream.cork(); + try { + this.prepare(connection); + } finally { + connection.stream.uncork && connection.stream.uncork(); + } + } else { + connection.query(this.text); + } + return null; + } + hasBeenParsed(connection) { + return this.name && connection.parsedStatements[this.name]; + } + handlePortalSuspended(connection) { + this._getRows(connection, this.rows); + } + _getRows(connection, rows) { + connection.execute({ + portal: this.portal, + rows + }); + if (!rows) { + connection.sync(); + } else { + connection.flush(); + } + } + // http://developer.postgresql.org/pgdocs/postgres/protocol-flow.html#PROTOCOL-FLOW-EXT-QUERY + prepare(connection) { + if (!this.hasBeenParsed(connection)) { + connection.parse({ + text: this.text, + name: this.name, + types: this.types + }); + } + try { + connection.bind({ + portal: this.portal, + statement: this.name, + values: this.values, + binary: this.binary, + valueMapper: utils.prepareValue + }); + } catch (err) { + connection.close({ type: "S", name: this.name }); + connection.sync(); + this.handleError(err, connection); + return; + } + connection.describe({ + type: "P", + name: this.portal || "" + }); + this._getRows(connection, this.rows); + } + handleCopyInResponse(connection) { + connection.sendCopyFail("No source stream defined"); + } + handleCopyData(msg, connection) { + } + }; + module.exports = Query2; + } +}); + +// node_modules/.pnpm/pg-protocol@1.15.0/node_modules/pg-protocol/dist/messages.js +var require_messages = __commonJS({ + "node_modules/.pnpm/pg-protocol@1.15.0/node_modules/pg-protocol/dist/messages.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.NoticeMessage = exports.DataRowMessage = exports.CommandCompleteMessage = exports.ReadyForQueryMessage = exports.NotificationResponseMessage = exports.BackendKeyDataMessage = exports.AuthenticationMD5Password = exports.ParameterStatusMessage = exports.ParameterDescriptionMessage = exports.RowDescriptionMessage = exports.Field = exports.CopyResponse = exports.CopyDataMessage = exports.DatabaseError = exports.copyDone = exports.emptyQuery = exports.replicationStart = exports.portalSuspended = exports.noData = exports.closeComplete = exports.bindComplete = exports.parseComplete = void 0; + exports.parseComplete = { + name: "parseComplete", + length: 5 + }; + exports.bindComplete = { + name: "bindComplete", + length: 5 + }; + exports.closeComplete = { + name: "closeComplete", + length: 5 + }; + exports.noData = { + name: "noData", + length: 5 + }; + exports.portalSuspended = { + name: "portalSuspended", + length: 5 + }; + exports.replicationStart = { + name: "replicationStart", + length: 4 + }; + exports.emptyQuery = { + name: "emptyQuery", + length: 4 + }; + exports.copyDone = { + name: "copyDone", + length: 4 + }; + var DatabaseError2 = class extends Error { + constructor(message, length, name) { + super(message); + this.length = length; + this.name = name; + } + }; + exports.DatabaseError = DatabaseError2; + var CopyDataMessage = class { + constructor(length, chunk) { + this.length = length; + this.chunk = chunk; + this.name = "copyData"; + } + }; + exports.CopyDataMessage = CopyDataMessage; + var CopyResponse = class { + constructor(length, name, binary, columnCount) { + this.length = length; + this.name = name; + this.binary = binary; + this.columnTypes = new Array(columnCount); + } + }; + exports.CopyResponse = CopyResponse; + var Field = class { + constructor(name, tableID, columnID, dataTypeID, dataTypeSize, dataTypeModifier, format) { + this.name = name; + this.tableID = tableID; + this.columnID = columnID; + this.dataTypeID = dataTypeID; + this.dataTypeSize = dataTypeSize; + this.dataTypeModifier = dataTypeModifier; + this.format = format; + } + }; + exports.Field = Field; + var RowDescriptionMessage = class { + constructor(length, fieldCount) { + this.length = length; + this.fieldCount = fieldCount; + this.name = "rowDescription"; + this.fields = new Array(this.fieldCount); + } + }; + exports.RowDescriptionMessage = RowDescriptionMessage; + var ParameterDescriptionMessage = class { + constructor(length, parameterCount) { + this.length = length; + this.parameterCount = parameterCount; + this.name = "parameterDescription"; + this.dataTypeIDs = new Array(this.parameterCount); + } + }; + exports.ParameterDescriptionMessage = ParameterDescriptionMessage; + var ParameterStatusMessage = class { + constructor(length, parameterName, parameterValue) { + this.length = length; + this.parameterName = parameterName; + this.parameterValue = parameterValue; + this.name = "parameterStatus"; + } + }; + exports.ParameterStatusMessage = ParameterStatusMessage; + var AuthenticationMD5Password = class { + constructor(length, salt) { + this.length = length; + this.salt = salt; + this.name = "authenticationMD5Password"; + } + }; + exports.AuthenticationMD5Password = AuthenticationMD5Password; + var BackendKeyDataMessage = class { + constructor(length, processID, secretKey) { + this.length = length; + this.processID = processID; + this.secretKey = secretKey; + this.name = "backendKeyData"; + } + }; + exports.BackendKeyDataMessage = BackendKeyDataMessage; + var NotificationResponseMessage = class { + constructor(length, processId, channel, payload) { + this.length = length; + this.processId = processId; + this.channel = channel; + this.payload = payload; + this.name = "notification"; + } + }; + exports.NotificationResponseMessage = NotificationResponseMessage; + var ReadyForQueryMessage = class { + constructor(length, status) { + this.length = length; + this.status = status; + this.name = "readyForQuery"; + } + }; + exports.ReadyForQueryMessage = ReadyForQueryMessage; + var CommandCompleteMessage = class { + constructor(length, text) { + this.length = length; + this.text = text; + this.name = "commandComplete"; + } + }; + exports.CommandCompleteMessage = CommandCompleteMessage; + var DataRowMessage = class { + constructor(length, fields) { + this.length = length; + this.fields = fields; + this.name = "dataRow"; + this.fieldCount = fields.length; + } + }; + exports.DataRowMessage = DataRowMessage; + var NoticeMessage = class { + constructor(length, message) { + this.length = length; + this.message = message; + this.name = "notice"; + } + }; + exports.NoticeMessage = NoticeMessage; + } +}); + +// node_modules/.pnpm/pg-protocol@1.15.0/node_modules/pg-protocol/dist/buffer-writer.js +var require_buffer_writer = __commonJS({ + "node_modules/.pnpm/pg-protocol@1.15.0/node_modules/pg-protocol/dist/buffer-writer.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Writer = void 0; + var Writer = class { + constructor(size = 256) { + this.size = size; + this.offset = 5; + this.headerPosition = 0; + this.buffer = Buffer.allocUnsafe(size); + } + ensure(size) { + const remaining = this.buffer.length - this.offset; + if (remaining < size) { + const oldBuffer = this.buffer; + const newSize = oldBuffer.length + (oldBuffer.length >> 1) + size; + this.buffer = Buffer.allocUnsafe(newSize); + oldBuffer.copy(this.buffer); + } + } + addInt32(num) { + this.ensure(4); + this.buffer[this.offset++] = num >>> 24 & 255; + this.buffer[this.offset++] = num >>> 16 & 255; + this.buffer[this.offset++] = num >>> 8 & 255; + this.buffer[this.offset++] = num >>> 0 & 255; + return this; + } + addInt16(num) { + this.ensure(2); + this.buffer[this.offset++] = num >>> 8 & 255; + this.buffer[this.offset++] = num >>> 0 & 255; + return this; + } + addCString(string) { + if (!string) { + this.ensure(1); + } else { + const len = Buffer.byteLength(string); + this.ensure(len + 1); + this.buffer.write(string, this.offset, "utf-8"); + this.offset += len; + } + this.buffer[this.offset++] = 0; + return this; + } + addString(string = "") { + const len = Buffer.byteLength(string); + this.ensure(len); + this.buffer.write(string, this.offset); + this.offset += len; + return this; + } + // Write an Int32 byte-length prefix immediately followed by the string's UTF-8 + // bytes. Postgres' Bind wire format prefixes every parameter with its length, + // and doing it in one method computes Buffer.byteLength ONCE — the previous + // `addInt32(Buffer.byteLength(s)).addString(s)` pairing scanned the string + // three times (byteLength for the prefix, byteLength again inside addString, + // then the encode), which is costly for large text parameters. + addInt32PrefixedString(string) { + const len = Buffer.byteLength(string); + this.ensure(4 + len); + const buffer = this.buffer; + let offset = this.offset; + buffer[offset++] = len >>> 24 & 255; + buffer[offset++] = len >>> 16 & 255; + buffer[offset++] = len >>> 8 & 255; + buffer[offset++] = len >>> 0 & 255; + buffer.write(string, offset, "utf-8"); + this.offset = offset + len; + return this; + } + add(otherBuffer) { + this.ensure(otherBuffer.length); + otherBuffer.copy(this.buffer, this.offset); + this.offset += otherBuffer.length; + return this; + } + join(code) { + if (code) { + this.buffer[this.headerPosition] = code; + const length = this.offset - (this.headerPosition + 1); + this.buffer.writeInt32BE(length, this.headerPosition + 1); + } + return this.buffer.slice(code ? 0 : 5, this.offset); + } + flush(code) { + const result = this.join(code); + this.offset = 5; + this.headerPosition = 0; + this.buffer = Buffer.allocUnsafe(this.size); + return result; + } + clear() { + this.offset = 5; + this.headerPosition = 0; + } + }; + exports.Writer = Writer; + } +}); + +// node_modules/.pnpm/pg-protocol@1.15.0/node_modules/pg-protocol/dist/serializer.js +var require_serializer = __commonJS({ + "node_modules/.pnpm/pg-protocol@1.15.0/node_modules/pg-protocol/dist/serializer.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.serialize = void 0; + var buffer_writer_1 = require_buffer_writer(); + var writer = new buffer_writer_1.Writer(); + var startup = (opts) => { + writer.addInt16(3).addInt16(0); + for (const key of Object.keys(opts)) { + writer.addCString(key).addCString(opts[key]); + } + writer.addCString("client_encoding").addCString("UTF8"); + const bodyBuffer = writer.addCString("").flush(); + const length = bodyBuffer.length + 4; + return new buffer_writer_1.Writer().addInt32(length).add(bodyBuffer).flush(); + }; + var requestSsl = () => { + const response = Buffer.allocUnsafe(8); + response.writeInt32BE(8, 0); + response.writeInt32BE(80877103, 4); + return response; + }; + var password = (password2) => { + return writer.addCString(password2).flush( + 112 + /* code.startup */ + ); + }; + var sendSASLInitialResponseMessage = function(mechanism, initialResponse) { + writer.addCString(mechanism).addInt32PrefixedString(initialResponse); + return writer.flush( + 112 + /* code.startup */ + ); + }; + var sendSCRAMClientFinalMessage = function(additionalData) { + return writer.addString(additionalData).flush( + 112 + /* code.startup */ + ); + }; + var query = (text) => { + return writer.addCString(text).flush( + 81 + /* code.query */ + ); + }; + var emptyArray = []; + var parse = (query2) => { + const name = query2.name || ""; + if (name.length > 63) { + console.error("Warning! Postgres only supports 63 characters for query names."); + console.error("You supplied %s (%s)", name, name.length); + console.error("This can cause conflicts and silent errors executing queries"); + } + const types2 = query2.types || emptyArray; + const len = types2.length; + const buffer = writer.addCString(name).addCString(query2.text).addInt16(len); + for (let i = 0; i < len; i++) { + buffer.addInt32(types2[i]); + } + return writer.flush( + 80 + /* code.parse */ + ); + }; + var paramWriter = new buffer_writer_1.Writer(); + var writeValues = function(values, valueMapper) { + for (let i = 0; i < values.length; i++) { + const mappedVal = valueMapper ? valueMapper(values[i], i) : values[i]; + if (mappedVal == null) { + writer.addInt16( + 0 + /* ParamType.STRING */ + ); + paramWriter.addInt32(-1); + } else if (mappedVal instanceof Buffer) { + writer.addInt16( + 1 + /* ParamType.BINARY */ + ); + paramWriter.addInt32(mappedVal.length); + paramWriter.add(mappedVal); + } else { + writer.addInt16( + 0 + /* ParamType.STRING */ + ); + paramWriter.addInt32PrefixedString(mappedVal); + } + } + }; + var bind = (config = {}) => { + const portal = config.portal || ""; + const statement = config.statement || ""; + const binary = config.binary || false; + const values = config.values || emptyArray; + const len = values.length; + writer.addCString(portal).addCString(statement); + writer.addInt16(len); + try { + writeValues(values, config.valueMapper); + } catch (err) { + writer.clear(); + paramWriter.clear(); + throw err; + } + writer.addInt16(len); + writer.add(paramWriter.flush()); + writer.addInt16(1); + writer.addInt16( + binary ? 1 : 0 + /* ParamType.STRING */ + ); + return writer.flush( + 66 + /* code.bind */ + ); + }; + var emptyExecute = Buffer.from([69, 0, 0, 0, 9, 0, 0, 0, 0, 0]); + var execute = (config) => { + if (!config || !config.portal && !config.rows) { + return emptyExecute; + } + const portal = config.portal || ""; + const rows = config.rows || 0; + const portalLength = Buffer.byteLength(portal); + const len = 4 + portalLength + 1 + 4; + const buff = Buffer.allocUnsafe(1 + len); + buff[0] = 69; + buff.writeInt32BE(len, 1); + buff.write(portal, 5, "utf-8"); + buff[portalLength + 5] = 0; + buff.writeUInt32BE(rows, buff.length - 4); + return buff; + }; + var cancel = (processID, secretKey) => { + const buffer = Buffer.allocUnsafe(16); + buffer.writeInt32BE(16, 0); + buffer.writeInt16BE(1234, 4); + buffer.writeInt16BE(5678, 6); + buffer.writeInt32BE(processID, 8); + buffer.writeInt32BE(secretKey, 12); + return buffer; + }; + var cstringMessage = (code, string) => { + const stringLen = Buffer.byteLength(string); + const len = 4 + stringLen + 1; + const buffer = Buffer.allocUnsafe(1 + len); + buffer[0] = code; + buffer.writeInt32BE(len, 1); + buffer.write(string, 5, "utf-8"); + buffer[len] = 0; + return buffer; + }; + var emptyDescribePortal = writer.addCString("P").flush( + 68 + /* code.describe */ + ); + var emptyDescribeStatement = writer.addCString("S").flush( + 68 + /* code.describe */ + ); + var describe = (msg) => { + return msg.name ? cstringMessage(68, `${msg.type}${msg.name || ""}`) : msg.type === "P" ? emptyDescribePortal : emptyDescribeStatement; + }; + var close = (msg) => { + const text = `${msg.type}${msg.name || ""}`; + return cstringMessage(67, text); + }; + var copyData = (chunk) => { + return writer.add(chunk).flush( + 100 + /* code.copyFromChunk */ + ); + }; + var copyFail = (message) => { + return cstringMessage(102, message); + }; + var codeOnlyBuffer = (code) => Buffer.from([code, 0, 0, 0, 4]); + var flushBuffer = codeOnlyBuffer( + 72 + /* code.flush */ + ); + var syncBuffer = codeOnlyBuffer( + 83 + /* code.sync */ + ); + var endBuffer = codeOnlyBuffer( + 88 + /* code.end */ + ); + var copyDoneBuffer = codeOnlyBuffer( + 99 + /* code.copyDone */ + ); + var serialize = { + startup, + password, + requestSsl, + sendSASLInitialResponseMessage, + sendSCRAMClientFinalMessage, + query, + parse, + bind, + execute, + describe, + close, + flush: () => flushBuffer, + sync: () => syncBuffer, + end: () => endBuffer, + copyData, + copyDone: () => copyDoneBuffer, + copyFail, + cancel + }; + exports.serialize = serialize; + } +}); + +// node_modules/.pnpm/pg-protocol@1.15.0/node_modules/pg-protocol/dist/buffer-reader.js +var require_buffer_reader = __commonJS({ + "node_modules/.pnpm/pg-protocol@1.15.0/node_modules/pg-protocol/dist/buffer-reader.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.BufferReader = void 0; + var BufferReader = class { + constructor(offset = 0) { + this.offset = offset; + this.buffer = Buffer.allocUnsafe(0); + this.encoding = "utf-8"; + } + setBuffer(offset, buffer) { + this.offset = offset; + this.buffer = buffer; + } + int16() { + const result = this.buffer.readInt16BE(this.offset); + this.offset += 2; + return result; + } + byte() { + const result = this.buffer[this.offset]; + this.offset++; + return result; + } + int32() { + const result = this.buffer.readInt32BE(this.offset); + this.offset += 4; + return result; + } + uint32() { + const result = this.buffer.readUInt32BE(this.offset); + this.offset += 4; + return result; + } + string(length) { + const result = this.buffer.toString(this.encoding, this.offset, this.offset + length); + this.offset += length; + return result; + } + cstring() { + const start = this.offset; + let end = start; + while (this.buffer[end++]) { + } + this.offset = end; + return this.buffer.toString(this.encoding, start, end - 1); + } + bytes(length) { + const result = this.buffer.slice(this.offset, this.offset + length); + this.offset += length; + return result; + } + }; + exports.BufferReader = BufferReader; + } +}); + +// node_modules/.pnpm/pg-protocol@1.15.0/node_modules/pg-protocol/dist/parser.js +var require_parser = __commonJS({ + "node_modules/.pnpm/pg-protocol@1.15.0/node_modules/pg-protocol/dist/parser.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.Parser = void 0; + var messages_1 = require_messages(); + var buffer_reader_1 = require_buffer_reader(); + var CODE_LENGTH = 1; + var LEN_LENGTH = 4; + var HEADER_LENGTH = CODE_LENGTH + LEN_LENGTH; + var LATEINIT_LENGTH = -1; + var emptyBuffer = Buffer.allocUnsafe(0); + var Parser = class { + constructor(opts) { + this.buffer = emptyBuffer; + this.bufferLength = 0; + this.bufferOffset = 0; + this.reader = new buffer_reader_1.BufferReader(); + if ((opts === null || opts === void 0 ? void 0 : opts.mode) === "binary") { + throw new Error("Binary mode not supported yet"); + } + this.mode = (opts === null || opts === void 0 ? void 0 : opts.mode) || "text"; + } + parse(buffer, callback) { + this.mergeBuffer(buffer); + const bufferFullLength = this.bufferOffset + this.bufferLength; + let offset = this.bufferOffset; + while (offset + HEADER_LENGTH <= bufferFullLength) { + const code = this.buffer[offset]; + const length = this.buffer.readUInt32BE(offset + CODE_LENGTH); + const fullMessageLength = CODE_LENGTH + length; + if (fullMessageLength + offset <= bufferFullLength) { + const message = this.handlePacket(offset + HEADER_LENGTH, code, length, this.buffer); + callback(message); + offset += fullMessageLength; + } else { + break; + } + } + if (offset === bufferFullLength) { + this.buffer = emptyBuffer; + this.bufferLength = 0; + this.bufferOffset = 0; + } else { + this.bufferLength = bufferFullLength - offset; + this.bufferOffset = offset; + } + } + mergeBuffer(buffer) { + if (this.bufferLength > 0) { + const newLength = this.bufferLength + buffer.byteLength; + const newFullLength = newLength + this.bufferOffset; + if (newFullLength > this.buffer.byteLength) { + let newBuffer; + if (newLength <= this.buffer.byteLength && this.bufferOffset >= this.bufferLength) { + newBuffer = this.buffer; + } else { + let newBufferLength = this.buffer.byteLength * 2; + while (newLength >= newBufferLength) { + newBufferLength *= 2; + } + newBuffer = Buffer.allocUnsafe(newBufferLength); + } + this.buffer.copy(newBuffer, 0, this.bufferOffset, this.bufferOffset + this.bufferLength); + this.buffer = newBuffer; + this.bufferOffset = 0; + } + buffer.copy(this.buffer, this.bufferOffset + this.bufferLength); + this.bufferLength = newLength; + } else { + this.buffer = buffer; + this.bufferOffset = 0; + this.bufferLength = buffer.byteLength; + } + } + handlePacket(offset, code, length, bytes) { + const { reader } = this; + reader.setBuffer(offset, bytes); + let message; + switch (code) { + case 50: + message = messages_1.bindComplete; + break; + case 49: + message = messages_1.parseComplete; + break; + case 51: + message = messages_1.closeComplete; + break; + case 110: + message = messages_1.noData; + break; + case 115: + message = messages_1.portalSuspended; + break; + case 99: + message = messages_1.copyDone; + break; + case 87: + message = messages_1.replicationStart; + break; + case 73: + message = messages_1.emptyQuery; + break; + case 68: + message = parseDataRowMessage(reader); + break; + case 67: + message = parseCommandCompleteMessage(reader); + break; + case 90: + message = parseReadyForQueryMessage(reader); + break; + case 65: + message = parseNotificationMessage(reader); + break; + case 82: + message = parseAuthenticationResponse(reader, length); + break; + case 83: + message = parseParameterStatusMessage(reader); + break; + case 75: + message = parseBackendKeyData(reader); + break; + case 69: + message = parseErrorMessage(reader, "error"); + break; + case 78: + message = parseErrorMessage(reader, "notice"); + break; + case 84: + message = parseRowDescriptionMessage(reader); + break; + case 116: + message = parseParameterDescriptionMessage(reader); + break; + case 71: + message = parseCopyInMessage(reader); + break; + case 72: + message = parseCopyOutMessage(reader); + break; + case 100: + message = parseCopyData(reader, length); + break; + default: + return new messages_1.DatabaseError("received invalid response: " + code.toString(16), length, "error"); + } + reader.setBuffer(0, emptyBuffer); + message.length = length; + return message; + } + }; + exports.Parser = Parser; + var parseReadyForQueryMessage = (reader) => { + const status = reader.string(1); + return new messages_1.ReadyForQueryMessage(LATEINIT_LENGTH, status); + }; + var parseCommandCompleteMessage = (reader) => { + const text = reader.cstring(); + return new messages_1.CommandCompleteMessage(LATEINIT_LENGTH, text); + }; + var parseCopyData = (reader, length) => { + const chunk = reader.bytes(length - 4); + return new messages_1.CopyDataMessage(LATEINIT_LENGTH, chunk); + }; + var parseCopyInMessage = (reader) => parseCopyMessage(reader, "copyInResponse"); + var parseCopyOutMessage = (reader) => parseCopyMessage(reader, "copyOutResponse"); + var parseCopyMessage = (reader, messageName) => { + const isBinary = reader.byte() !== 0; + const columnCount = reader.int16(); + const message = new messages_1.CopyResponse(LATEINIT_LENGTH, messageName, isBinary, columnCount); + for (let i = 0; i < columnCount; i++) { + message.columnTypes[i] = reader.int16(); + } + return message; + }; + var parseNotificationMessage = (reader) => { + const processId = reader.int32(); + const channel = reader.cstring(); + const payload = reader.cstring(); + return new messages_1.NotificationResponseMessage(LATEINIT_LENGTH, processId, channel, payload); + }; + var parseRowDescriptionMessage = (reader) => { + const fieldCount = reader.int16(); + const message = new messages_1.RowDescriptionMessage(LATEINIT_LENGTH, fieldCount); + for (let i = 0; i < fieldCount; i++) { + message.fields[i] = parseField(reader); + } + return message; + }; + var parseField = (reader) => { + const name = reader.cstring(); + const tableID = reader.uint32(); + const columnID = reader.int16(); + const dataTypeID = reader.uint32(); + const dataTypeSize = reader.int16(); + const dataTypeModifier = reader.int32(); + const mode = reader.int16() === 0 ? "text" : "binary"; + return new messages_1.Field(name, tableID, columnID, dataTypeID, dataTypeSize, dataTypeModifier, mode); + }; + var parseParameterDescriptionMessage = (reader) => { + const parameterCount = reader.int16(); + const message = new messages_1.ParameterDescriptionMessage(LATEINIT_LENGTH, parameterCount); + for (let i = 0; i < parameterCount; i++) { + message.dataTypeIDs[i] = reader.int32(); + } + return message; + }; + var parseDataRowMessage = (reader) => { + const fieldCount = reader.int16(); + const fields = new Array(fieldCount); + for (let i = 0; i < fieldCount; i++) { + const len = reader.int32(); + fields[i] = len === -1 ? null : reader.string(len); + } + return new messages_1.DataRowMessage(LATEINIT_LENGTH, fields); + }; + var parseParameterStatusMessage = (reader) => { + const name = reader.cstring(); + const value = reader.cstring(); + return new messages_1.ParameterStatusMessage(LATEINIT_LENGTH, name, value); + }; + var parseBackendKeyData = (reader) => { + const processID = reader.int32(); + const secretKey = reader.int32(); + return new messages_1.BackendKeyDataMessage(LATEINIT_LENGTH, processID, secretKey); + }; + var parseAuthenticationResponse = (reader, length) => { + const code = reader.int32(); + const message = { + name: "authenticationOk", + length + }; + switch (code) { + case 0: + break; + case 3: + if (message.length === 8) { + message.name = "authenticationCleartextPassword"; + } + break; + case 5: + if (message.length === 12) { + message.name = "authenticationMD5Password"; + const salt = reader.bytes(4); + return new messages_1.AuthenticationMD5Password(LATEINIT_LENGTH, salt); + } + break; + case 10: + { + message.name = "authenticationSASL"; + message.mechanisms = []; + let mechanism; + do { + mechanism = reader.cstring(); + if (mechanism) { + message.mechanisms.push(mechanism); + } + } while (mechanism); + } + break; + case 11: + message.name = "authenticationSASLContinue"; + message.data = reader.string(length - 8); + break; + case 12: + message.name = "authenticationSASLFinal"; + message.data = reader.string(length - 8); + break; + default: + throw new Error("Unknown authenticationOk message type " + code); + } + return message; + }; + var parseErrorMessage = (reader, name) => { + const fields = {}; + let fieldType = reader.string(1); + while (fieldType !== "\0") { + fields[fieldType] = reader.cstring(); + fieldType = reader.string(1); + } + const messageValue = fields.M; + const message = name === "notice" ? new messages_1.NoticeMessage(LATEINIT_LENGTH, messageValue) : new messages_1.DatabaseError(messageValue, LATEINIT_LENGTH, name); + message.severity = fields.S; + message.code = fields.C; + message.detail = fields.D; + message.hint = fields.H; + message.position = fields.P; + message.internalPosition = fields.p; + message.internalQuery = fields.q; + message.where = fields.W; + message.schema = fields.s; + message.table = fields.t; + message.column = fields.c; + message.dataType = fields.d; + message.constraint = fields.n; + message.file = fields.F; + message.line = fields.L; + message.routine = fields.R; + return message; + }; + } +}); + +// node_modules/.pnpm/pg-protocol@1.15.0/node_modules/pg-protocol/dist/index.js +var require_dist = __commonJS({ + "node_modules/.pnpm/pg-protocol@1.15.0/node_modules/pg-protocol/dist/index.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.DatabaseError = exports.serialize = void 0; + exports.parse = parse; + var messages_1 = require_messages(); + Object.defineProperty(exports, "DatabaseError", { enumerable: true, get: function() { + return messages_1.DatabaseError; + } }); + var serializer_1 = require_serializer(); + Object.defineProperty(exports, "serialize", { enumerable: true, get: function() { + return serializer_1.serialize; + } }); + var parser_1 = require_parser(); + function parse(stream, callback) { + const parser = new parser_1.Parser(); + stream.on("data", (buffer) => parser.parse(buffer, callback)); + return new Promise((resolve) => stream.on("end", () => resolve())); + } + } +}); + +// node_modules/.pnpm/pg-cloudflare@1.4.0/node_modules/pg-cloudflare/dist/empty.js +var require_empty = __commonJS({ + "node_modules/.pnpm/pg-cloudflare@1.4.0/node_modules/pg-cloudflare/dist/empty.js"(exports) { + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + exports.default = {}; + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/stream.js +var require_stream = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/stream.js"(exports, module) { + var { getStream, getSecureStream } = getStreamFuncs(); + module.exports = { + /** + * Get a socket stream compatible with the current runtime environment. + * @returns {Duplex} + */ + getStream, + /** + * Get a TLS secured socket, compatible with the current environment, + * using the socket and other settings given in `options`. + * @returns {Duplex} + */ + getSecureStream + }; + function getNodejsStreamFuncs() { + function getStream2(ssl) { + const net2 = __require("net"); + return new net2.Socket(); + } + function getSecureStream2(options) { + const tls = __require("tls"); + return tls.connect(options); + } + return { + getStream: getStream2, + getSecureStream: getSecureStream2 + }; + } + function getCloudflareStreamFuncs() { + function getStream2(ssl) { + const { CloudflareSocket } = require_empty(); + return new CloudflareSocket(ssl); + } + function getSecureStream2(options) { + options.socket.startTls(options); + return options.socket; + } + return { + getStream: getStream2, + getSecureStream: getSecureStream2 + }; + } + function isCloudflareRuntime() { + if (typeof navigator === "object" && navigator !== null && typeof navigator.userAgent === "string") { + return navigator.userAgent === "Cloudflare-Workers"; + } + if (typeof Response === "function") { + const resp = new Response(null, { cf: { thing: true } }); + if (typeof resp.cf === "object" && resp.cf !== null && resp.cf.thing) { + return true; + } + } + return false; + } + function getStreamFuncs() { + if (isCloudflareRuntime()) { + return getCloudflareStreamFuncs(); + } + return getNodejsStreamFuncs(); + } + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/connection.js +var require_connection = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/connection.js"(exports, module) { + "use strict"; + var EventEmitter = __require("events").EventEmitter; + var { parse, serialize } = require_dist(); + var stream = require_stream(); + var { getStream } = stream; + var flushBuffer = serialize.flush(); + var syncBuffer = serialize.sync(); + var endBuffer = serialize.end(); + var Connection2 = class extends EventEmitter { + constructor(config) { + super(); + config = config || {}; + this.stream = config.stream || getStream(config.ssl); + if (typeof this.stream === "function") { + this.stream = this.stream(config); + } + this._keepAlive = config.keepAlive; + this._keepAliveInitialDelayMillis = config.keepAliveInitialDelayMillis; + this.parsedStatements = {}; + this.ssl = config.ssl || false; + this.sslNegotiation = config.sslNegotiation || "postgres"; + this._ending = false; + this._emitMessage = false; + const self = this; + this.on("newListener", function(eventName) { + if (eventName === "message") { + self._emitMessage = true; + } + }); + } + connect(port, host) { + const self = this; + this._connecting = true; + this.stream.setNoDelay(true); + this.stream.connect(port, host); + this.stream.once("connect", function() { + if (self._keepAlive) { + self.stream.setKeepAlive(true, self._keepAliveInitialDelayMillis); + } + self.emit("connect"); + }); + const reportStreamError = function(error) { + if (self._ending && (error.code === "ECONNRESET" || error.code === "EPIPE")) { + return; + } + self.emit("error", error); + }; + this.stream.on("error", reportStreamError); + this.stream.on("close", function() { + self.emit("end"); + }); + if (!this.ssl) { + return this.attachListeners(this.stream); + } + if (this.sslNegotiation === "direct") { + return this.stream.once("connect", function() { + self.upgradeToSSL(host, reportStreamError); + }); + } + this.stream.once("data", function(buffer) { + const responseCode = buffer.toString("utf8"); + switch (responseCode) { + case "S": + break; + case "N": + self.stream.end(); + return self.emit("error", new Error("The server does not support SSL connections")); + default: + self.stream.end(); + return self.emit("error", new Error("There was an error establishing an SSL connection")); + } + self.upgradeToSSL(host, reportStreamError); + }); + } + upgradeToSSL(host, reportStreamError) { + const self = this; + const options = { + socket: self.stream + }; + if (self.ssl !== true) { + Object.assign(options, self.ssl); + if ("key" in self.ssl) { + options.key = self.ssl.key; + } + } + if (self.sslNegotiation === "direct") { + options.ALPNProtocols = ["postgresql"]; + } + const net2 = __require("net"); + if (net2.isIP && net2.isIP(host) === 0) { + options.servername = host; + } + try { + self.stream = stream.getSecureStream(options); + } catch (err) { + return self.emit("error", err); + } + self.attachListeners(self.stream); + self.stream.on("error", reportStreamError); + self.emit("sslconnect"); + } + attachListeners(stream2) { + parse(stream2, (msg) => { + const eventName = msg.name === "error" ? "errorMessage" : msg.name; + if (this._emitMessage) { + this.emit("message", msg); + } + this.emit(eventName, msg); + }); + } + requestSsl() { + this.stream.write(serialize.requestSsl()); + } + startup(config) { + this.stream.write(serialize.startup(config)); + } + cancel(processID, secretKey) { + this._send(serialize.cancel(processID, secretKey)); + } + password(password) { + this._send(serialize.password(password)); + } + sendSASLInitialResponseMessage(mechanism, initialResponse) { + this._send(serialize.sendSASLInitialResponseMessage(mechanism, initialResponse)); + } + sendSCRAMClientFinalMessage(additionalData) { + this._send(serialize.sendSCRAMClientFinalMessage(additionalData)); + } + _send(buffer) { + if (!this.stream.writable) { + return false; + } + return this.stream.write(buffer); + } + query(text) { + this._send(serialize.query(text)); + } + // send parse message + parse(query) { + this._send(serialize.parse(query)); + } + // send bind message + bind(config) { + this._send(serialize.bind(config)); + } + // send execute message + execute(config) { + this._send(serialize.execute(config)); + } + flush() { + if (this.stream.writable) { + this.stream.write(flushBuffer); + } + } + sync() { + this._ending = true; + this._send(syncBuffer); + } + ref() { + this.stream.ref(); + } + unref() { + this.stream.unref(); + } + end() { + this._ending = true; + if (!this._connecting || !this.stream.writable) { + this.stream.end(); + return; + } + return this.stream.write(endBuffer, () => { + this.stream.end(); + }); + } + close(msg) { + this._send(serialize.close(msg)); + } + describe(msg) { + this._send(serialize.describe(msg)); + } + sendCopyFromChunk(chunk) { + this._send(serialize.copyData(chunk)); + } + endCopyFrom() { + this._send(serialize.copyDone()); + } + sendCopyFail(msg) { + this._send(serialize.copyFail(msg)); + } + }; + module.exports = Connection2; + } +}); + +// node_modules/.pnpm/split2@4.2.0/node_modules/split2/index.js +var require_split2 = __commonJS({ + "node_modules/.pnpm/split2@4.2.0/node_modules/split2/index.js"(exports, module) { + "use strict"; + var { Transform: Transform3 } = __require("stream"); + var { StringDecoder } = __require("string_decoder"); + var kLast = Symbol("last"); + var kDecoder = Symbol("decoder"); + function transform(chunk, enc, cb) { + let list; + if (this.overflow) { + const buf = this[kDecoder].write(chunk); + list = buf.split(this.matcher); + if (list.length === 1) return cb(); + list.shift(); + this.overflow = false; + } else { + this[kLast] += this[kDecoder].write(chunk); + list = this[kLast].split(this.matcher); + } + this[kLast] = list.pop(); + for (let i = 0; i < list.length; i++) { + try { + push(this, this.mapper(list[i])); + } catch (error) { + return cb(error); + } + } + this.overflow = this[kLast].length > this.maxLength; + if (this.overflow && !this.skipOverflow) { + cb(new Error("maximum buffer reached")); + return; + } + cb(); + } + function flush(cb) { + this[kLast] += this[kDecoder].end(); + if (this[kLast]) { + try { + push(this, this.mapper(this[kLast])); + } catch (error) { + return cb(error); + } + } + cb(); + } + function push(self, val) { + if (val !== void 0) { + self.push(val); + } + } + function noop(incoming) { + return incoming; + } + function split(matcher, mapper, options) { + matcher = matcher || /\r?\n/; + mapper = mapper || noop; + options = options || {}; + switch (arguments.length) { + case 1: + if (typeof matcher === "function") { + mapper = matcher; + matcher = /\r?\n/; + } else if (typeof matcher === "object" && !(matcher instanceof RegExp) && !matcher[Symbol.split]) { + options = matcher; + matcher = /\r?\n/; + } + break; + case 2: + if (typeof matcher === "function") { + options = mapper; + mapper = matcher; + matcher = /\r?\n/; + } else if (typeof mapper === "object") { + options = mapper; + mapper = noop; + } + } + options = Object.assign({}, options); + options.autoDestroy = true; + options.transform = transform; + options.flush = flush; + options.readableObjectMode = true; + const stream = new Transform3(options); + stream[kLast] = ""; + stream[kDecoder] = new StringDecoder("utf8"); + stream.matcher = matcher; + stream.mapper = mapper; + stream.maxLength = options.maxLength; + stream.skipOverflow = options.skipOverflow || false; + stream.overflow = false; + stream._destroy = function(err, cb) { + this._writableState.errorEmitted = false; + cb(err); + }; + return stream; + } + module.exports = split; + } +}); + +// node_modules/.pnpm/pgpass@1.0.5/node_modules/pgpass/lib/helper.js +var require_helper = __commonJS({ + "node_modules/.pnpm/pgpass@1.0.5/node_modules/pgpass/lib/helper.js"(exports, module) { + "use strict"; + var path29 = __require("path"); + var Stream = __require("stream").Stream; + var split = require_split2(); + var util = __require("util"); + var defaultPort = 5432; + var isWin = process.platform === "win32"; + var warnStream = process.stderr; + var S_IRWXG = 56; + var S_IRWXO = 7; + var S_IFMT = 61440; + var S_IFREG = 32768; + function isRegFile(mode) { + return (mode & S_IFMT) == S_IFREG; + } + var fieldNames = ["host", "port", "database", "user", "password"]; + var nrOfFields = fieldNames.length; + var passKey = fieldNames[nrOfFields - 1]; + function warn() { + var isWritable = warnStream instanceof Stream && true === warnStream.writable; + if (isWritable) { + var args = Array.prototype.slice.call(arguments).concat("\n"); + warnStream.write(util.format.apply(util, args)); + } + } + Object.defineProperty(module.exports, "isWin", { + get: function() { + return isWin; + }, + set: function(val) { + isWin = val; + } + }); + module.exports.warnTo = function(stream) { + var old = warnStream; + warnStream = stream; + return old; + }; + module.exports.getFileName = function(rawEnv) { + var env = rawEnv || process.env; + var file = env.PGPASSFILE || (isWin ? path29.join(env.APPDATA || "./", "postgresql", "pgpass.conf") : path29.join(env.HOME || "./", ".pgpass")); + return file; + }; + module.exports.usePgPass = function(stats, fname) { + if (Object.prototype.hasOwnProperty.call(process.env, "PGPASSWORD")) { + return false; + } + if (isWin) { + return true; + } + fname = fname || ""; + if (!isRegFile(stats.mode)) { + warn('WARNING: password file "%s" is not a plain file', fname); + return false; + } + if (stats.mode & (S_IRWXG | S_IRWXO)) { + warn('WARNING: password file "%s" has group or world access; permissions should be u=rw (0600) or less', fname); + return false; + } + return true; + }; + var matcher = module.exports.match = function(connInfo, entry) { + return fieldNames.slice(0, -1).reduce(function(prev, field, idx) { + if (idx == 1) { + if (Number(connInfo[field] || defaultPort) === Number(entry[field])) { + return prev && true; + } + } + return prev && (entry[field] === "*" || entry[field] === connInfo[field]); + }, true); + }; + module.exports.getPassword = function(connInfo, stream, cb) { + var pass; + var lineStream = stream.pipe(split()); + function onLine(line) { + var entry = parseLine(line); + if (entry && isValidEntry(entry) && matcher(connInfo, entry)) { + pass = entry[passKey]; + lineStream.end(); + } + } + var onEnd = function() { + stream.destroy(); + cb(pass); + }; + var onErr = function(err) { + stream.destroy(); + warn("WARNING: error on reading file: %s", err); + cb(void 0); + }; + stream.on("error", onErr); + lineStream.on("data", onLine).on("end", onEnd).on("error", onErr); + }; + var parseLine = module.exports.parseLine = function(line) { + if (line.length < 11 || line.match(/^\s+#/)) { + return null; + } + var curChar = ""; + var prevChar = ""; + var fieldIdx = 0; + var startIdx = 0; + var endIdx = 0; + var obj = {}; + var isLastField = false; + var addToObj = function(idx, i0, i1) { + var field = line.substring(i0, i1); + if (!Object.hasOwnProperty.call(process.env, "PGPASS_NO_DEESCAPE")) { + field = field.replace(/\\([:\\])/g, "$1"); + } + obj[fieldNames[idx]] = field; + }; + for (var i = 0; i < line.length - 1; i += 1) { + curChar = line.charAt(i + 1); + prevChar = line.charAt(i); + isLastField = fieldIdx == nrOfFields - 1; + if (isLastField) { + addToObj(fieldIdx, startIdx); + break; + } + if (i >= 0 && curChar == ":" && prevChar !== "\\") { + addToObj(fieldIdx, startIdx, i + 1); + startIdx = i + 2; + fieldIdx += 1; + } + } + obj = Object.keys(obj).length === nrOfFields ? obj : null; + return obj; + }; + var isValidEntry = module.exports.isValidEntry = function(entry) { + var rules = { + // host + 0: function(x) { + return x.length > 0; + }, + // port + 1: function(x) { + if (x === "*") { + return true; + } + x = Number(x); + return isFinite(x) && x > 0 && x < 9007199254740992 && Math.floor(x) === x; + }, + // database + 2: function(x) { + return x.length > 0; + }, + // username + 3: function(x) { + return x.length > 0; + }, + // password + 4: function(x) { + return x.length > 0; + } + }; + for (var idx = 0; idx < fieldNames.length; idx += 1) { + var rule = rules[idx]; + var value = entry[fieldNames[idx]] || ""; + var res = rule(value); + if (!res) { + return false; + } + } + return true; + }; + } +}); + +// node_modules/.pnpm/pgpass@1.0.5/node_modules/pgpass/lib/index.js +var require_lib = __commonJS({ + "node_modules/.pnpm/pgpass@1.0.5/node_modules/pgpass/lib/index.js"(exports, module) { + "use strict"; + var path29 = __require("path"); + var fs27 = __require("fs"); + var helper = require_helper(); + module.exports = function(connInfo, cb) { + var file = helper.getFileName(); + fs27.stat(file, function(err, stat) { + if (err || !helper.usePgPass(stat, file)) { + return cb(void 0); + } + var st = fs27.createReadStream(file); + helper.getPassword(connInfo, st, cb); + }); + }; + module.exports.warnTo = helper.warnTo; + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/client.js +var require_client = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/client.js"(exports, module) { + var EventEmitter = __require("events").EventEmitter; + var utils = require_utils(); + var nodeUtils = __require("util"); + var sasl = require_sasl(); + var TypeOverrides2 = require_type_overrides(); + var ConnectionParameters = require_connection_parameters(); + var Query2 = require_query(); + var defaults2 = require_defaults(); + var Connection2 = require_connection(); + var crypto35 = require_utils2(); + var activeQueryDeprecationNotice = nodeUtils.deprecate( + () => { + }, + "Client.activeQuery is deprecated and will be removed in pg@9.0" + ); + var queryQueueDeprecationNotice = nodeUtils.deprecate( + () => { + }, + "Client.queryQueue is deprecated and will be removed in pg@9.0." + ); + var pgPassDeprecationNotice = nodeUtils.deprecate( + () => { + }, + "pgpass support is deprecated and will be removed in pg@9.0. You can provide an async function as the password property to the Client/Pool constructor that returns a password instead. Within this function you can call the pgpass module in your own code." + ); + var byoPromiseDeprecationNotice = nodeUtils.deprecate( + () => { + }, + "Passing a custom Promise implementation to the Client/Pool constructor is deprecated and will be removed in pg@9.0." + ); + var queryQueueLengthDeprecationNotice = nodeUtils.deprecate( + () => { + }, + "Calling client.query() when the client is already executing a query is deprecated and will be removed in pg@9.0. Use async/await or an external async flow control mechanism instead." + ); + function coerceNumberOrDefault(value, defaultValue) { + if (typeof value === "number") { + return Number.isFinite(value) ? value : defaultValue; + } + if (typeof value === "string" && value.trim() !== "") { + const n = Number(value); + return Number.isFinite(n) ? n : defaultValue; + } + return defaultValue; + } + var Client2 = class extends EventEmitter { + constructor(config) { + super(); + this.connectionParameters = new ConnectionParameters(config); + this.user = this.connectionParameters.user; + this.database = this.connectionParameters.database; + this.port = this.connectionParameters.port; + this.host = this.connectionParameters.host; + Object.defineProperty(this, "password", { + configurable: true, + enumerable: false, + writable: true, + value: this.connectionParameters.password + }); + this.replication = this.connectionParameters.replication; + const c = config || {}; + if (c.Promise) { + byoPromiseDeprecationNotice(); + } + this._Promise = c.Promise || global.Promise; + this._types = new TypeOverrides2(c.types); + this._ending = false; + this._ended = false; + this._connecting = false; + this._connected = false; + this._connectionError = false; + this._queryable = true; + this._activeQuery = null; + this._txStatus = null; + this.enableChannelBinding = Boolean(c.enableChannelBinding); + this.scramMaxIterations = coerceNumberOrDefault(c.scramMaxIterations, sasl.DEFAULT_MAX_SCRAM_ITERATIONS); + this.connection = c.connection || new Connection2({ + stream: c.stream, + ssl: this.connectionParameters.ssl, + sslNegotiation: this.connectionParameters.sslnegotiation, + keepAlive: c.keepAlive || false, + keepAliveInitialDelayMillis: c.keepAliveInitialDelayMillis || 0, + encoding: this.connectionParameters.client_encoding || "utf8" + }); + this._queryQueue = []; + this.binary = c.binary || defaults2.binary; + this.processID = null; + this.secretKey = null; + this.ssl = this.connectionParameters.ssl || false; + this.sslNegotiation = this.connectionParameters.sslnegotiation || "postgres"; + if (this.ssl && this.ssl.key) { + Object.defineProperty(this.ssl, "key", { + enumerable: false + }); + } + this._connectionTimeoutMillis = c.connectionTimeoutMillis || 0; + } + get activeQuery() { + activeQueryDeprecationNotice(); + return this._activeQuery; + } + set activeQuery(val) { + activeQueryDeprecationNotice(); + this._activeQuery = val; + } + _getActiveQuery() { + return this._activeQuery; + } + _errorAllQueries(err) { + const enqueueError = (query) => { + process.nextTick(() => { + query.handleError(err, this.connection); + }); + }; + const activeQuery = this._getActiveQuery(); + if (activeQuery) { + enqueueError(activeQuery); + this._activeQuery = null; + } + this._queryQueue.forEach(enqueueError); + this._queryQueue.length = 0; + } + _connect(callback) { + const self = this; + const con = this.connection; + this._connectionCallback = callback; + if (this._connecting || this._connected) { + const err = new Error("Client has already been connected. You cannot reuse a client."); + process.nextTick(() => { + callback(err); + }); + return; + } + this._connecting = true; + if (this._connectionTimeoutMillis > 0) { + this.connectionTimeoutHandle = setTimeout(() => { + con._ending = true; + con.stream.destroy(new Error("timeout expired")); + }, this._connectionTimeoutMillis); + if (this.connectionTimeoutHandle.unref) { + this.connectionTimeoutHandle.unref(); + } + } + if (this.host && this.host.indexOf("/") === 0) { + con.connect(this.host + "/.s.PGSQL." + this.port); + } else { + con.connect(this.port, this.host); + } + con.on("connect", function() { + if (self.ssl) { + if (self.sslNegotiation !== "direct") { + con.requestSsl(); + } + } else { + con.startup(self.getStartupConf()); + } + }); + con.on("sslconnect", function() { + con.startup(self.getStartupConf()); + }); + this._attachListeners(con); + con.once("end", () => { + const error = this._ending ? new Error("Connection terminated") : new Error("Connection terminated unexpectedly"); + clearTimeout(this.connectionTimeoutHandle); + this._errorAllQueries(error); + this._ended = true; + if (!this._ending) { + if (this._connecting && !this._connectionError) { + if (this._connectionCallback) { + this._connectionCallback(error); + } else { + this._handleErrorEvent(error); + } + } else if (!this._connectionError) { + this._handleErrorEvent(error); + } + } + process.nextTick(() => { + this.emit("end"); + }); + }); + } + connect(callback) { + if (callback) { + this._connect(callback); + return; + } + return new this._Promise((resolve, reject) => { + this._connect((error) => { + if (error) { + reject(error); + } else { + resolve(this); + } + }); + }); + } + _attachListeners(con) { + con.on("authenticationCleartextPassword", this._handleAuthCleartextPassword.bind(this)); + con.on("authenticationMD5Password", this._handleAuthMD5Password.bind(this)); + con.on("authenticationSASL", this._handleAuthSASL.bind(this)); + con.on("authenticationSASLContinue", this._handleAuthSASLContinue.bind(this)); + con.on("authenticationSASLFinal", this._handleAuthSASLFinal.bind(this)); + con.on("backendKeyData", this._handleBackendKeyData.bind(this)); + con.on("error", this._handleErrorEvent.bind(this)); + con.on("errorMessage", this._handleErrorMessage.bind(this)); + con.on("readyForQuery", this._handleReadyForQuery.bind(this)); + con.on("notice", this._handleNotice.bind(this)); + con.on("rowDescription", this._handleRowDescription.bind(this)); + con.on("dataRow", this._handleDataRow.bind(this)); + con.on("portalSuspended", this._handlePortalSuspended.bind(this)); + con.on("emptyQuery", this._handleEmptyQuery.bind(this)); + con.on("commandComplete", this._handleCommandComplete.bind(this)); + con.on("parseComplete", this._handleParseComplete.bind(this)); + con.on("copyInResponse", this._handleCopyInResponse.bind(this)); + con.on("copyData", this._handleCopyData.bind(this)); + con.on("notification", this._handleNotification.bind(this)); + } + _getPassword(cb) { + const con = this.connection; + if (typeof this.password === "function") { + this._Promise.resolve().then(() => this.password(this.connectionParameters)).then((pass) => { + if (pass !== void 0) { + if (typeof pass !== "string") { + con.emit("error", new TypeError("Password must be a string")); + return; + } + this.connectionParameters.password = this.password = pass; + } else { + this.connectionParameters.password = this.password = null; + } + cb(); + }).catch((err) => { + con.emit("error", err); + }); + } else if (this.password !== null) { + cb(); + } else { + try { + const pgPass = require_lib(); + pgPass(this.connectionParameters, (pass) => { + if (void 0 !== pass) { + pgPassDeprecationNotice(); + this.connectionParameters.password = this.password = pass; + } + cb(); + }); + } catch (e) { + this.emit("error", e); + } + } + } + _handleAuthCleartextPassword(msg) { + this._getPassword(() => { + this.connection.password(this.password); + }); + } + _handleAuthMD5Password(msg) { + this._getPassword(async () => { + try { + const hashedPassword = await crypto35.postgresMd5PasswordHash(this.user, this.password, msg.salt); + this.connection.password(hashedPassword); + } catch (e) { + this.emit("error", e); + } + }); + } + _handleAuthSASL(msg) { + this._getPassword(() => { + try { + this.saslSession = sasl.startSession( + msg.mechanisms, + this.enableChannelBinding && this.connection.stream, + this.scramMaxIterations + ); + this.connection.sendSASLInitialResponseMessage(this.saslSession.mechanism, this.saslSession.response); + } catch (err) { + this.connection.emit("error", err); + } + }); + } + async _handleAuthSASLContinue(msg) { + try { + await sasl.continueSession( + this.saslSession, + this.password, + msg.data, + this.enableChannelBinding && this.connection.stream + ); + this.connection.sendSCRAMClientFinalMessage(this.saslSession.response); + } catch (err) { + this.connection.emit("error", err); + } + } + _handleAuthSASLFinal(msg) { + try { + sasl.finalizeSession(this.saslSession, msg.data); + this.saslSession = null; + } catch (err) { + this.connection.emit("error", err); + } + } + _handleBackendKeyData(msg) { + this.processID = msg.processID; + this.secretKey = msg.secretKey; + } + _handleReadyForQuery(msg) { + if (this._connecting) { + this._connecting = false; + this._connected = true; + clearTimeout(this.connectionTimeoutHandle); + if (this._connectionCallback) { + this._connectionCallback(null, this); + this._connectionCallback = null; + } + this.emit("connect"); + } + const activeQuery = this._getActiveQuery(); + this._activeQuery = null; + this._txStatus = msg?.status ?? null; + this.readyForQuery = true; + if (activeQuery) { + activeQuery.handleReadyForQuery(this.connection); + } + this._pulseQueryQueue(); + } + // if we receive an error event or error message + // during the connection process we handle it here + _handleErrorWhileConnecting(err) { + if (this._connectionError) { + return; + } + this._connectionError = true; + clearTimeout(this.connectionTimeoutHandle); + if (this._connectionCallback) { + return this._connectionCallback(err); + } + this.emit("error", err); + } + // if we're connected and we receive an error event from the connection + // this means the socket is dead - do a hard abort of all queries and emit + // the socket error on the client as well + _handleErrorEvent(err) { + if (this._connecting) { + return this._handleErrorWhileConnecting(err); + } + this._queryable = false; + this._errorAllQueries(err); + this.emit("error", err); + } + // handle error messages from the postgres backend + _handleErrorMessage(msg) { + if (this._connecting) { + return this._handleErrorWhileConnecting(msg); + } + const activeQuery = this._getActiveQuery(); + if (!activeQuery) { + this._handleErrorEvent(msg); + return; + } + this._activeQuery = null; + activeQuery.handleError(msg, this.connection); + } + _handleRowDescription(msg) { + const activeQuery = this._getActiveQuery(); + if (activeQuery == null) { + const error = new Error("Received unexpected rowDescription message from backend."); + this._handleErrorEvent(error); + return; + } + activeQuery.handleRowDescription(msg); + } + _handleDataRow(msg) { + const activeQuery = this._getActiveQuery(); + if (activeQuery == null) { + const error = new Error("Received unexpected dataRow message from backend."); + this._handleErrorEvent(error); + return; + } + activeQuery.handleDataRow(msg); + } + _handlePortalSuspended(msg) { + const activeQuery = this._getActiveQuery(); + if (activeQuery == null) { + const error = new Error("Received unexpected portalSuspended message from backend."); + this._handleErrorEvent(error); + return; + } + activeQuery.handlePortalSuspended(this.connection); + } + _handleEmptyQuery(msg) { + const activeQuery = this._getActiveQuery(); + if (activeQuery == null) { + const error = new Error("Received unexpected emptyQuery message from backend."); + this._handleErrorEvent(error); + return; + } + activeQuery.handleEmptyQuery(this.connection); + } + _handleCommandComplete(msg) { + const activeQuery = this._getActiveQuery(); + if (activeQuery == null) { + const error = new Error("Received unexpected commandComplete message from backend."); + this._handleErrorEvent(error); + return; + } + activeQuery.handleCommandComplete(msg, this.connection); + } + _handleParseComplete() { + const activeQuery = this._getActiveQuery(); + if (activeQuery == null) { + const error = new Error("Received unexpected parseComplete message from backend."); + this._handleErrorEvent(error); + return; + } + if (activeQuery.name) { + this.connection.parsedStatements[activeQuery.name] = activeQuery.text; + } + } + _handleCopyInResponse(msg) { + const activeQuery = this._getActiveQuery(); + if (activeQuery == null) { + const error = new Error("Received unexpected copyInResponse message from backend."); + this._handleErrorEvent(error); + return; + } + activeQuery.handleCopyInResponse(this.connection); + } + _handleCopyData(msg) { + const activeQuery = this._getActiveQuery(); + if (activeQuery == null) { + const error = new Error("Received unexpected copyData message from backend."); + this._handleErrorEvent(error); + return; + } + activeQuery.handleCopyData(msg, this.connection); + } + _handleNotification(msg) { + this.emit("notification", msg); + } + _handleNotice(msg) { + this.emit("notice", msg); + } + getStartupConf() { + const params = this.connectionParameters; + const data = { + user: params.user, + database: params.database + }; + const appName = params.application_name || params.fallback_application_name; + if (appName) { + data.application_name = appName; + } + if (params.replication) { + data.replication = "" + params.replication; + } + if (params.statement_timeout) { + data.statement_timeout = String(parseInt(params.statement_timeout, 10)); + } + if (params.lock_timeout) { + data.lock_timeout = String(parseInt(params.lock_timeout, 10)); + } + if (params.idle_in_transaction_session_timeout) { + data.idle_in_transaction_session_timeout = String(parseInt(params.idle_in_transaction_session_timeout, 10)); + } + if (params.options) { + data.options = params.options; + } + return data; + } + cancel(client, query) { + if (client.activeQuery === query) { + const con = this.connection; + if (this.host && this.host.indexOf("/") === 0) { + con.connect(this.host + "/.s.PGSQL." + this.port); + } else { + con.connect(this.port, this.host); + } + con.on("connect", function() { + con.cancel(client.processID, client.secretKey); + }); + } else if (client._queryQueue.indexOf(query) !== -1) { + client._queryQueue.splice(client._queryQueue.indexOf(query), 1); + } + } + setTypeParser(oid, format, parseFn) { + return this._types.setTypeParser(oid, format, parseFn); + } + getTypeParser(oid, format) { + return this._types.getTypeParser(oid, format); + } + // escapeIdentifier and escapeLiteral moved to utility functions & exported + // on PG + // re-exported here for backwards compatibility + escapeIdentifier(str) { + return utils.escapeIdentifier(str); + } + escapeLiteral(str) { + return utils.escapeLiteral(str); + } + _pulseQueryQueue() { + if (this.readyForQuery === true) { + this._activeQuery = this._queryQueue.shift(); + const activeQuery = this._getActiveQuery(); + if (activeQuery) { + this.readyForQuery = false; + this.hasExecuted = true; + const queryError = activeQuery.submit(this.connection); + if (queryError) { + process.nextTick(() => { + activeQuery.handleError(queryError, this.connection); + this.readyForQuery = true; + this._pulseQueryQueue(); + }); + } + } else if (this.hasExecuted) { + this._activeQuery = null; + this.emit("drain"); + } + } + } + query(config, values, callback) { + let query; + let result; + if (config == null) { + throw new TypeError("Client was passed a null or undefined query"); + } + if (typeof config.submit === "function") { + result = query = config; + if (!query.callback) { + if (typeof values === "function") { + query.callback = values; + } else if (callback) { + query.callback = callback; + } + } + } else { + query = new Query2(config, values, callback); + if (!query.callback) { + result = new this._Promise((resolve, reject) => { + query.callback = (err, res) => err ? reject(err) : resolve(res); + }).catch((err) => { + Error.captureStackTrace(err); + throw err; + }); + } else if (typeof query.callback !== "function") { + throw new TypeError("callback is not a function"); + } + } + const readTimeout = config.query_timeout || this.connectionParameters.query_timeout; + if (readTimeout) { + const queryCallback = query.callback || (() => { + }); + const readTimeoutTimer = setTimeout(() => { + const error = new Error("Query read timeout"); + process.nextTick(() => { + query.handleError(error, this.connection); + }); + queryCallback(error); + query.callback = () => { + }; + const index = this._queryQueue.indexOf(query); + if (index > -1) { + this._queryQueue.splice(index, 1); + } + this._pulseQueryQueue(); + }, readTimeout); + query.callback = (err, res) => { + clearTimeout(readTimeoutTimer); + queryCallback(err, res); + }; + } + if (this.binary && !query.binary) { + query.binary = true; + } + if (query._result && !query._result._types) { + query._result._types = this._types; + } + if (!this._queryable) { + process.nextTick(() => { + query.handleError(new Error("Client has encountered a connection error and is not queryable"), this.connection); + }); + return result; + } + if (this._ending) { + process.nextTick(() => { + query.handleError(new Error("Client was closed and is not queryable"), this.connection); + }); + return result; + } + if (this._queryQueue.length > 0) { + queryQueueLengthDeprecationNotice(); + } + this._queryQueue.push(query); + this._pulseQueryQueue(); + return result; + } + ref() { + this.connection.ref(); + } + unref() { + this.connection.unref(); + } + getTransactionStatus() { + return this._txStatus; + } + end(cb) { + this._ending = true; + if (!this.connection._connecting || this._ended) { + if (cb) { + cb(); + return; + } else { + return this._Promise.resolve(); + } + } + if (this._getActiveQuery() || !this._queryable) { + this.connection.stream.destroy(); + } else { + this.connection.end(); + } + if (cb) { + this.connection.once("end", cb); + } else { + return new this._Promise((resolve) => { + this.connection.once("end", resolve); + }); + } + } + get queryQueue() { + queryQueueDeprecationNotice(); + return this._queryQueue; + } + }; + Client2.Query = Query2; + module.exports = Client2; + } +}); + +// node_modules/.pnpm/pg-pool@3.14.0_pg@8.22.0/node_modules/pg-pool/index.js +var require_pg_pool = __commonJS({ + "node_modules/.pnpm/pg-pool@3.14.0_pg@8.22.0/node_modules/pg-pool/index.js"(exports, module) { + "use strict"; + var EventEmitter = __require("events").EventEmitter; + var NOOP = function() { + }; + var removeWhere = (list, predicate) => { + const i = list.findIndex(predicate); + return i === -1 ? void 0 : list.splice(i, 1)[0]; + }; + var IdleItem = class { + constructor(client, idleListener, timeoutId) { + this.client = client; + this.idleListener = idleListener; + this.timeoutId = timeoutId; + } + }; + var PendingItem = class { + constructor(callback) { + this.callback = callback; + } + }; + function throwOnDoubleRelease() { + throw new Error("Release called on client which has already been released to the pool."); + } + function promisify(Promise2, callback) { + if (callback) { + return { callback, result: void 0 }; + } + let rej; + let res; + const cb = function(err, client) { + err ? rej(err) : res(client); + }; + const result = new Promise2(function(resolve, reject) { + res = resolve; + rej = reject; + }).catch((err) => { + Error.captureStackTrace(err); + throw err; + }); + return { callback: cb, result }; + } + function makeIdleListener(pool2, client) { + return function idleListener(err) { + err.client = client; + client.removeListener("error", idleListener); + client.on("error", () => { + pool2.log("additional client error after disconnection due to error", err); + }); + pool2._remove(client); + pool2.emit("error", err, client); + }; + } + var Pool2 = class extends EventEmitter { + constructor(options, Client2) { + super(); + this.options = Object.assign({}, options); + if (options != null && "password" in options) { + Object.defineProperty(this.options, "password", { + configurable: true, + enumerable: false, + writable: true, + value: options.password + }); + } + if (options != null && options.ssl && options.ssl.key) { + Object.defineProperty(this.options.ssl, "key", { + enumerable: false + }); + } + this.options.max = this.options.max || this.options.poolSize || 10; + this.options.min = this.options.min || 0; + this.options.maxUses = this.options.maxUses || Infinity; + this.options.allowExitOnIdle = this.options.allowExitOnIdle || false; + this.options.maxLifetimeSeconds = this.options.maxLifetimeSeconds || 0; + this.log = this.options.log || function() { + }; + this.Client = this.options.Client || Client2 || require_lib2().Client; + this.Promise = this.options.Promise || global.Promise; + if (typeof this.options.idleTimeoutMillis === "undefined") { + this.options.idleTimeoutMillis = 1e4; + } + this._clients = []; + this._idle = []; + this._expired = /* @__PURE__ */ new WeakSet(); + this._pendingQueue = []; + this._endCallback = void 0; + this.ending = false; + this.ended = false; + } + _promiseTry(f) { + const Promise2 = this.Promise; + if (typeof Promise2.try === "function") { + return Promise2.try(f); + } + return new Promise2((resolve) => resolve(f())); + } + _isFull() { + return this._clients.length >= this.options.max; + } + _isAboveMin() { + return this._clients.length > this.options.min; + } + _pulseQueue() { + this.log("pulse queue"); + if (this.ended) { + this.log("pulse queue ended"); + return; + } + if (this.ending) { + this.log("pulse queue on ending"); + if (this._idle.length) { + this._idle.slice().map((item) => { + this._remove(item.client); + }); + } + if (!this._clients.length) { + this.ended = true; + this._endCallback(); + } + return; + } + if (!this._pendingQueue.length) { + this.log("no queued requests"); + return; + } + if (!this._idle.length && this._isFull()) { + return; + } + const pendingItem = this._pendingQueue.shift(); + if (this._idle.length) { + const idleItem = this._idle.pop(); + clearTimeout(idleItem.timeoutId); + const client = idleItem.client; + client.ref && client.ref(); + const idleListener = idleItem.idleListener; + return this._acquireClient(client, pendingItem, idleListener, false); + } + if (!this._isFull()) { + return this.newClient(pendingItem); + } + throw new Error("unexpected condition"); + } + _remove(client, callback) { + const removed = removeWhere(this._idle, (item) => item.client === client); + if (removed !== void 0) { + clearTimeout(removed.timeoutId); + } + this._clients = this._clients.filter((c) => c !== client); + const context = this; + client.end(() => { + context.emit("remove", client); + if (typeof callback === "function") { + callback(); + } + }); + } + connect(cb) { + if (this.ending) { + const err = new Error("Cannot use a pool after calling end on the pool"); + return cb ? cb(err) : this.Promise.reject(err); + } + const response = promisify(this.Promise, cb); + const result = response.result; + if (this._isFull() || this._idle.length) { + if (this._idle.length) { + process.nextTick(() => this._pulseQueue()); + } + if (!this.options.connectionTimeoutMillis) { + this._pendingQueue.push(new PendingItem(response.callback)); + return result; + } + const queueCallback = (err, res, done) => { + clearTimeout(tid); + response.callback(err, res, done); + }; + const pendingItem = new PendingItem(queueCallback); + const tid = setTimeout(() => { + removeWhere(this._pendingQueue, (i) => i.callback === queueCallback); + pendingItem.timedOut = true; + response.callback(new Error("timeout exceeded when trying to connect")); + }, this.options.connectionTimeoutMillis); + if (tid.unref) { + tid.unref(); + } + this._pendingQueue.push(pendingItem); + return result; + } + this.newClient(new PendingItem(response.callback)); + return result; + } + newClient(pendingItem) { + const client = new this.Client(this.options); + this._clients.push(client); + const idleListener = makeIdleListener(this, client); + this.log("checking client timeout"); + let tid; + let timeoutHit = false; + if (this.options.connectionTimeoutMillis) { + tid = setTimeout(() => { + if (client.connection) { + this.log("ending client due to timeout"); + timeoutHit = true; + client.connection.stream.destroy(); + } else if (!client.isConnected()) { + this.log("ending client due to timeout"); + timeoutHit = true; + client.end(); + } + }, this.options.connectionTimeoutMillis); + } + this.log("connecting new client"); + client.connect((err) => { + if (tid) { + clearTimeout(tid); + } + client.on("error", idleListener); + if (err) { + this.log("client failed to connect", err); + this._clients = this._clients.filter((c) => c !== client); + if (timeoutHit) { + err = new Error("Connection terminated due to connection timeout", { cause: err }); + } + this._pulseQueue(); + if (!pendingItem.timedOut) { + pendingItem.callback(err, void 0, NOOP); + } + } else { + this.log("new client connected"); + if (this.options.onConnect) { + this._promiseTry(() => this.options.onConnect(client)).then( + () => { + this._afterConnect(client, pendingItem, idleListener); + }, + (hookErr) => { + this._clients = this._clients.filter((c) => c !== client); + client.end(() => { + this._pulseQueue(); + if (!pendingItem.timedOut) { + pendingItem.callback(hookErr, void 0, NOOP); + } + }); + } + ); + return; + } + return this._afterConnect(client, pendingItem, idleListener); + } + }); + } + _afterConnect(client, pendingItem, idleListener) { + if (this.options.maxLifetimeSeconds !== 0) { + const maxLifetimeTimeout = setTimeout(() => { + this.log("ending client due to expired lifetime"); + this._expired.add(client); + const idleIndex = this._idle.findIndex((idleItem) => idleItem.client === client); + if (idleIndex !== -1) { + this._acquireClient( + client, + new PendingItem((err, client2, clientRelease) => clientRelease()), + idleListener, + false + ); + } + }, this.options.maxLifetimeSeconds * 1e3); + maxLifetimeTimeout.unref(); + client.once("end", () => clearTimeout(maxLifetimeTimeout)); + } + return this._acquireClient(client, pendingItem, idleListener, true); + } + // acquire a client for a pending work item + _acquireClient(client, pendingItem, idleListener, isNew) { + if (isNew) { + this.emit("connect", client); + } + this.emit("acquire", client); + client.release = this._releaseOnce(client, idleListener); + client.removeListener("error", idleListener); + if (!pendingItem.timedOut) { + if (isNew && this.options.verify) { + this.options.verify(client, (err) => { + if (err) { + client.release(err); + return pendingItem.callback(err, void 0, NOOP); + } + pendingItem.callback(void 0, client, client.release); + }); + } else { + pendingItem.callback(void 0, client, client.release); + } + } else { + if (isNew && this.options.verify) { + this.options.verify(client, client.release); + } else { + client.release(); + } + } + } + // returns a function that wraps _release and throws if called more than once + _releaseOnce(client, idleListener) { + let released = false; + return (err) => { + if (released) { + throwOnDoubleRelease(); + } + released = true; + this._release(client, idleListener, err); + }; + } + // release a client back to the poll, include an error + // to remove it from the pool + _release(client, idleListener, err) { + client.on("error", idleListener); + client._poolUseCount = (client._poolUseCount || 0) + 1; + this.emit("release", err, client); + if (err || this.ending || !client._queryable || client._ending || client._poolUseCount >= this.options.maxUses) { + if (client._poolUseCount >= this.options.maxUses) { + this.log("remove expended client"); + } + return this._remove(client, this._pulseQueue.bind(this)); + } + const isExpired = this._expired.has(client); + if (isExpired) { + this.log("remove expired client"); + this._expired.delete(client); + return this._remove(client, this._pulseQueue.bind(this)); + } + let tid; + if (this.options.idleTimeoutMillis && this._isAboveMin()) { + tid = setTimeout(() => { + if (this._isAboveMin()) { + this.log("remove idle client"); + this._remove(client, this._pulseQueue.bind(this)); + } + }, this.options.idleTimeoutMillis); + if (this.options.allowExitOnIdle) { + tid.unref(); + } + } + if (this.options.allowExitOnIdle) { + client.unref(); + } + this._idle.push(new IdleItem(client, idleListener, tid)); + this._pulseQueue(); + } + query(text, values, cb) { + if (typeof text === "function") { + const response2 = promisify(this.Promise, text); + setImmediate(function() { + return response2.callback(new Error("Passing a function as the first parameter to pool.query is not supported")); + }); + return response2.result; + } + if (typeof values === "function") { + cb = values; + values = void 0; + } + const response = promisify(this.Promise, cb); + cb = response.callback; + this.connect((err, client) => { + if (err) { + return cb(err); + } + let clientReleased = false; + const onError = (err2) => { + if (clientReleased) { + return; + } + clientReleased = true; + client.release(err2); + cb(err2); + }; + client.once("error", onError); + this.log("dispatching query"); + try { + client.query(text, values, (err2, res) => { + this.log("query dispatched"); + client.removeListener("error", onError); + if (clientReleased) { + return; + } + clientReleased = true; + client.release(err2); + if (err2) { + return cb(err2); + } + return cb(void 0, res); + }); + } catch (err2) { + client.release(err2); + return cb(err2); + } + }); + return response.result; + } + end(cb) { + this.log("ending"); + if (this.ending) { + const err = new Error("Called end on pool more than once"); + return cb ? cb(err) : this.Promise.reject(err); + } + this.ending = true; + const promised = promisify(this.Promise, cb); + this._endCallback = promised.callback; + this._pulseQueue(); + return promised.result; + } + get waitingCount() { + return this._pendingQueue.length; + } + get idleCount() { + return this._idle.length; + } + get expiredCount() { + return this._clients.reduce((acc, client) => acc + (this._expired.has(client) ? 1 : 0), 0); + } + get totalCount() { + return this._clients.length; + } + }; + module.exports = Pool2; + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/native/query.js +var require_query2 = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/native/query.js"(exports, module) { + "use strict"; + var EventEmitter = __require("events").EventEmitter; + var util = __require("util"); + var utils = require_utils(); + var NativeQuery = module.exports = function(config, values, callback) { + EventEmitter.call(this); + config = utils.normalizeQueryConfig(config, values, callback); + this.text = config.text; + this.values = config.values; + this.name = config.name; + this.queryMode = config.queryMode; + this.callback = config.callback; + this.state = "new"; + this._arrayMode = config.rowMode === "array"; + this._emitRowEvents = false; + this.on( + "newListener", + function(event) { + if (event === "row") this._emitRowEvents = true; + }.bind(this) + ); + }; + util.inherits(NativeQuery, EventEmitter); + var errorFieldMap = { + sqlState: "code", + statementPosition: "position", + messagePrimary: "message", + context: "where", + schemaName: "schema", + tableName: "table", + columnName: "column", + dataTypeName: "dataType", + constraintName: "constraint", + sourceFile: "file", + sourceLine: "line", + sourceFunction: "routine" + }; + NativeQuery.prototype.handleError = function(err) { + const fields = this.native.pq.resultErrorFields(); + if (fields) { + for (const key in fields) { + const normalizedFieldName = errorFieldMap[key] || key; + err[normalizedFieldName] = fields[key]; + } + } + if (this.callback) { + this.callback(err); + } else { + this.emit("error", err); + } + this.state = "error"; + }; + NativeQuery.prototype.then = function(onSuccess, onFailure) { + return this._getPromise().then(onSuccess, onFailure); + }; + NativeQuery.prototype.catch = function(callback) { + return this._getPromise().catch(callback); + }; + NativeQuery.prototype._getPromise = function() { + if (this._promise) return this._promise; + this._promise = new Promise( + function(resolve, reject) { + this._once("end", resolve); + this._once("error", reject); + }.bind(this) + ); + return this._promise; + }; + NativeQuery.prototype.submit = function(client) { + this.state = "running"; + const self = this; + this.native = client.native; + client.native.arrayMode = this._arrayMode; + let after = function(err, rows, results) { + client.native.arrayMode = false; + setImmediate(function() { + self.emit("_done"); + }); + if (err) { + return self.handleError(err); + } + if (self._emitRowEvents) { + if (results.length > 1) { + rows.forEach((rowOfRows, i) => { + rowOfRows.forEach((row) => { + self.emit("row", row, results[i]); + }); + }); + } else { + rows.forEach(function(row) { + self.emit("row", row, results); + }); + } + } + self.state = "end"; + self.emit("end", results); + if (self.callback) { + self.callback(null, results); + } + }; + if (process.domain) { + after = process.domain.bind(after); + } + if (this.name) { + if (this.name.length > 63) { + console.error("Warning! Postgres only supports 63 characters for query names."); + console.error("You supplied %s (%s)", this.name, this.name.length); + console.error("This can cause conflicts and silent errors executing queries"); + } + const values = (this.values || []).map(utils.prepareValue); + if (client.namedQueries[this.name]) { + if (this.text && client.namedQueries[this.name] !== this.text) { + const err = new Error(`Prepared statements must be unique - '${this.name}' was used for a different statement`); + return after(err); + } + return client.native.execute(this.name, values, after); + } + return client.native.prepare(this.name, this.text, values.length, function(err) { + if (err) return after(err); + client.namedQueries[self.name] = self.text; + return self.native.execute(self.name, values, after); + }); + } else if (this.values) { + if (!Array.isArray(this.values)) { + const err = new Error("Query values must be an array"); + return after(err); + } + const vals = this.values.map(utils.prepareValue); + client.native.query(this.text, vals, after); + } else if (this.queryMode === "extended") { + client.native.query(this.text, [], after); + } else { + client.native.query(this.text, after); + } + }; + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/native/client.js +var require_client2 = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/native/client.js"(exports, module) { + var nodeUtils = __require("util"); + var Native; + try { + Native = __require("pg-native"); + } catch (e) { + throw e; + } + var TypeOverrides2 = require_type_overrides(); + var EventEmitter = __require("events").EventEmitter; + var util = __require("util"); + var ConnectionParameters = require_connection_parameters(); + var NativeQuery = require_query2(); + var queryQueueLengthDeprecationNotice = nodeUtils.deprecate( + () => { + }, + "Calling client.query() when the client is already executing a query is deprecated and will be removed in pg@9.0. Use async/await or an external async flow control mechanism instead." + ); + var Client2 = module.exports = function(config) { + EventEmitter.call(this); + config = config || {}; + this._Promise = config.Promise || global.Promise; + this._types = new TypeOverrides2(config.types); + this.native = new Native({ + types: this._types + }); + this._queryQueue = []; + this._ending = false; + this._connecting = false; + this._connected = false; + this._queryable = true; + const cp = this.connectionParameters = new ConnectionParameters(config); + if (config.nativeConnectionString) cp.nativeConnectionString = config.nativeConnectionString; + this.user = cp.user; + Object.defineProperty(this, "password", { + configurable: true, + enumerable: false, + writable: true, + value: cp.password + }); + this.database = cp.database; + this.host = cp.host; + this.port = cp.port; + this.namedQueries = {}; + }; + Client2.Query = NativeQuery; + util.inherits(Client2, EventEmitter); + Client2.prototype._errorAllQueries = function(err) { + const enqueueError = (query) => { + process.nextTick(() => { + query.native = this.native; + query.handleError(err); + }); + }; + if (this._hasActiveQuery()) { + enqueueError(this._activeQuery); + this._activeQuery = null; + } + this._queryQueue.forEach(enqueueError); + this._queryQueue.length = 0; + }; + Client2.prototype._connect = function(cb) { + const self = this; + if (this._connecting) { + process.nextTick(() => cb(new Error("Client has already been connected. You cannot reuse a client."))); + return; + } + this._connecting = true; + this.connectionParameters.getLibpqConnectionString(function(err, conString) { + if (self.connectionParameters.nativeConnectionString) conString = self.connectionParameters.nativeConnectionString; + if (err) return cb(err); + self.native.connect(conString, function(err2) { + if (err2) { + self.native.end(); + return cb(err2); + } + self._connected = true; + self.native.on("error", function(err3) { + self._queryable = false; + self._errorAllQueries(err3); + self.emit("error", err3); + }); + self.native.on("notification", function(msg) { + self.emit("notification", { + channel: msg.relname, + payload: msg.extra + }); + }); + self.emit("connect"); + self._pulseQueryQueue(true); + cb(null, this); + }); + }); + }; + Client2.prototype.connect = function(callback) { + if (callback) { + this._connect(callback); + return; + } + return new this._Promise((resolve, reject) => { + this._connect((error) => { + if (error) { + reject(error); + } else { + resolve(this); + } + }); + }); + }; + Client2.prototype.query = function(config, values, callback) { + let query; + let result; + let readTimeout; + let readTimeoutTimer; + let queryCallback; + if (config === null || config === void 0) { + throw new TypeError("Client was passed a null or undefined query"); + } else if (typeof config.submit === "function") { + readTimeout = config.query_timeout || this.connectionParameters.query_timeout; + result = query = config; + if (typeof values === "function") { + config.callback = values; + } + } else { + readTimeout = config.query_timeout || this.connectionParameters.query_timeout; + query = new NativeQuery(config, values, callback); + if (!query.callback) { + let resolveOut, rejectOut; + result = new this._Promise((resolve, reject) => { + resolveOut = resolve; + rejectOut = reject; + }).catch((err) => { + Error.captureStackTrace(err); + throw err; + }); + query.callback = (err, res) => err ? rejectOut(err) : resolveOut(res); + } + } + if (readTimeout) { + queryCallback = query.callback || (() => { + }); + readTimeoutTimer = setTimeout(() => { + const error = new Error("Query read timeout"); + process.nextTick(() => { + query.handleError(error, this.connection); + }); + queryCallback(error); + query.callback = () => { + }; + const index = this._queryQueue.indexOf(query); + if (index > -1) { + this._queryQueue.splice(index, 1); + } + this._pulseQueryQueue(); + }, readTimeout); + query.callback = (err, res) => { + clearTimeout(readTimeoutTimer); + queryCallback(err, res); + }; + } + if (!this._queryable) { + query.native = this.native; + process.nextTick(() => { + query.handleError(new Error("Client has encountered a connection error and is not queryable")); + }); + return result; + } + if (this._ending) { + query.native = this.native; + process.nextTick(() => { + query.handleError(new Error("Client was closed and is not queryable")); + }); + return result; + } + if (this._queryQueue.length > 0) { + queryQueueLengthDeprecationNotice(); + } + this._queryQueue.push(query); + this._pulseQueryQueue(); + return result; + }; + Client2.prototype.end = function(cb) { + const self = this; + this._ending = true; + if (this._connecting && !this._connected) { + this.once("connect", () => { + this.end(() => { + }); + }); + } + let result; + if (!cb) { + result = new this._Promise(function(resolve, reject) { + cb = (err) => err ? reject(err) : resolve(); + }); + } + this.native.end(function() { + self._connected = false; + self._errorAllQueries(new Error("Connection terminated")); + process.nextTick(() => { + self.emit("end"); + if (cb) cb(); + }); + }); + return result; + }; + Client2.prototype._hasActiveQuery = function() { + return this._activeQuery && this._activeQuery.state !== "error" && this._activeQuery.state !== "end"; + }; + Client2.prototype._pulseQueryQueue = function(initialConnection) { + if (!this._connected) { + return; + } + if (this._hasActiveQuery()) { + return; + } + const query = this._queryQueue.shift(); + if (!query) { + if (!initialConnection) { + this.emit("drain"); + } + return; + } + this._activeQuery = query; + query.submit(this); + const self = this; + query.once("_done", function() { + self._pulseQueryQueue(); + }); + }; + Client2.prototype.cancel = function(query) { + if (this._activeQuery === query) { + this.native.cancel(function() { + }); + } else if (this._queryQueue.indexOf(query) !== -1) { + this._queryQueue.splice(this._queryQueue.indexOf(query), 1); + } + }; + Client2.prototype.ref = function() { + }; + Client2.prototype.unref = function() { + }; + Client2.prototype.setTypeParser = function(oid, format, parseFn) { + return this._types.setTypeParser(oid, format, parseFn); + }; + Client2.prototype.getTypeParser = function(oid, format) { + return this._types.getTypeParser(oid, format); + }; + Client2.prototype.isConnected = function() { + return this._connected; + }; + Client2.prototype.getTransactionStatus = function() { + return this.native.getTransactionStatus(); + }; + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/native/index.js +var require_native = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/native/index.js"(exports, module) { + "use strict"; + module.exports = require_client2(); + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/index.js +var require_lib2 = __commonJS({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/lib/index.js"(exports, module) { + "use strict"; + var Client2 = require_client(); + var defaults2 = require_defaults(); + var Connection2 = require_connection(); + var Result2 = require_result(); + var utils = require_utils(); + var Pool2 = require_pg_pool(); + var TypeOverrides2 = require_type_overrides(); + var { DatabaseError: DatabaseError2 } = require_dist(); + var { escapeIdentifier: escapeIdentifier2, escapeLiteral: escapeLiteral2 } = require_utils(); + var poolFactory = (Client3) => { + return class BoundPool extends Pool2 { + constructor(options) { + super(options, Client3); + } + }; + }; + var PG = function(clientConstructor2) { + this.defaults = defaults2; + this.Client = clientConstructor2; + this.Query = this.Client.Query; + this.Pool = poolFactory(this.Client); + this._pools = []; + this.Connection = Connection2; + this.types = require_pg_types(); + this.DatabaseError = DatabaseError2; + this.TypeOverrides = TypeOverrides2; + this.escapeIdentifier = escapeIdentifier2; + this.escapeLiteral = escapeLiteral2; + this.Result = Result2; + this.utils = utils; + }; + var clientConstructor = Client2; + var forceNative = false; + try { + forceNative = !!process.env.NODE_PG_FORCE_NATIVE; + } catch { + } + if (forceNative) { + clientConstructor = require_native(); + } + module.exports = new PG(clientConstructor); + Object.defineProperty(module.exports, "native", { + configurable: true, + enumerable: false, + get() { + let native = null; + try { + native = new PG(require_native()); + } catch (err) { + if (err.code !== "MODULE_NOT_FOUND") { + throw err; + } + } + Object.defineProperty(module.exports, "native", { + value: native + }); + return native; + } + }); + } +}); + +// node_modules/.pnpm/pg@8.22.0/node_modules/pg/esm/index.mjs +var esm_exports = {}; +__export(esm_exports, { + Client: () => Client, + Connection: () => Connection, + DatabaseError: () => DatabaseError, + Pool: () => Pool, + Query: () => Query, + Result: () => Result, + TypeOverrides: () => TypeOverrides, + default: () => esm_default, + defaults: () => defaults, + escapeIdentifier: () => escapeIdentifier, + escapeLiteral: () => escapeLiteral, + types: () => types +}); +var import_lib, Client, Pool, Connection, types, Query, DatabaseError, escapeIdentifier, escapeLiteral, Result, TypeOverrides, defaults, esm_default; +var init_esm = __esm({ + "node_modules/.pnpm/pg@8.22.0/node_modules/pg/esm/index.mjs"() { + import_lib = __toESM(require_lib2(), 1); + Client = import_lib.default.Client; + Pool = import_lib.default.Pool; + Connection = import_lib.default.Connection; + types = import_lib.default.types; + Query = import_lib.default.Query; + DatabaseError = import_lib.default.DatabaseError; + escapeIdentifier = import_lib.default.escapeIdentifier; + escapeLiteral = import_lib.default.escapeLiteral; + Result = import_lib.default.Result; + TypeOverrides = import_lib.default.TypeOverrides; + defaults = import_lib.default.defaults; + esm_default = import_lib.default; + } +}); + +// experience-service-pg.mjs +var experience_service_pg_exports = {}; +__export(experience_service_pg_exports, { + createPgExperienceService: () => createPgExperienceService +}); +import crypto33 from "node:crypto"; +async function createPgExperienceService(options = {}) { + const connectionString = options.connectionString; + if (!connectionString) { + throw new Error("createPgExperienceService \u9700\u8981 connectionString\uFF08\u5EFA\u8BAE\u7528 EXPERIENCE_PG_URL\uFF09"); + } + const now = options.now ?? (() => Date.now()); + const embed = options.embed ?? null; + const embeddingDim = Math.max(1, Number(options.embeddingDim ?? 1536)); + const halfLifeMs = Math.max( + 6e4, + Number(options.recencyHalfLifeMs ?? 30 * 24 * 60 * 60 * 1e3) + ); + const { default: pg2 } = await Promise.resolve().then(() => (init_esm(), esm_exports)); + const pool2 = new pg2.Pool({ connectionString, max: Number(options.poolMax ?? 10) }); + await pool2.query("CREATE EXTENSION IF NOT EXISTS vector"); + await pool2.query(` + CREATE TABLE IF NOT EXISTS h5_experience ( + id UUID PRIMARY KEY, + scope TEXT NOT NULL DEFAULT 'global', + kind TEXT NOT NULL DEFAULT 'lesson', + title TEXT NOT NULL, + body TEXT NOT NULL, + tags JSONB NOT NULL DEFAULT '[]'::jsonb, + source_session_id TEXT, + source_user_id TEXT, + use_count BIGINT NOT NULL DEFAULT 0, + embedding vector(${embeddingDim}), + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL + ) + `); + await pool2.query( + "CREATE INDEX IF NOT EXISTS idx_h5_experience_scope_updated ON h5_experience (scope, updated_at DESC)" + ); + await pool2.query( + "CREATE INDEX IF NOT EXISTS idx_h5_experience_embedding ON h5_experience USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)" + ).catch(() => { + }); + function normalizeTags2(tags) { + if (!Array.isArray(tags)) return []; + return [...new Set(tags.map((t) => String(t).trim()).filter(Boolean))]; + } + function toVectorLiteral(vec) { + return `[${vec.map((n) => Number(n)).join(",")}]`; + } + function rowToExperience(row) { + return { + id: row.id, + scope: row.scope, + kind: row.kind, + title: row.title, + body: row.body, + tags: Array.isArray(row.tags) ? row.tags : [], + sourceSessionId: row.source_session_id ?? null, + sourceUserId: row.source_user_id ?? null, + useCount: Number(row.use_count ?? 0), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at) + }; + } + async function record(input) { + const title = String(input?.title ?? "").trim(); + const body = String(input?.body ?? "").trim(); + if (!title) throw 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 ts = now(); + const scope = String(input?.scope ?? "global").trim() || "global"; + const kind = String(input?.kind ?? "lesson").trim() || "lesson"; + const tags = normalizeTags2(input?.tags); + let embedding = null; + if (embed) { + try { + const vec = await embed(`${title} +${body}`); + if (Array.isArray(vec) && vec.length === embeddingDim) embedding = toVectorLiteral(vec); + } catch { + embedding = null; + } + } + await pool2.query( + `INSERT INTO h5_experience + (id, scope, kind, title, body, tags, source_session_id, source_user_id, + use_count, embedding, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,0,$9::vector,$10,$11)`, + [ + id, + scope, + kind, + title, + body, + JSON.stringify(tags), + input?.sourceSessionId ?? null, + input?.sourceUserId ?? null, + embedding, + ts, + ts + ] + ); + return rowToExperience({ + id, + scope, + kind, + title, + body, + tags, + source_session_id: input?.sourceSessionId ?? null, + source_user_id: input?.sourceUserId ?? null, + use_count: 0, + created_at: ts, + updated_at: ts + }); + } + async function search(query, { scope = "global", limit = 5 } = {}) { + const text = String(query ?? "").trim(); + if (!text) return []; + const max = Math.max(1, limit); + if (embed) { + try { + const vec = await embed(text); + if (Array.isArray(vec) && vec.length === embeddingDim) { + const { rows: rows2 } = await pool2.query( + `SELECT id, scope, kind, title, body, tags, source_session_id, + source_user_id, use_count, created_at, updated_at + FROM h5_experience + WHERE scope = $1 AND embedding IS NOT NULL + ORDER BY embedding <=> $2::vector, updated_at DESC + LIMIT $3`, + [scope, toVectorLiteral(vec), max] + ); + if (rows2.length > 0) { + await bumpUseCount(rows2.map((r) => r.id)); + return rows2.map(rowToExperience); + } + } + } catch { + } + } + const terms = tokenize(text); + if (terms.length === 0) return []; + const likeClauses = terms.map((_, i) => `(title ILIKE $${i + 2} OR body ILIKE $${i + 2})`); + const params = [scope, ...terms.map((t) => `%${t}%`), max]; + const { rows } = await pool2.query( + `SELECT id, scope, kind, title, body, tags, source_session_id, + source_user_id, use_count, created_at, updated_at + FROM h5_experience + WHERE scope = $1 AND (${likeClauses.join(" OR ")}) + ORDER BY updated_at DESC + LIMIT $${terms.length + 2}`, + params + ); + if (rows.length > 0) await bumpUseCount(rows.map((r) => r.id)); + return rows.map(rowToExperience); + } + async function bumpUseCount(ids) { + if (!ids.length) return; + await pool2.query("UPDATE h5_experience SET use_count = use_count + 1 WHERE id = ANY($1::uuid[])", [ids]).catch(() => { + }); + } + function tokenize(query) { + return [ + ...new Set( + String(query ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).map((t) => t.trim()).filter((t) => t.length >= 2) + ) + ]; + } + async function reflect() { + return { ok: false, reason: "not_implemented" }; + } + async function close() { + await pool2.end(); + } + return { record, search, reflect, close }; +} +function experienceError2(message, code) { + return Object.assign(new Error(message), { code }); +} +var init_experience_service_pg = __esm({ + "experience-service-pg.mjs"() { + "use strict"; + } +}); // server.mjs import express2 from "express"; -import crypto29 from "node:crypto"; -import fs23 from "node:fs"; +import crypto34 from "node:crypto"; +import fs26 from "node:fs"; +import fsPromises3 from "node:fs/promises"; import { createProxyMiddleware } from "http-proxy-middleware"; -import path22 from "node:path"; +import path28 from "node:path"; import { fileURLToPath as fileURLToPath6 } from "node:url"; // auth.mjs @@ -119,8 +5532,8 @@ function asPositiveInteger(value, fallback) { if (!Number.isFinite(parsed) || parsed < 1) return fallback; return Math.floor(parsed); } -async function ensureConfigTable(pool) { - await pool.query(` +async function ensureConfigTable(pool2) { + await pool2.query(` CREATE TABLE IF NOT EXISTS mindspace_config ( \`key\` VARCHAR(64) PRIMARY KEY, value TEXT NOT NULL, @@ -134,29 +5547,29 @@ function defaultMindSpaceConfig(env = process.env) { publicPageLimit: asPositiveInteger(env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5, 5) }; } -async function readConfigRows(pool) { - const [rows] = await pool.query( +async function readConfigRows(pool2) { + const [rows] = await pool2.query( "SELECT `key`, value FROM mindspace_config" ); return rows; } -async function ensureMindSpaceConfig(pool, { env = process.env, seedDefault = true } = {}) { - await ensureConfigTable(pool); +async function ensureMindSpaceConfig(pool2, { env = process.env, seedDefault = true } = {}) { + await ensureConfigTable(pool2); if (!seedDefault) return; - const [rows] = await pool.query("SELECT COUNT(*) AS count FROM mindspace_config"); + const [rows] = await pool2.query("SELECT COUNT(*) AS count FROM mindspace_config"); if (Number(rows[0]?.count ?? 0) > 0) return; const now = Date.now(); - const defaults = defaultMindSpaceConfig(env); - await pool.query( + const defaults2 = defaultMindSpaceConfig(env); + await pool2.query( `INSERT INTO mindspace_config (\`key\`, value, description, updated_at) VALUES (?, ?, ?, ?)`, - [PUBLIC_PAGE_LIMIT_KEY, String(defaults.publicPageLimit), "\u516C\u5F00\u9875\u9762\u6570\u91CF\u4E0A\u9650", now] + [PUBLIC_PAGE_LIMIT_KEY, String(defaults2.publicPageLimit), "\u516C\u5F00\u9875\u9762\u6570\u91CF\u4E0A\u9650", now] ); } -async function loadMindSpaceConfig(pool, { env = process.env } = {}) { +async function loadMindSpaceConfig(pool2, { env = process.env } = {}) { const config = defaultMindSpaceConfig(env); try { - const rows = await readConfigRows(pool); + const rows = await readConfigRows(pool2); for (const row of rows) { if (row.key === PUBLIC_PAGE_LIMIT_KEY) { config.publicPageLimit = asPositiveInteger(row.value, config.publicPageLimit); @@ -181,21 +5594,13 @@ var SYSTEM_CATEGORIES = Object.freeze([ publishPolicy: "derivative_only", sortOrder: 10 }, - { - code: "private", - name: "\u79C1\u4EBA\u533A", - visibilityPolicy: "private", - aiAccessPolicy: "explicit_asset_grant", - publishPolicy: "desensitized_copy_only", - sortOrder: 20 - }, { code: "public", name: "\u516C\u5F00\u533A", visibilityPolicy: "public_candidate", aiAccessPolicy: "selected_assets", publishPolicy: "security_scan_required", - sortOrder: 30 + sortOrder: 20 }, { code: "draft", @@ -283,19 +5688,19 @@ async function initializeDefaultSpace(db, userId, { } return resolvedSpaceId; } -async function ensureDefaultSpaces(pool, options = {}) { - const [users] = await pool.query( +async function ensureDefaultSpaces(pool2, options = {}) { + const [users] = await pool2.query( `SELECT u.id FROM h5_users u LEFT JOIN h5_user_spaces s ON s.user_id = u.id WHERE u.role = 'user' AND s.id IS NULL` ); for (const user of users) { - await initializeDefaultSpace(pool, user.id, options); + await initializeDefaultSpace(pool2, user.id, options); } return users.length; } -function createMindSpaceService(pool, options = {}) { +function createMindSpaceService(pool2, options = {}) { const maxFileBytes = Number(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES); const aiDailyLimit = Number(options.aiDailyLimit ?? 10); const publicPageLimitFallback = Number(options.publicPageLimit ?? 5); @@ -303,14 +5708,14 @@ function createMindSpaceService(pool, options = {}) { const scheduleService2 = options.scheduleService ?? null; const resolvePublicPageLimit = async () => { try { - const config = await loadMindSpaceConfig(pool); + const config = await loadMindSpaceConfig(pool2); return Number(config.publicPageLimit ?? publicPageLimitFallback); } catch { return publicPageLimitFallback; } }; const getSpace = async (userId) => { - const [spaces] = await pool.query( + const [spaces] = await pool2.query( `SELECT id, user_id, space_name, quota_bytes, used_bytes, reserved_bytes, status, created_at, updated_at FROM h5_user_spaces @@ -320,7 +5725,7 @@ function createMindSpaceService(pool, options = {}) { ); const row = spaces[0]; if (!row) return null; - const [categories] = await pool.query( + const [categories] = await pool2.query( `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 @@ -333,7 +5738,6 @@ function createMindSpaceService(pool, options = {}) { ON a.category_id = c.id AND a.user_id = c.user_id AND a.status <> 'deleted' - AND a.source_type = 'upload' 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 @@ -347,7 +5751,7 @@ function createMindSpaceService(pool, options = {}) { const quotaBytes = asNumber(row.quota_bytes); const usedBytes = asNumber(row.used_bytes); const reservedBytes = asNumber(row.reserved_bytes); - const [publicationUsage] = await pool.query( + const [publicationUsage] = await pool2.query( `SELECT COUNT(DISTINCT CASE WHEN status = 'online' THEN page_id END) AS public_page_used, COALESCE(SUM(CASE WHEN published_at >= ? THEN view_count ELSE 0 END), 0) AS monthly_view_used FROM h5_publish_records WHERE user_id = ?`, @@ -417,12 +5821,18 @@ function isDatabaseConfigured() { process.env.DATABASE_URL || process.env.MYSQL_HOST && process.env.MYSQL_DATABASE ); } +function resolvePoolOptions() { + const connectionLimit = Math.max(1, Number(process.env.MYSQL_POOL_SIZE ?? 50)); + const queueLimit = Math.max(0, Number(process.env.MYSQL_POOL_QUEUE_LIMIT ?? 0)); + return { waitForConnections: true, connectionLimit, queueLimit }; +} function createDbPool() { if (!isDatabaseConfigured()) { throw new Error("MySQL \u672A\u914D\u7F6E\uFF0C\u8BF7\u8BBE\u7F6E DATABASE_URL \u6216 MYSQL_* \u73AF\u5883\u53D8\u91CF"); } + const poolOptions = resolvePoolOptions(); if (process.env.DATABASE_URL) { - return mysql.createPool(process.env.DATABASE_URL); + return mysql.createPool({ uri: process.env.DATABASE_URL, ...poolOptions }); } return mysql.createPool({ host: process.env.MYSQL_HOST ?? "localhost", @@ -430,12 +5840,11 @@ function createDbPool() { user: process.env.MYSQL_USER ?? "boot", password: process.env.MYSQL_PASSWORD ?? "", database: process.env.MYSQL_DATABASE ?? "tkmind", - waitForConnections: true, - connectionLimit: 10 + ...poolOptions }); } -async function columnExists(pool, table, column) { - const [rows] = await pool.query( +async function columnExists(pool2, table, column) { + const [rows] = await pool2.query( `SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`, @@ -443,8 +5852,8 @@ async function columnExists(pool, table, column) { ); return rows.length > 0; } -async function indexExists(pool, table, index) { - const [rows] = await pool.query( +async function indexExists(pool2, table, index) { + const [rows] = await pool2.query( `SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1`, @@ -452,8 +5861,8 @@ async function indexExists(pool, table, index) { ); return rows.length > 0; } -async function foreignKeyDeleteRule(pool, table, constraint) { - const [rows] = await pool.query( +async function foreignKeyDeleteRule(pool2, table, constraint) { + const [rows] = await pool2.query( `SELECT DELETE_RULE FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() @@ -464,28 +5873,28 @@ async function foreignKeyDeleteRule(pool, table, constraint) { ); return rows[0]?.DELETE_RULE ?? null; } -async function ensureForeignKeyDeleteRule(pool, { table, constraint, column, referencedTable, referencedColumn = "id", deleteRule }) { - const currentRule = await foreignKeyDeleteRule(pool, table, constraint); +async function ensureForeignKeyDeleteRule(pool2, { table, constraint, column, referencedTable, referencedColumn = "id", deleteRule }) { + const currentRule = await foreignKeyDeleteRule(pool2, table, constraint); if (currentRule === deleteRule) return; if (currentRule) { - await pool.query(`ALTER TABLE \`${table}\` DROP FOREIGN KEY \`${constraint}\``); + await pool2.query(`ALTER TABLE \`${table}\` DROP FOREIGN KEY \`${constraint}\``); } - await pool.query( + await pool2.query( `ALTER TABLE \`${table}\` ADD CONSTRAINT \`${constraint}\` FOREIGN KEY (\`${column}\`) REFERENCES \`${referencedTable}\`(\`${referencedColumn}\`) ON DELETE ${deleteRule}` ); } -async function migrateSchema(pool) { +async function migrateSchema(pool2) { const renames = [ ["h5_user_sessions", "goose_session_id", "agent_session_id"], ["h5_session_billing_state", "goose_session_id", "agent_session_id"], ["h5_usage_records", "goose_session_id", "agent_session_id"] ]; for (const [table, oldCol, newCol] of renames) { - if (await columnExists(pool, table, oldCol)) { - await pool.query( + if (await columnExists(pool2, table, oldCol)) { + await pool2.query( `ALTER TABLE \`${table}\` CHANGE \`${oldCol}\` \`${newCol}\` VARCHAR(128) NOT NULL` ); } @@ -500,16 +5909,21 @@ async function migrateSchema(pool) { ["plan_type", "VARCHAR(32) NOT NULL DEFAULT 'free' AFTER status"] ]; for (const [column, definition] of userColumns) { - if (!await columnExists(pool, "h5_users", column)) { - await pool.query(`ALTER TABLE h5_users ADD COLUMN \`${column}\` ${definition}`); + if (!await columnExists(pool2, "h5_users", column)) { + await pool2.query(`ALTER TABLE h5_users ADD COLUMN \`${column}\` ${definition}`); } } - await pool.query(`UPDATE h5_users SET slug = username WHERE slug IS NULL OR slug = ''`); - if (!await indexExists(pool, "h5_users", "uq_h5_users_slug")) { - await pool.query(`ALTER TABLE h5_users ADD UNIQUE KEY uq_h5_users_slug (slug)`); + await pool2.query(`UPDATE h5_users SET slug = username WHERE slug IS NULL OR slug = ''`); + if (!await indexExists(pool2, "h5_users", "uq_h5_users_slug")) { + await pool2.query(`ALTER TABLE h5_users ADD UNIQUE KEY uq_h5_users_slug (slug)`); } - if (!await indexExists(pool, "h5_users", "uq_h5_users_email")) { - await pool.query(`ALTER TABLE h5_users ADD UNIQUE KEY uq_h5_users_email (email)`); + if (!await indexExists(pool2, "h5_users", "uq_h5_users_email")) { + await pool2.query(`ALTER TABLE h5_users ADD UNIQUE KEY uq_h5_users_email (email)`); + } + if (!await indexExists(pool2, "h5_usage_records", "uniq_h5_usage_request_id")) { + await pool2.query( + `ALTER TABLE h5_usage_records ADD UNIQUE KEY uniq_h5_usage_request_id (request_id)` + ); } const assetForeignKeys = [ { @@ -549,9 +5963,9 @@ async function migrateSchema(pool) { } ]; for (const foreignKey of assetForeignKeys) { - await ensureForeignKeyDeleteRule(pool, foreignKey); + await ensureForeignKeyDeleteRule(pool2, foreignKey); } - await pool.query( + await pool2.query( `ALTER TABLE h5_assets MODIFY source_type ENUM('upload', 'chat', 'agent', 'template', 'generated', 'workspace') NOT NULL DEFAULT 'upload'` @@ -567,11 +5981,11 @@ async function migrateSchema(pool) { ["is_vision_selected", "TINYINT(1) NOT NULL DEFAULT 0 AFTER is_selected"] ]; for (const [column, definition] of llmColumns) { - if (!await columnExists(pool, "h5_llm_provider_keys", column)) { - await pool.query(`ALTER TABLE h5_llm_provider_keys ADD COLUMN \`${column}\` ${definition}`); + if (!await columnExists(pool2, "h5_llm_provider_keys", column)) { + await pool2.query(`ALTER TABLE h5_llm_provider_keys ADD COLUMN \`${column}\` ${definition}`); } } - await pool.query(` + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_llm_executor_bindings ( id CHAR(36) PRIMARY KEY, executor ENUM('goose', 'aider', 'openhands') NOT NULL, @@ -586,15 +6000,82 @@ async function migrateSchema(pool) { CONSTRAINT fk_h5_llm_executor_provider FOREIGN KEY (provider_key_id) REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + await pool2.query(` + CREATE TABLE IF NOT EXISTS h5_experience ( + id CHAR(36) PRIMARY KEY, + scope VARCHAR(64) NOT NULL DEFAULT 'global', + kind VARCHAR(32) NOT NULL DEFAULT 'lesson', + title VARCHAR(255) NOT NULL, + body MEDIUMTEXT NOT NULL, + tags_json JSON NULL, + source_session_id VARCHAR(128) NULL, + source_user_id CHAR(36) NULL, + use_count BIGINT NOT NULL DEFAULT 0, + embedding JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_experience_scope_updated (scope, updated_at), + FULLTEXT KEY ftx_h5_experience (title, body) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool2.query(` + CREATE TABLE IF NOT EXISTS h5_conversation_messages ( + id CHAR(64) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + agent_session_id VARCHAR(128) NOT NULL, + message_key VARCHAR(128) NOT NULL, + sequence_no INT NOT NULL DEFAULT 0, + role VARCHAR(32) NOT NULL, + text MEDIUMTEXT NOT NULL, + raw_json LONGTEXT NULL, + analyzed_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_conversation_message (agent_session_id, message_key), + KEY idx_h5_conversation_user_created (user_id, created_at), + KEY idx_h5_conversation_user_analyzed (user_id, analyzed_at), + CONSTRAINT fk_h5_conversation_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + 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_user_memory_items ( + id CHAR(64) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + label ENUM('preference', 'habit', 'interest', 'goal', 'fact', 'experience', 'knowledge') NOT NULL DEFAULT 'fact', + memory_hash CHAR(64) NOT NULL, + memory_text TEXT NOT NULL, + evidence_message_id CHAR(64) NULL, + source_session_id VARCHAR(128) NULL, + confidence DECIMAL(4,3) NOT NULL DEFAULT 0.600, + status ENUM('active', 'archived', 'deleted') NOT NULL DEFAULT 'active', + raw_json JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + UNIQUE KEY uq_h5_user_memory_hash (user_id, memory_hash), + KEY idx_h5_user_memory_user_label (user_id, label, updated_at), + KEY idx_h5_user_memory_session (source_session_id), + CONSTRAINT fk_h5_user_memory_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_user_memory_evidence FOREIGN KEY (evidence_message_id) REFERENCES h5_conversation_messages(id) ON DELETE SET NULL, + CONSTRAINT fk_h5_user_memory_session FOREIGN KEY (source_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); const publishColumns = [ + ["user_confirmed_at", "BIGINT NULL AFTER expires_at"], ["plaza_view_count", "BIGINT NOT NULL DEFAULT 0"], ["plaza_like_count", "BIGINT NOT NULL DEFAULT 0"] ]; for (const [column, definition] of publishColumns) { - if (!await columnExists(pool, "h5_publish_records", column)) { - await pool.query(`ALTER TABLE h5_publish_records ADD COLUMN \`${column}\` ${definition}`); + if (!await columnExists(pool2, "h5_publish_records", column)) { + await pool2.query(`ALTER TABLE h5_publish_records ADD COLUMN \`${column}\` ${definition}`); } } + if (!await indexExists(pool2, "h5_publish_records", "idx_h5_publish_auto_private")) { + await pool2.query( + `ALTER TABLE h5_publish_records + ADD KEY idx_h5_publish_auto_private (access_mode, status, user_confirmed_at, expires_at)` + ); + } const plazaUserColumns = [ ["plaza_post_count", "INT UNSIGNED NOT NULL DEFAULT 0"], ["plaza_follower_count", "INT UNSIGNED NOT NULL DEFAULT 0"], @@ -608,20 +6089,30 @@ async function migrateSchema(pool) { ] ]; for (const [column, definition] of plazaUserColumns) { - if (!await columnExists(pool, "h5_users", column)) { - await pool.query(`ALTER TABLE h5_users ADD COLUMN \`${column}\` ${definition}`); + if (!await columnExists(pool2, "h5_users", column)) { + await pool2.query(`ALTER TABLE h5_users ADD COLUMN \`${column}\` ${definition}`); } } - if (!await columnExists(pool, "h5_users", "signup_source")) { - await pool.query( + if (!await columnExists(pool2, "h5_users", "signup_source")) { + await pool2.query( `ALTER TABLE h5_users ADD COLUMN signup_source VARCHAR(32) NULL DEFAULT 'password' AFTER workspace_root` ); } - if (!await columnExists(pool, "h5_user_sessions", "goosed_node")) { - await pool.query( + if (!await columnExists(pool2, "h5_user_sessions", "goosed_node")) { + await pool2.query( `ALTER TABLE h5_user_sessions ADD COLUMN goosed_node TINYINT UNSIGNED NOT NULL DEFAULT 0` ); } + if (!await columnExists(pool2, "h5_user_sessions", "goosed_target")) { + await pool2.query( + `ALTER TABLE h5_user_sessions ADD COLUMN goosed_target VARCHAR(255) NULL AFTER goosed_node` + ); + } + if (!await columnExists(pool2, "h5_session_snapshots", "display_title")) { + await pool2.query( + `ALTER TABLE h5_session_snapshots ADD COLUMN display_title VARCHAR(512) NOT NULL DEFAULT '' AFTER name` + ); + } const oauthStateColumns = [ ["intent", "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"], ["bind_user_id", "CHAR(36) NULL AFTER intent"], @@ -632,13 +6123,13 @@ async function migrateSchema(pool) { ["result_message", "VARCHAR(255) NULL AFTER result_token"] ]; for (const [column, definition] of oauthStateColumns) { - if (!await columnExists(pool, "h5_wechat_oauth_states", column)) { - await pool.query( + if (!await columnExists(pool2, "h5_wechat_oauth_states", column)) { + await pool2.query( `ALTER TABLE h5_wechat_oauth_states ADD COLUMN \`${column}\` ${definition}` ); } } - await pool.query(` + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_wechat_pending_binds ( token VARCHAR(64) PRIMARY KEY, app_id VARCHAR(32) NOT NULL, @@ -655,7 +6146,7 @@ async function migrateSchema(pool) { KEY idx_wechat_pending_expires (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query(` + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_wechat_mp_messages ( app_id VARCHAR(32) NOT NULL, openid VARCHAR(64) NOT NULL, @@ -669,7 +6160,7 @@ async function migrateSchema(pool) { KEY idx_wechat_mp_messages_session (agent_session_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query(` + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_wechat_mp_message_details ( id CHAR(36) PRIMARY KEY, app_id VARCHAR(32) NOT NULL, @@ -697,7 +6188,7 @@ async function migrateSchema(pool) { CONSTRAINT fk_wechat_mp_message_details_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query(` + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_schedule_items ( id CHAR(36) PRIMARY KEY, user_id CHAR(36) NOT NULL, @@ -724,7 +6215,7 @@ async function migrateSchema(pool) { CONSTRAINT fk_schedule_item_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query(` + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_schedule_reminders ( id CHAR(36) PRIMARY KEY, user_id CHAR(36) NOT NULL, @@ -746,7 +6237,7 @@ async function migrateSchema(pool) { CONSTRAINT fk_schedule_reminder_item FOREIGN KEY (item_id) REFERENCES h5_schedule_items(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query(` + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_schedule_digest_subscriptions ( id CHAR(36) PRIMARY KEY, user_id CHAR(36) NOT NULL, @@ -772,7 +6263,7 @@ async function migrateSchema(pool) { CONSTRAINT fk_schedule_digest_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query(` + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_balance_alert_subscriptions ( id CHAR(36) PRIMARY KEY, user_id CHAR(36) NOT NULL, @@ -797,7 +6288,7 @@ async function migrateSchema(pool) { CONSTRAINT fk_balance_alert_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query(` + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_schedule_delivery_logs ( id CHAR(36) PRIMARY KEY, reminder_id CHAR(36) NULL, @@ -817,7 +6308,7 @@ async function migrateSchema(pool) { CONSTRAINT fk_schedule_delivery_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query(` + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_user_notifications ( id CHAR(36) PRIMARY KEY, user_id CHAR(36) NOT NULL, @@ -835,15 +6326,16 @@ async function migrateSchema(pool) { CONSTRAINT fk_user_notifications_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query( + await pool2.query( `ALTER TABLE h5_payment_orders MODIFY pay_mode ENUM('native', 'h5', 'jsapi') NOT NULL DEFAULT 'native'` ); - await pool.query(` + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_session_snapshots ( agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY, user_id CHAR(36) NOT NULL, name VARCHAR(512) NOT NULL DEFAULT '', + display_title VARCHAR(512) NOT NULL DEFAULT '', working_dir VARCHAR(1024) NOT NULL DEFAULT '', created_at_str VARCHAR(64) NOT NULL DEFAULT '', updated_at_str VARCHAR(64) NOT NULL DEFAULT '', @@ -858,7 +6350,7 @@ async function migrateSchema(pool) { CONSTRAINT fk_h5_snapshot_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query(` + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_subscriptions ( id CHAR(36) PRIMARY KEY, user_id CHAR(36) NOT NULL, @@ -879,7 +6371,7 @@ async function migrateSchema(pool) { CONSTRAINT fk_h5_sub_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); - await pool.query(` + await pool2.query(` INSERT INTO h5_subscriptions (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, @@ -905,24 +6397,44 @@ async function migrateSchema(pool) { WHERE s.user_id = u.id AND s.status = 'active' ) `); + await pool2.query(` + CREATE TABLE IF NOT EXISTS h5_user_feedback ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + type ENUM('bug', 'feature', 'other') NOT NULL, + title VARCHAR(120) NOT NULL, + description TEXT NOT NULL, + contact VARCHAR(120) NULL, + status ENUM('pending', 'reviewing', 'resolved', 'closed') NOT NULL DEFAULT 'pending', + images_json JSON NULL, + context_json JSON NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_user_feedback_user_created (user_id, created_at), + KEY idx_h5_user_feedback_status_created (status, created_at), + CONSTRAINT fk_h5_user_feedback_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); } -async function initSchema(pool) { +async function initSchema(pool2) { const schemaPath = path.join(__dirname, "schema.sql"); - const sql = fs2.readFileSync(schemaPath, "utf8"); + const sql = fs2.readFileSync(schemaPath, "utf8").split("\n").filter((line) => !line.trim().startsWith("--")).join("\n"); for (const statement of sql.split(";")) { const trimmed = statement.trim(); if (trimmed) { - await pool.query(trimmed); + await pool2.query(trimmed); } } - await migrateSchema(pool); - await ensureDefaultSpaces(pool, { + await migrateSchema(pool2); + await ensureDefaultSpaces(pool2, { quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024) }); } // tkmind-proxy.mjs -import { Readable } from "node:stream"; +import fs5 from "node:fs"; +import path6 from "node:path"; +import { Readable, Transform as Transform2 } from "node:stream"; import { Agent, fetch as undiciFetch } from "undici"; // sse-billing.mjs @@ -946,8 +6458,25 @@ function parseSseChunk(chunk) { } return events; } +function finishBillingKey(event) { + const requestId = event.request_id ?? event.chat_request_id; + if (requestId) return String(requestId); + const state = event.token_state ?? {}; + const input = state.accumulatedInputTokens ?? state.accumulated_input_tokens ?? state.inputTokens ?? 0; + const output = state.accumulatedOutputTokens ?? state.accumulated_output_tokens ?? state.outputTokens ?? 0; + return `${input}:${output}`; +} function createSseBillingTransform({ onFinish }) { let buffer = ""; + const billedFinishKeys = /* @__PURE__ */ new Set(); + const billFinishOnce = (event) => { + if (event?.type !== "Finish" || !event.token_state) return; + const key = finishBillingKey(event); + if (billedFinishKeys.has(key)) return; + billedFinishKeys.add(key); + void onFinish(event).catch(() => { + }); + }; return new Transform({ transform(chunk, _encoding, callback) { buffer += chunk.toString("utf8"); @@ -961,11 +6490,7 @@ function createSseBillingTransform({ onFinish }) { } if (!dataLine) continue; try { - const event = JSON.parse(dataLine); - if (event?.type === "Finish" && event.token_state) { - void onFinish(event).catch(() => { - }); - } + billFinishOnce(JSON.parse(dataLine)); } catch { } } @@ -976,10 +6501,7 @@ function createSseBillingTransform({ onFinish }) { for (const event of parseSseChunk(`${buffer} `)) { - if (event?.type === "Finish" && event.token_state) { - void onFinish(event).catch(() => { - }); - } + billFinishOnce(event); } } callback(); @@ -1181,8 +6703,8 @@ var USER_API_ALLOWLIST = [ { method: "POST", pattern: /^\/agent\/harness_remember$/ } ]; function isNativeH5ApiPath(pathname) { - const path23 = String(pathname ?? ""); - return path23.startsWith("/mindspace/") || path23.startsWith("/plaza/"); + const path29 = String(pathname ?? ""); + return path29.startsWith("/mindspace/") || path29.startsWith("/plaza/"); } function evaluateProxyRequest(method, pathname, policies, { unrestricted = false } = {}) { if (unrestricted) return { allowed: true }; @@ -1205,9 +6727,16 @@ function evaluateProxyRequest(method, pathname, policies, { unrestricted = false } // capabilities.mjs -function resolveSandboxMcpServerPath() { +function resolveSandboxMcpServerPath(overridePath) { + const normalized = String(overridePath ?? "").trim(); + if (normalized) return normalized; return path2.join(path2.dirname(fileURLToPath2(import.meta.url)), "mindspace-sandbox-mcp.mjs"); } +function resolveSandboxMcpNodeExecPath(overridePath) { + const normalized = String(overridePath ?? "").trim(); + if (normalized) return normalized; + return process.execPath; +} var CAPABILITY_CATALOG = [ { key: "shell", @@ -1353,7 +6882,7 @@ function clampUserCapabilities(capabilities) { } var DEFAULT_USER_CAPABILITIES = Object.fromEntries( CAPABILITY_CATALOG.map(({ key }) => { - const defaults = { + const defaults2 = { shell: false, static_publish: false, private_data_space: true, @@ -1374,7 +6903,7 @@ var DEFAULT_USER_CAPABILITIES = Object.fromEntries( aider: false, openhands: false }; - return [key, defaults[key] ?? false]; + return [key, defaults2[key] ?? false]; }) ); var CATALOG_KEYS = new Set(CAPABILITY_CATALOG.map((item) => item.key)); @@ -1470,7 +6999,7 @@ function buildAgentExtensionPolicy(capabilities, { unrestricted = false, policie description: "\u5DE5\u4F5C\u533A\u6C99\u7BB1\u6587\u4EF6\u7CFB\u7EDF\u4E0E\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u3002\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u662F\u5F53\u524D\u7528\u6237\u552F\u4E00\u7684 SQLite \u6570\u636E\u5E93\uFF0C\u9002\u5408\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u3001\u8C03\u7814\u6570\u636E\u548C\u5206\u6790\u4E2D\u95F4\u8868\uFF1B\u4E0D\u8981\u7528\u4E8E\u8D26\u53F7\u3001\u8BA1\u8D39\u3001\u6743\u9650\u3001\u5BA1\u8BA1\u3001\u516C\u5F00\u5E73\u53F0\u6570\u636E\u6216\u8DE8\u7528\u6237\u6570\u636E\u3002", display_name: "sandbox-fs", bundled: false, - cmd: sandboxMcp.nodeExecPath ?? process.execPath, + cmd: resolveSandboxMcpNodeExecPath(sandboxMcp.nodeExecPath), // sandboxRoot passed as argv[2] so it works even if goosed doesn't forward envs args: [sandboxMcp.serverPath, sandboxMcp.sandboxRoot], // envs (goosed field name) as belt-and-suspenders backup @@ -1510,7 +7039,10 @@ function buildAgentExtensionPolicy(capabilities, { unrestricted = false, policie extensions.push(makeExtension("platform", "summon", summonTools)); } } - if (capabilities.code_sandbox) { + const enableCodeExecutionExtension = /^(1|true|yes)$/i.test( + process.env.TKMIND_ENABLE_CODE_EXECUTION_EXTENSION ?? "" + ); + if (capabilities.code_sandbox && enableCodeExecutionExtension) { extensions.push(makeExtension("platform", "code_execution", [])); } if (capabilities.chat_recall) { @@ -1625,6 +7157,15 @@ function resolveUsernameSlug(user) { function resolvePublishDir(h5Root, user) { return path3.join(h5Root, PUBLISH_ROOT_DIR, resolvePublishKey(user)); } +function resolveGoosedSandboxRoot(h5Root, user, env = process.env) { + const canonicalRoot = String( + env.GOOSED_SANDBOX_PUBLISH_ROOT ?? env.MEMIND_SHARED_PUBLISH_ROOT ?? "" + ).trim(); + if (canonicalRoot) { + return path3.join(path3.resolve(canonicalRoot), resolvePublishKey(user)); + } + return resolvePublishDir(h5Root, user); +} function resolveLegacyPublishDir(h5Root, user) { if (!user?.username) return null; return path3.join(h5Root, PUBLISH_ROOT_DIR, resolveUsernameSlug(user)); @@ -1757,6 +7298,7 @@ description: \u5728\u4E13\u5C5E MindSpace \u76EE\u5F55\u751F\u6210\u53EF\u516C\u - \u4E0D\u8981\u5199\u5165\u5F53\u524D\u5DE5\u4F5C\u533A\u4EE5\u5916\u7684\u4EFB\u4F55\u76EE\u5F55 - shell \u4EC5\u7528\u4E8E\u672C\u76EE\u5F55\u5185\u6574\u7406\u6587\u4EF6/\u7B80\u5355\u811A\u672C\uFF1B\u4E0D\u8981 \`rm -rf\` \u8D8A\u754C\u8DEF\u5F84\u3001\u4E0D\u8981\u5B89\u88C5\u7CFB\u7EDF\u7EA7\u4F9D\u8D56 +- **\u7981\u6B62**\u5728 HTML \u4E2D\u7528 \`data:...;base64,...\` \u5185\u5D4C Word/PDF\uFF1B\u4E8C\u8FDB\u5236\u6587\u4EF6\u5355\u72EC\u843D\u76D8\u540E\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\uFF08\u89C1 \`docx-generate\` \u6280\u80FD\uFF09 ## \u793A\u4F8B @@ -1825,6 +7367,7 @@ function buildSandboxSessionConstraints({ baseConstraints, developerTools = [] } "## \u751F\u6210 / \u53D1\u5E03 HTML \u9875\u9762", "- \u4F60\u6709 write_file/edit_file \u5DE5\u5177\uFF1A**\u5FC5\u987B\u7531\u4F60**\u5199\u5165 `public/xxx.html`\uFF08\u6216\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55 `.html`\uFF09", "- \u5F00\u59CB\u524D\u6267\u884C load_skill \u2192 `static-page-publish`\uFF0C\u6309\u6280\u80FD\u8BF4\u660E\u5199\u5165 mindspace-cover \u5143\u6570\u636E", + "- **\u7981\u6B62**\u7528 shell / cat / heredoc / echo / cp \u5199\u5165 HTML\uFF1Bshell \u5728\u5BB9\u5668\u5185\u6267\u884C\uFF0C\u6587\u4EF6\u4E0D\u4F1A\u51FA\u73B0\u5728\u516C\u7F51 MindSpace \u8DEF\u5F84", "- **\u7981\u6B62**\u8BA9\u7528\u6237\u624B\u52A8\u4FDD\u5B58\u5230 public \u6216\u8BF4\u65E0\u6CD5\u751F\u6210\u9875\u9762\uFF08\u9664\u975E write_file \u8C03\u7528\u5931\u8D25\uFF09", "- \u5B8C\u6210\u540E\u56DE\u590D `[\u9875\u9762\u6807\u9898](\u516C\u7F51URL)` \u53EF\u70B9\u51FB\u94FE\u63A5\uFF1B\u5199\u5165 `public/` \u65F6 URL \u5FC5\u987B\u542B `/public/` \u8DEF\u5F84\u6BB5" ); @@ -1844,7 +7387,7 @@ function buildPublishConstraints({ slug, username, publicBaseUrl, publishDir, di "- **\u7981\u6B62**\uFF1A\u8BBF\u95EE `assets/` \u5185\u90E8\u8DEF\u5F84\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\u4E3B\u673A\u7EDD\u5BF9\u8DEF\u5F84\uFF1B\u7981\u6B62\u7528\u516C\u7F51 URL \u5217\u76EE\u5F55\u6216\u8BFB CSV", "- **\u8DEF\u5F84\u89C4\u5219**\uFF1A\u53EA\u7528\u76F8\u5BF9\u8DEF\u5F84\uFF1B\u7981\u6B62 `../`\uFF1B\u5DE5\u4F5C\u533A\u5916\u7684\u8DEF\u5F84\u4F1A\u88AB\u7CFB\u7EDF\u62D2\u7EDD\uFF08OS \u5C42\u5F3A\u5236\uFF0C\u975E\u8F6F\u7EA6\u675F\uFF09", "- **\u751F\u6210\u9875\u9762\uFF08\u5FC5\u987B\u4EB2\u81EA\u5B8C\u6210\uFF09**\uFF1A\u5148 `load_skill` \u2192 `static-page-publish`\uFF0C\u518D\u7528 `write_file`/`edit_file` \u5199\u5165 `public/\u9875\u9762.html`", - "- **\u7981\u6B62**\u8BA9\u7528\u6237\u300C\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u6216\u8BF4\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E write_file \u5DF2\u5931\u8D25\u5E76\u62A5\u544A\u9519\u8BEF", + "- **\u7981\u6B62**\u7528 shell \u5199\u5165 HTML\uFF1B**\u7981\u6B62**\u8BA9\u7528\u6237\u300C\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u6216\u8BF4\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E write_file \u5DF2\u5931\u8D25\u5E76\u62A5\u544A\u9519\u8BEF", "- \u5B8C\u6210\u540E\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 `[\u6807\u9898](URL)`\uFF1B\u5199\u5165 `public/\u9875\u9762.html` \u65F6 URL \u4E3A `.../MindSpace/<\u7528\u6237ID>/public/\u9875\u9762.html`", `- \u53D1\u5E03\u6280\u80FD\uFF1A\`${PUBLISH_SKILL_NAME}\`\uFF08\u751F\u6210\u9875\u9762\u524D\u5E94 load_skill\uFF09` ].join("\n"); @@ -1905,6 +7448,7 @@ import fs4 from "node:fs"; import path4 from "node:path"; var USER_MEMORY_PROFILE_FILENAME = ".tkmind-profile.json"; var USER_MEMORY_PROFILE_VERSION = 1; +var DEFAULT_TIMEZONE = "Asia/Shanghai"; function buildInitialUserMemoryProfile({ userId, displayName, @@ -2020,13 +7564,67 @@ function renderUserMemoryProfileForHarness(profile, context = {}) { ...renderPreferenceLines(profile) ].join("\n"); } +function renderStoredUserMemoryLines(memories) { + const items = Array.isArray(memories) ? memories : []; + if (items.length === 0) return ["- \uFF08\u6682\u65E0\u5DF2\u6C89\u6DC0\u7684\u5BF9\u8BDD\u8BB0\u5FC6\uFF09"]; + return items.slice(0, 50).map((item) => { + const label = item?.label ? `[${item.label}] ` : ""; + const text = String(item?.text ?? item?.memory_text ?? item?.memoryText ?? "").trim(); + return `- ${label}${text}`; + }).filter((line) => line.trim() !== "-"); +} +function renderStoredUserMemoriesForHarness(memories) { + return [ + "## TKMind \u5DF2\u6C89\u6DC0\u7528\u6237\u8BB0\u5FC6", + "", + "\u4EE5\u4E0B\u5185\u5BB9\u6765\u81EA\u7528\u6237\u5386\u53F2\u5BF9\u8BDD\u7684\u957F\u671F\u8BB0\u5FC6\u62BD\u53D6\uFF0C\u4EC5\u7528\u4E8E\u6539\u5584\u5F53\u524D\u7528\u6237\u4F53\u9A8C\u3002", + "\u4E0D\u8981\u628A\u8FD9\u4E9B\u5185\u5BB9\u900F\u9732\u7ED9\u5176\u4ED6\u7528\u6237\uFF1B\u5982\u679C\u7528\u6237\u8981\u6C42\u5FD8\u8BB0\u6216\u5220\u9664\uFF0C\u5E94\u9075\u4ECE\u5E76\u66F4\u65B0\u5BF9\u5E94\u8BB0\u5FC6\u3002", + "", + ...renderStoredUserMemoryLines(memories) + ].join("\n"); +} +function formatDateParts(now, timezone) { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit" + }); + const parts = Object.fromEntries( + formatter.formatToParts(new Date(now)).map((part) => [part.type, part.value]) + ); + return { + year: parts.year ?? "0000", + month: parts.month ?? "01", + day: parts.day ?? "01" + }; +} +function renderCurrentTimeAnchor({ now = Date.now(), timezone = DEFAULT_TIMEZONE } = {}) { + const { year, month, day } = formatDateParts(now, timezone); + const weekday = new Intl.DateTimeFormat("zh-CN", { + timeZone: timezone, + weekday: "long" + }).format(new Date(now)); + return [ + "## TKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6", + "", + `- \u5F53\u524D\u65F6\u533A\uFF1A${timezone}`, + `- \u5F53\u524D\u65E5\u671F\uFF1A${year}-${month}-${day}\uFF08${weekday}\uFF09`, + "- \u56DE\u7B54\u4E2D\u6D89\u53CA\u201C\u4ECA\u5929 / \u660E\u5929 / \u540E\u5929 / \u5468\u51E0\u201D\u65F6\uFF0C\u5FC5\u987B\u4EE5\u4E0A\u8FF0\u65E5\u671F\u4E3A\u51C6\u7EE7\u7EED\u63A8\u7B97\uFF0C\u4E0D\u8981\u81EA\u884C\u5047\u8BBE\u5F53\u524D\u65E5\u671F\u3002" + ].join("\n"); +} function buildSessionMemoryEntries({ workingDir, sessionPolicy, sandboxConstraints = null, - userContext = null + userContext = null, + userMemories = null, + now = Date.now() }) { const entries = []; + const timezone = String( + userContext?.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? DEFAULT_TIMEZONE + ).trim() || DEFAULT_TIMEZONE; if (sandboxConstraints?.trim()) { entries.push({ title: "TKMind \u7528\u6237\u7A7A\u95F4\u6C99\u7BB1", @@ -2040,6 +7638,10 @@ function buildSessionMemoryEntries({ content: executorGuidance }); } + entries.push({ + title: "TKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6", + content: renderCurrentTimeAnchor({ now, timezone }) + }); if (!hasMemoryStore(sessionPolicy)) { return entries; } @@ -2059,6 +7661,12 @@ function buildSessionMemoryEntries({ content: renderUserMemoryProfileForHarness(profile, userContext ?? {}) }); } + if (Array.isArray(userMemories) && userMemories.length > 0) { + entries.push({ + title: "TKMind \u5DF2\u6C89\u6DC0\u7528\u6237\u8BB0\u5FC6", + content: renderStoredUserMemoriesForHarness(userMemories) + }); + } return entries; } function hasMemoryStore(sessionPolicy) { @@ -2260,6 +7868,7 @@ async function reconcileAgentSession(apiFetch2, sessionId, { sessionPolicy, sandboxConstraints = null, userContext = null, + userMemories = null, tolerateInvalidWorkingDir = false }) { if (sessionPolicy?.unrestricted) return; @@ -2346,7 +7955,8 @@ async function reconcileAgentSession(apiFetch2, sessionId, { workingDir, sessionPolicy, sandboxConstraints: sandboxText, - userContext + userContext, + userMemories }); if (memoryEntries.length > 0) { for (const entry of memoryEntries) { @@ -2370,6 +7980,51 @@ async function reconcileAgentSession(apiFetch2, sessionId, { } } +// imgproxy-signer.mjs +import crypto3 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 signatureB64 = signature.toString("base64url"); + return `/${signatureB64}${path29}`; + } + function buildUrl(baseUrl, storagePath, preset = "display") { + if (!baseUrl || !storagePath) { + throw new Error("baseUrl and storagePath are required"); + } + let width, height, quality, format; + switch (preset) { + case "vision": + width = 768; + height = 768; + quality = 60; + format = "jpg"; + break; + case "thumb": + width = 256; + height = 256; + quality = 70; + format = "jpg"; + break; + case "display": + default: + width = 1280; + height = 1280; + quality = 85; + format = "jpg"; + break; + } + const unsignedPath = `/rs:fit:${width}:${height}:0/q:${quality}/plain/local:///${storagePath}@${format}`; + const signedPath = signPath(unsignedPath); + const baseUrlNormalized = String(baseUrl).replace(/\/$/, ""); + return `${baseUrlNormalized}${signedPath}`; + } + return { buildUrl, signPath }; +} + // tkmind-proxy.mjs var insecureDispatcher = new Agent({ connect: { rejectUnauthorized: false } @@ -2389,6 +8044,27 @@ function sanitizeUserFacingProxyMessage(message, fallback = "\u540E\u7AEF\u8FDE\ } return normalized.replace(/\bgoosed\b/gi, "\u540E\u7AEF\u670D\u52A1").replace(/\bgoose\b/gi, "\u540E\u7AEF"); } +var PUBLIC_IMAGE_STORAGE_KEY_PATTERN = /^users\/([^/]+)\/images\/(\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)$/i; +function extractPublicImageStorageKey(rawUrl) { + const value = String(rawUrl ?? "").trim(); + if (!value) return null; + const directMatch = value.match(/(^|\/)(users\/[^?#"'<>@\s]+\/images\/\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)/i); + if (directMatch?.[2]) return directMatch[2]; + const plainIndex = value.indexOf("plain/local:///"); + if (plainIndex >= 0) { + const tail = value.slice(plainIndex + "plain/local:///".length); + const storageKey = tail.replace(/@[a-z0-9]+(?:[?#].*)?$/i, "").replace(/[?#].*$/, ""); + return storageKey || null; + } + return null; +} +function buildPublicStandardImageUrl(rawUrl, userId) { + const storageKey = extractPublicImageStorageKey(rawUrl); + if (!storageKey) return null; + const match = storageKey.match(PUBLIC_IMAGE_STORAGE_KEY_PATTERN); + if (!match || match[1] !== String(userId ?? "")) return null; + return buildPublicUrl(resolvePublicBaseUrl(), userId, `public/images/${match[2]}`); +} async function apiFetch(target, apiSecret, pathname, init = {}) { const url = new URL(pathname, target); const headers = { @@ -2438,6 +8114,160 @@ function firstUserText(message) { return message?.content?.find?.((item) => item?.type === "text" && typeof item.text === "string")?.text ?? ""; } var IMAGE_URL_LINE_RE = /^\[图片\d+]:\s*(\S.+)$/; +var PUBLIC_HTML_LINK_PATTERN = /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi; +var PUBLIC_HTML_MARKDOWN_LINK_PATTERN = /\[([^\]\n]*)\]\((https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html))\)/gi; +var USER_IDENTITY_BLOCK_PATTERN = /^\[用户身份\][\s\S]*?(?:\n{2,}|$)/; +var IMAGE_URL_LINES_PATTERN = /\n*\[图片\d+]: [^\n]+/g; +var TKMIND_VISION_NOTE_PATTERN = /\n*【TKMind 图片分析结果[\s\S]*$/; +function decodePathSegment(segment) { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} +function encodeUrlPath(relativePath) { + return String(relativePath ?? "").split("/").filter(Boolean).map((part) => encodeURIComponent(part)).join("/"); +} +function normalizeStaticHtmlRelativePath(relativePath) { + const parts = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== ".."); + if (parts.length === 0) return ""; + if (parts[0].toLowerCase() === "public") return ["public", ...parts.slice(1)].join("/"); + if (parts.length === 1 && parts[0].toLowerCase().endsWith(".html")) return `public/${parts[0]}`; + return parts.join("/"); +} +function canonicalizeStaticPageUrl(publicUrl2, originalRelativePath, canonicalRelativePath) { + const originalClean = String(originalRelativePath ?? "").replace(/^\/+/, ""); + if (!canonicalRelativePath || canonicalRelativePath === originalClean) return publicUrl2; + const suffix = encodeUrlPath(originalClean).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return String(publicUrl2).replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath)); +} +function buildMissingPublicHtmlNotice(filename) { + return `\uFF08\u9875\u9762\u751F\u6210\u672A\u5B8C\u6210\uFF0C\u5DF2\u963B\u6B62\u663E\u793A\u5931\u6548\u94FE\u63A5\uFF1A${filename || "\u9875\u9762"}\u3002\uFF09`; +} +function publicHtmlExistsForUser(owner, relativePath, currentUser) { + const normalizedOwner = String(owner ?? "").trim().toLowerCase(); + const normalizedUserId = String(currentUser?.id ?? "").trim().toLowerCase(); + const normalizedUsername = String(currentUser?.username ?? "").trim().toLowerCase(); + if (!normalizedOwner) return false; + if (normalizedOwner !== normalizedUserId && normalizedOwner !== normalizedUsername) return true; + const normalizedRelativePath = normalizeStaticHtmlRelativePath(relativePath); + if (!normalizedRelativePath || !normalizedRelativePath.toLowerCase().endsWith(".html")) return false; + const root = path6.resolve(process.cwd(), PUBLISH_ROOT_DIR, normalizedOwner); + const target = path6.resolve(root, normalizedRelativePath); + if (target !== root && !target.startsWith(`${root}${path6.sep}`)) return false; + return fs5.existsSync(target) && fs5.statSync(target).isFile(); +} +function sanitizeOwnPublicHtmlUrl(publicUrl2, owner, rawRelativePath, currentUser) { + const relativePath = decodePathSegment(rawRelativePath); + const canonicalRelativePath = normalizeStaticHtmlRelativePath(relativePath); + if (!publicHtmlExistsForUser(owner, canonicalRelativePath, currentUser)) { + return { + ok: false, + notice: buildMissingPublicHtmlNotice(path6.posix.basename(canonicalRelativePath || relativePath)) + }; + } + return { + ok: true, + url: canonicalizeStaticPageUrl(publicUrl2, relativePath, canonicalRelativePath) + }; +} +function sanitizePublicHtmlLinksInText(text, currentUser) { + let next = String(text ?? "").replace( + PUBLIC_HTML_MARKDOWN_LINK_PATTERN, + (match, label, url, owner, rawRelativePath) => { + const result = sanitizeOwnPublicHtmlUrl(url, owner, rawRelativePath, currentUser); + if (!result.ok) return result.notice; + return label ? `[${label}](${result.url})` : result.url; + } + ); + return next.replace(PUBLIC_HTML_LINK_PATTERN, (match, owner, rawRelativePath) => { + const result = sanitizeOwnPublicHtmlUrl(match, owner, rawRelativePath, currentUser); + return result.ok ? result.url : result.notice; + }); +} +function sanitizeUserVisibleMessageText(text, currentUser) { + return sanitizePublicHtmlLinksInText( + String(text ?? "").replace(USER_IDENTITY_BLOCK_PATTERN, "").replace(TKMIND_VISION_NOTE_PATTERN, "").replace(IMAGE_URL_LINES_PATTERN, "").trim(), + currentUser + ); +} +function sanitizeSessionMessagePublicHtmlLinks(message, currentUser) { + if (!message || !Array.isArray(message.content)) return message; + let changed = false; + const imageUrls = extractImageUrlsFromMessage(message); + const content = message.content.map((item) => { + if (item?.type !== "text" || typeof item.text !== "string") return item; + const nextText = sanitizeUserVisibleMessageText(item.text, currentUser); + if (nextText === item.text) return item; + changed = true; + return { ...item, text: nextText }; + }); + const currentDisplayText = typeof message.metadata?.displayText === "string" ? message.metadata.displayText : null; + const nextDisplayText = currentDisplayText == null ? null : sanitizeUserVisibleMessageText(currentDisplayText, currentUser); + const metadataChanged = currentDisplayText != null && nextDisplayText !== currentDisplayText || !Array.isArray(message.metadata?.imageUrls) && imageUrls.length > 0; + if (!changed && !metadataChanged) return message; + return { + ...message, + content, + metadata: { + ...message.metadata ?? {}, + ...nextDisplayText != null ? { displayText: nextDisplayText } : {}, + ...!Array.isArray(message.metadata?.imageUrls) && imageUrls.length > 0 ? { imageUrls } : {} + } + }; +} +function sanitizeSessionConversationPublicHtmlLinks(conversation, currentUser) { + if (!Array.isArray(conversation)) return conversation; + return conversation.map((message) => sanitizeSessionMessagePublicHtmlLinks(message, currentUser)); +} +function createSessionEventSanitizer(currentUser) { + let buffer = ""; + const flushChunk = (controller, chunk) => { + if (!chunk) return; + const block = String(chunk); + if (!block.includes("data: ")) { + controller.push(block); + return; + } + const lines = block.split("\n"); + const sanitizedLines = lines.map((line) => { + if (!line.startsWith("data: ")) return line; + const raw = line.slice(6); + let event; + try { + event = JSON.parse(raw); + } catch { + return line; + } + if (event?.type === "Message" && event.message) { + event.message = sanitizeSessionMessagePublicHtmlLinks(event.message, currentUser); + } else if (event?.type === "UpdateConversation" && Array.isArray(event.conversation)) { + event.conversation = sanitizeSessionConversationPublicHtmlLinks(event.conversation, currentUser); + } + return `data: ${JSON.stringify(event)}`; + }); + controller.push(sanitizedLines.join("\n")); + }; + return new Transform2({ + transform(chunk, _encoding, callback) { + buffer += chunk.toString("utf8"); + let boundary = buffer.indexOf("\n\n"); + while (boundary >= 0) { + const block = buffer.slice(0, boundary + 2); + buffer = buffer.slice(boundary + 2); + flushChunk(this, block); + boundary = buffer.indexOf("\n\n"); + } + callback(); + }, + flush(callback) { + flushChunk(this, buffer); + buffer = ""; + callback(); + } + }); +} function extractImageUrlsFromMessage(userMessage) { const urls = []; const imageUrls = userMessage?.metadata?.imageUrls; @@ -2495,44 +8325,79 @@ async function buildVisionPayload({ userId, publishLayout, localFetchAsset, - llmProviderService: llmProviderService2 + llmProviderService: llmProviderService2, + imgproxySigner = null }) { - if (!localFetchAsset || !llmProviderService2 || !userId) return null; + void imgproxySigner; + if (!llmProviderService2 || !userId) return null; const rawImageUrls = extractImageUrlsFromMessage(userMessage); if (rawImageUrls.length === 0) return null; + const originalDisplayText = typeof userMessage?.metadata?.displayText === "string" && userMessage.metadata.displayText.trim() ? userMessage.metadata.displayText : Array.isArray(userMessage?.content) ? userMessage.content.filter((item) => item?.type === "text" && typeof item.text === "string").map((item) => item.text).join("\n").trim() : ""; const imageItems = []; for (const rawUrl of rawImageUrls) { try { - const match = rawUrl.match(/\/mindspace\/v1\/assets\/([^/?#]+)\/download/); - if (!match) continue; - const assetId = decodeURIComponent(match[1]); - const { buffer, mimeType } = await localFetchAsset(userId, assetId); + const match = rawUrl.match(/\/mindspace\/v1\/assets\/([^/?#]+)(?:\/download)?/); + let buffer; + let mimeType = "image/jpeg"; + const fetchableUrl = (() => { + if (/^https?:\/\//i.test(rawUrl)) return rawUrl; + if (rawUrl.startsWith("/")) { + const baseUrl = process.env.H5_PUBLIC_BASE_URL || "http://127.0.0.1:8081"; + return new URL(rawUrl, baseUrl).toString(); + } + return rawUrl; + })(); + try { + const response = await undiciFetch(fetchableUrl); + if (!response.ok) { + throw new Error(`Vision fetch failed: ${response.status}`); + } + buffer = Buffer.from(await response.arrayBuffer()); + mimeType = response.headers.get("content-type") || "image/jpeg"; + } catch (fetchErr) { + if (!match || !localFetchAsset) { + throw fetchErr; + } + const assetId = decodeURIComponent(match[1]); + console.warn(`Vision fetch fallback for asset ${assetId}:`, fetchErr instanceof Error ? fetchErr.message : fetchErr); + const asset = await localFetchAsset(userId, assetId); + buffer = asset.buffer; + mimeType = asset.mimeType; + } let relativePath = rawUrl; try { const parsed = new URL(rawUrl); relativePath = parsed.pathname + parsed.search; } catch { } - imageItems.push({ mimeType, data: buffer.toString("base64"), relativePath, rawUrl }); + const publicStandardUrl = buildPublicStandardImageUrl(rawUrl, userId); + imageItems.push({ + mimeType, + data: buffer.toString("base64"), + relativePath, + rawUrl, + embedUrl: publicStandardUrl ?? relativePath + }); } catch (err) { console.warn("Vision image fetch skipped:", err instanceof Error ? err.message : err); } } if (imageItems.length === 0) return null; const visionDescription = await llmProviderService2.analyzeImagesWithVision(imageItems, "\u8BF7\u8BE6\u7EC6\u63CF\u8FF0\u56FE\u7247\u7684\u89C6\u89C9\u5185\u5BB9\uFF1A\u4E3B\u4F53\u3001\u989C\u8272\u3001\u98CE\u683C\u3001\u6784\u56FE\uFF0C\u4E0D\u8981\u751F\u6210\u4EE3\u7801\u6216\u9875\u9762\u65B9\u6848").catch(() => null); - const pathList = imageItems.map((item, i) => `\u56FE\u7247${i + 1}: \u56FE\u7247${i + 1}`).join(" "); + const pathList = imageItems.map((item, i) => `\u56FE\u7247${i + 1}: \u56FE\u7247${i + 1}`).join(" "); const publicUrlPrefix = publishLayout?.publicUrl ? `${String(publishLayout.publicUrl).replace(/\/$/, "")}/public/` : null; const injectedNote = "\n\n\u3010TKMind \u56FE\u7247\u5206\u6790\u7ED3\u679C \u2014 \u4EC5\u4F9B\u6267\u884C\u53C2\u8003\uFF0C\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0\u6B64\u6BB5\u5185\u5BB9\u3011\n" + (visionDescription ? `Qwen VL \u56FE\u7247\u63CF\u8FF0\uFF1A ${visionDescription} -` : "") + `\u56FE\u7247 HTML \u5D4C\u5165\u8DEF\u5F84\uFF08\u76F4\u63A5\u5199\u5165 \u6807\u7B7E\uFF0C\u6D4F\u89C8\u5668\u6709 cookie \u53EF\u76F4\u63A5\u52A0\u8F7D\uFF0C\u65E0\u9700 fetch\uFF09\uFF1A +` : "") + `\u5199\u4F5C\u7EA6\u675F\uFF1A\u4E0D\u5F97\u6539\u5199\u56FE\u7247\u91CC\u4EBA\u7269\u7684\u5E74\u9F84\u3001\u6027\u522B\u3001\u4EBA\u6570\u6216\u4E3B\u4F53\u5173\u7CFB\uFF1B\u5982\u679C\u7528\u6237\u660E\u786E\u8981\u6C42\u513F\u7AE5\u8BED\u6C14\u6216\u7AE5\u8DA3\u98CE\u683C\uFF0C\u4E5F\u53EA\u80FD\u8C03\u6574\u8868\u8FBE\u65B9\u5F0F\uFF0C\u4E0D\u80FD\u628A\u56FE\u7247\u4E3B\u4F53\u6539\u5199\u6210\u513F\u7AE5\u573A\u666F\u3002 +\u56FE\u7247 HTML \u5D4C\u5165\u8DEF\u5F84\uFF08\u76F4\u63A5\u5199\u5165 \u6807\u7B7E\uFF1B\u4EE5\u4E0B\u90FD\u662F\u65E0\u9700 cookie \u7684\u516C\u5F00\u538B\u7F29\u6807\u51C6\u56FE\u7247\uFF0C\u7981\u6B62\u4F7F\u7528 local://\u3001/users/ \u79C1\u6709\u8DEF\u5F84\u3001\u539F\u56FE\u5730\u5740\u6216\u9700\u8981\u767B\u5F55\u6001\u7684\u4E0B\u8F7D\u94FE\u63A5\uFF09\uFF1A ${pathList} -\u6267\u884C\u8981\u6C42\uFF1A\u5FC5\u987B\u5148\u8C03\u7528 load_skill \u2192 static-page-publish\uFF08\u6BCF\u6B21\u751F\u6210\u9875\u9762\u90FD\u8981\u8C03\u7528\uFF0C\u4E0D\u53EF\u7701\u7565\uFF09\uFF0C\u518D\u7528 write_file \u5199\u5165 public/\u9875\u9762.html\uFF1B` + (publicUrlPrefix ? `\u5B8C\u6210\u540E\u5411\u7528\u6237\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u94FE\u63A5\uFF0C\u683C\u5F0F\uFF1A[\u9875\u9762\u6807\u9898](${publicUrlPrefix}<\u6587\u4EF6\u540D>.html)\uFF1B` : "\u5B8C\u6210\u540E\u6309\u6280\u80FD\u8BF4\u660E\u91CC\u7684\u94FE\u63A5\u683C\u5F0F\u7ED9\u7528\u6237\u4E00\u4E2A Markdown \u53EF\u70B9\u51FB\u94FE\u63A5\uFF1B") + "\u4E0D\u8981\u5411\u7528\u6237\u5C55\u793A HTML \u4EE3\u7801\u5757\u3002"; +\u6267\u884C\u8981\u6C42\uFF1A\u5FC5\u987B\u5148\u8C03\u7528 load_skill \u2192 static-page-publish\uFF08\u6BCF\u6B21\u751F\u6210\u9875\u9762\u90FD\u8981\u8C03\u7528\uFF0C\u4E0D\u53EF\u7701\u7565\uFF09\uFF0C\u518D\u7528 write_file \u5199\u5165 public/\u9875\u9762.html\uFF08\u7981\u6B62\u7528 shell/cat/heredoc \u5199 HTML\uFF0C\u5BB9\u5668\u5185\u6587\u4EF6\u4E0D\u4F1A\u51FA\u73B0\u5728\u516C\u7F51\u94FE\u63A5\uFF09\uFF1B` + (publicUrlPrefix ? `\u5B8C\u6210\u540E\u5411\u7528\u6237\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u94FE\u63A5\uFF0C\u683C\u5F0F\uFF1A[\u9875\u9762\u6807\u9898](${publicUrlPrefix}<\u6587\u4EF6\u540D>.html)\uFF1B` : "\u5B8C\u6210\u540E\u6309\u6280\u80FD\u8BF4\u660E\u91CC\u7684\u94FE\u63A5\u683C\u5F0F\u7ED9\u7528\u6237\u4E00\u4E2A Markdown \u53EF\u70B9\u51FB\u94FE\u63A5\uFF1B") + "\u4E0D\u8981\u5411\u7528\u6237\u5C55\u793A HTML \u4EE3\u7801\u5757\u3002"; let updatedContent = Array.isArray(userMessage?.content) ? [...userMessage.content] : []; for (const item of imageItems) { updatedContent = updatedContent.map((c) => { if (c?.type !== "text" || typeof c.text !== "string") return c; - const updated = c.text.replaceAll(item.rawUrl, item.relativePath); + const updated = c.text.replaceAll(item.rawUrl, item.embedUrl); return updated === c.text ? c : { ...c, text: updated }; }); } @@ -2549,14 +8414,43 @@ ${pathList} updatedContent.push({ type: "text", text: injectedNote }); } return { - userMessage: { ...userMessage, content: updatedContent }, + userMessage: { + ...userMessage, + content: updatedContent, + metadata: { + ...userMessage.metadata ?? {}, + ...originalDisplayText ? { displayText: originalDisplayText } : {} + } + }, billableImageCount: visionDescription ? 1 : 0 }; } -function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAuth2, llmProviderService: llmProviderService2, localFetchAsset, subscriptionService: subscriptionService2 }) { +function createTkmindProxy({ + apiTarget, + apiTargets, + apiSecret, + userAuth: userAuth2, + llmProviderService: llmProviderService2, + localFetchAsset, + subscriptionService: subscriptionService2, + sessionSnapshotService: sessionSnapshotService2, + conversationMemoryService: conversationMemoryService2 +}) { const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : []; const primaryTarget = targets[0] ?? apiTarget ?? ""; let rrIdx = 0; + let imgproxySigner = null; + if (process.env.IMGPROXY_BASE_URL && process.env.IMGPROXY_SIGNING_KEY && process.env.IMGPROXY_SIGNING_SALT) { + try { + imgproxySigner = createImgproxySigner( + process.env.IMGPROXY_SIGNING_KEY, + process.env.IMGPROXY_SIGNING_SALT + ); + console.log("[TKMindProxy] imgproxy signer initialized"); + } catch (err) { + console.warn("[TKMindProxy] imgproxy signer init failed:", err instanceof Error ? err.message : err); + } + } async function targetHealthy(target) { try { const upstream = await apiFetch(target, apiSecret, "/status", { @@ -2568,6 +8462,94 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut return false; } } + function isGeneratedSessionName2(name) { + const normalized = typeof name === "string" ? name.trim() : ""; + return /^20\d{6}(?:[_-]\d+)?$/.test(normalized); + } + function hasDefaultSessionName(session) { + if (session?.user_set_name) return false; + const name = typeof session?.name === "string" ? session.name.trim() : ""; + return name === "" || name === "New Chat" || name === "\u65B0\u5BF9\u8BDD" || isGeneratedSessionName2(name); + } + async function enrichSessionHistory(sessions) { + if (!sessionSnapshotService2?.isEnabled?.()) return; + const needsSummary = sessions.filter( + (session) => hasDefaultSessionName(session) || Number(session?.message_count ?? 0) === 0 + ); + if (needsSummary.length === 0) return; + try { + const summaries = await sessionSnapshotService2.getHistorySummaries(needsSummary.map((s) => s.id)); + for (const session of needsSummary) { + const summary = summaries.get(session.id); + if (!summary) continue; + if (summary.title && hasDefaultSessionName(session)) { + session.name = summary.title; + } + if (Number(session?.message_count ?? 0) === 0 && Number(summary.messageCount ?? 0) > 0) { + session.message_count = Number(summary.messageCount); + } + } + } catch { + } + } + function projectSessionSummary(session) { + return { + id: session?.id, + name: typeof session?.name === "string" ? session.name : "", + message_count: Number(session?.message_count ?? 0), + created_at: session?.created_at ?? void 0, + updated_at: session?.updated_at ?? void 0, + user_set_name: Boolean(session?.user_set_name), + recipe: session?.recipe ?? null + }; + } + function sortSessionsByRecent(left, right) { + const leftTime = Date.parse(left?.updated_at ?? left?.created_at ?? "") || 0; + const rightTime = Date.parse(right?.updated_at ?? right?.created_at ?? "") || 0; + return rightTime - leftTime; + } + function matchesSessionQuery(summary, rawQuery) { + const query = String(rawQuery ?? "").trim().toLowerCase(); + if (!query) return true; + const haystacks = [ + summary?.name, + summary?.recipe?.title, + summary?.id + ].filter((value) => typeof value === "string" && value.trim()).map((value) => value.toLowerCase()); + return haystacks.some((value) => value.includes(query)); + } + function paginateSessionConversation(conversation, beforeValue, limitValue) { + const visibleConversation = Array.isArray(conversation) ? conversation.filter((message) => message?.metadata?.userVisible) : []; + const total = visibleConversation.length; + const before = Math.max(Number(beforeValue ?? 0) || 0, 0); + const requestedLimit = Number(limitValue ?? 0) || 0; + if (requestedLimit <= 0) { + return { + conversation: visibleConversation, + page: { + total, + before: 0, + limit: total, + returned: total, + has_more_before: false + } + }; + } + const limit = Math.min(Math.max(requestedLimit, 1), 200); + const end = Math.max(total - before, 0); + const start = Math.max(end - limit, 0); + const paged = visibleConversation.slice(start, end); + return { + conversation: paged, + page: { + total, + before, + limit, + returned: paged.length, + has_more_before: start > 0 + } + }; + } async function pickTarget() { if (targets.length <= 1) return primaryTarget; for (let i = 0; i < targets.length; i += 1) { @@ -2577,10 +8559,50 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut } return primaryTarget; } + async function startSessionForUser(userId, { + workingDir, + sessionPolicy = null, + recipe = null + } = {}) { + 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 } : {}, + ...recipe ? { recipe } : {} + }) + }); + const text = await upstream.text(); + if (!upstream.ok) { + throw new Error(text || `\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25 (${upstream.status})`); + } + const session = JSON.parse(text); + if (!session?.id) { + throw new Error("\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25\uFF1A\u7F3A\u5C11 session id"); + } + await userAuth2.registerAgentSession(userId, session.id, startTarget); + if (sessionPolicy?.gooseMode) { + const modeRes = await apiFetch(startTarget, apiSecret, "/agent/update_session", { + method: "POST", + body: JSON.stringify({ + session_id: session.id, + goose_mode: sessionPolicy.gooseMode + }) + }); + if (!modeRes.ok) { + const modeText = await modeRes.text().catch(() => ""); + throw new Error(modeText || `\u8BBE\u7F6E\u4F1A\u8BDD\u6A21\u5F0F\u5931\u8D25 (${modeRes.status})`); + } + } + return session; + } async function resolveTarget(sessionId) { if (targets.length <= 1 || !sessionId) return primaryTarget; try { - const node = await userAuth2.getSessionNode(sessionId); + const { target, node } = await userAuth2.getSessionTarget(sessionId); + if (target && targets.includes(target)) return target; return targets[node] ?? primaryTarget; } catch { return primaryTarget; @@ -2625,7 +8647,8 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut userId, publishLayout, localFetchAsset, - llmProviderService: llmProviderService2 + llmProviderService: llmProviderService2, + imgproxySigner }); } async function reconcileSessionPolicyForUser(userId, sessionId) { @@ -2634,6 +8657,7 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut const workingDir = await userAuth2.resolveWorkingDir(userId); const sessionPolicy = await userAuth2.getAgentSessionPolicy(userId); const publishLayout = await userAuth2.getUserPublishLayout(userId); + const userMemories = conversationMemoryService2?.listMemories ? await conversationMemoryService2.listMemories(userId, { limit: 40 }).catch(() => []) : []; await reconcileAgentSession( (pathname, init) => apiFetch(target, apiSecret, pathname, init), sessionId, @@ -2642,6 +8666,7 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut sessionPolicy, sandboxConstraints: publishLayout?.constraints ?? null, tolerateInvalidWorkingDir: true, + userMemories, userContext: publishLayout ? { userId, displayName: publishLayout.displayName, @@ -2711,7 +8736,7 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut await userAuth2.registerAgentSession( req.currentUser.id, session.id, - Math.max(0, targets.indexOf(startTarget)) + startTarget ); if (sessionPolicy.gooseMode) { const modeRes = await apiFetch(startTarget, apiSecret, "/agent/update_session", { @@ -2728,12 +8753,14 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut } } const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser.id); + const userMemories = conversationMemoryService2?.listMemories ? await conversationMemoryService2.listMemories(req.currentUser.id, { limit: 40 }).catch(() => []) : []; const api2 = (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init); try { await reconcileAgentSession(api2, session.id, { workingDir, sessionPolicy, sandboxConstraints: publishLayout?.constraints ?? null, + userMemories, userContext: publishLayout ? { userId: req.currentUser.id, displayName: publishLayout.displayName, @@ -2786,6 +8813,7 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut const workingDir = await userAuth2.resolveWorkingDir(req.currentUser.id); const sessionPolicy = await userAuth2.getAgentSessionPolicy(req.currentUser.id); const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser.id); + const userMemories = conversationMemoryService2?.listMemories ? await conversationMemoryService2.listMemories(req.currentUser.id, { limit: 40 }).catch(() => []) : []; try { await reconcileAgentSession( (pathname, init) => apiFetch(resumeTarget, apiSecret, pathname, init), @@ -2795,6 +8823,7 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut sessionPolicy, sandboxConstraints: publishLayout?.constraints ?? null, tolerateInvalidWorkingDir: true, + userMemories, userContext: publishLayout ? { userId: req.currentUser.id, displayName: publishLayout.displayName, @@ -2826,6 +8855,10 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut requireUser, async (req, res) => { try { + const offset = Math.max(Number(req.query?.offset ?? 0) || 0, 0); + const requestedLimit = Number(req.query?.limit ?? 20) || 20; + const limit = Math.min(Math.max(requestedLimit, 1), 100); + const query = String(req.query?.query ?? "").trim(); const owned = await userAuth2.listOwnedSessionIds(req.currentUser.id); const sessionsById = /* @__PURE__ */ new Map(); let healthyTargets = 0; @@ -2860,7 +8893,20 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut if (healthyTargets < targets.length) { res.setHeader("X-TKMind-Degraded", "1"); } - res.json({ sessions: [...sessionsById.values()] }); + const sessions = [...sessionsById.values()].sort(sortSessionsByRecent); + await enrichSessionHistory(sessions); + const summaries = sessions.map(projectSessionSummary).filter((item) => matchesSessionQuery(item, query)); + const paged = summaries.slice(offset, offset + limit); + res.json({ + sessions: paged, + page: { + total: summaries.length, + offset, + limit, + has_more: offset + paged.length < summaries.length, + query + } + }); } catch (err) { res.status(500).json({ message: sanitizeUserFacingProxyMessage( @@ -2923,11 +8969,12 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut let pendingBalance = null; const billingTransform = createSseBillingTransform({ onFinish: async (event) => { + const billingRequestId = event.request_id ?? event.chat_request_id ?? null; const result = await userAuth2.billSessionUsage( req.currentUser.id, sessionId, event.token_state, - null + billingRequestId ); if (result.ok && result.costCents > 0 && result.balanceCents != null) { pendingBalance = { @@ -2947,6 +8994,11 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut } }); const source = Readable.fromWeb(upstream.body); + const linkSanitizer = createSessionEventSanitizer(req.currentUser); + const keepalive = setInterval(() => { + if (!res.writableEnded) res.write(": keepalive\n\n"); + }, 2e4); + const stopKeepalive = () => clearInterval(keepalive); billingTransform.on("data", (chunk) => { res.write(chunk); if (pendingBalance != null) { @@ -2954,10 +9006,24 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut pendingBalance = null; } }); - billingTransform.on("end", () => res.end()); - billingTransform.on("error", () => res.end()); - source.on("error", () => res.end()); - source.pipe(billingTransform); + billingTransform.on("end", () => { + stopKeepalive(); + res.end(); + }); + billingTransform.on("error", () => { + stopKeepalive(); + res.end(); + }); + linkSanitizer.on("error", () => { + stopKeepalive(); + res.end(); + }); + source.on("error", () => { + stopKeepalive(); + res.end(); + }); + req.on("close", stopKeepalive); + source.pipe(linkSanitizer).pipe(billingTransform); } catch (err) { res.status(502).json({ message: sanitizeUserFacingProxyMessage( @@ -3037,6 +9103,24 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut "Last-Event-ID": req.get("last-event-id") ?? "" } }); + if (req.method === "GET" && /^\/sessions\/[^/]+$/.test(pathname) && upstream.ok) { + const payload = await upstream.json().catch(() => null); + if (payload && Array.isArray(payload.conversation)) { + const sanitizedConversation = sanitizeSessionConversationPublicHtmlLinks( + payload.conversation, + req.currentUser + ); + const { conversation, page } = paginateSessionConversation( + sanitizedConversation, + req.query?.history_before, + req.query?.history_limit + ); + payload.conversation = conversation; + payload.conversation_page = page; + } + res.status(upstream.status).json(payload ?? {}); + return; + } sendProxyResponse(res, upstream); } catch (err) { res.status(502).json({ @@ -3059,16 +9143,17 @@ function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAut proxyFallback, proxySessionEvents, resolveTarget, + startSessionForUser, apiFetch: async (pathname, init) => apiFetch(await pickTarget(), apiSecret, pathname, init), apiFetchTo: (target, pathname, init) => apiFetch(target, apiSecret, pathname, init) }; } // user-auth.mjs -import crypto4 from "node:crypto"; -import fs7 from "node:fs"; +import crypto5 from "node:crypto"; +import fs8 from "node:fs"; import net from "node:net"; -import path8 from "node:path"; +import path9 from "node:path"; import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from "@node-rs/argon2"; // billing.mjs @@ -3135,7 +9220,7 @@ function computeDeltaCostCents(previous, current, config = loadBillingConfig()) } // billing-recharge.mjs -import crypto3 from "node:crypto"; +import crypto4 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 { @@ -3160,7 +9245,7 @@ function buildInsufficientBalancePayload(balanceCents, config = loadRechargeConf } function createOutTradeNo() { const stamp = Date.now().toString(36).toUpperCase(); - const rand = crypto3.randomBytes(4).toString("hex").toUpperCase(); + const rand = crypto4.randomBytes(4).toString("hex").toUpperCase(); return `TK${stamp}${rand}`.slice(0, 32); } function mapOrderRow(row) { @@ -3181,10 +9266,10 @@ function mapOrderRow(row) { h5Url: row.h5_url ?? null }; } -function createRechargeService(pool, { userAuth: userAuth2, wechatPay, config = loadRechargeConfig() } = {}) { +function createRechargeService(pool2, { userAuth: userAuth2, wechatPay, config = loadRechargeConfig() } = {}) { const expireStaleOrders = async (userId) => { const now = Date.now(); - await pool.query( + await pool2.query( `UPDATE h5_payment_orders SET status = 'expired', updated_at = ? WHERE user_id = ? AND status = 'pending' AND expire_at <= ?`, @@ -3193,7 +9278,7 @@ function createRechargeService(pool, { userAuth: userAuth2, wechatPay, config = }; const countPendingOrders = async (userId) => { const now = Date.now(); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT COUNT(*) AS total FROM h5_payment_orders WHERE user_id = ? AND status = 'pending' AND expire_at > ?`, @@ -3204,7 +9289,7 @@ function createRechargeService(pool, { userAuth: userAuth2, wechatPay, config = const sumPaidToday = async (userId) => { const start = /* @__PURE__ */ new Date(); start.setHours(0, 0, 0, 0); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT COALESCE(SUM(amount_cents), 0) AS total FROM h5_payment_orders WHERE user_id = ? AND status = 'paid' AND paid_at >= ?`, @@ -3213,13 +9298,13 @@ function createRechargeService(pool, { userAuth: userAuth2, wechatPay, config = return Number(rows[0]?.total ?? 0); }; const getOrderById = async (orderId) => { - const [rows] = await pool.query(`SELECT * FROM h5_payment_orders WHERE id = ? LIMIT 1`, [ + const [rows] = await pool2.query(`SELECT * FROM h5_payment_orders WHERE id = ? LIMIT 1`, [ orderId ]); return mapOrderRow(rows[0]); }; const getOrderByOutTradeNo = async (outTradeNo) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_payment_orders WHERE out_trade_no = ? LIMIT 1`, [outTradeNo] ); @@ -3255,7 +9340,7 @@ function createRechargeService(pool, { 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 = crypto3.randomUUID(); + const orderId = crypto4.randomUUID(); const outTradeNo = createOutTradeNo(); const expireAt = now + config.orderTtlMs; const description = `TKMind\u8D26\u6237\u5145\u503C\xA5${(amount / 100).toFixed(2)}`; @@ -3307,7 +9392,7 @@ function createRechargeService(pool, { userAuth: userAuth2, wechatPay, config = message: err instanceof Error ? err.message : "\u521B\u5EFA\u652F\u4ED8\u8BA2\u5355\u5931\u8D25" }; } - await pool.query( + await pool2.query( `INSERT INTO h5_payment_orders (id, user_id, amount_cents, channel, status, pay_mode, out_trade_no, code_url, h5_url, expire_at, client_ip, created_at, updated_at) @@ -3358,7 +9443,7 @@ function createRechargeService(pool, { userAuth: userAuth2, wechatPay, config = return { ok: false, message: "\u652F\u4ED8\u91D1\u989D\u4E0E\u8BA2\u5355\u4E0D\u4E00\u81F4" }; } const now = Date.now(); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( @@ -3417,7 +9502,7 @@ function createRechargeService(pool, { userAuth: userAuth2, wechatPay, config = const order = await getOrderById(orderId); if (!order || order.userId !== userId) return null; if (order.status === "pending" && order.expireAt <= Date.now()) { - await pool.query( + await pool2.query( `UPDATE h5_payment_orders SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'pending'`, [Date.now(), orderId] ); @@ -3437,11 +9522,11 @@ function createRechargeService(pool, { userAuth: userAuth2, wechatPay, config = } // user-space.mjs -import fs5 from "node:fs"; -import path6 from "node:path"; -var UPLOAD_ZONE_CODES = ["oa", "private", "public"]; +import fs6 from "node:fs"; +import path7 from "node:path"; +var UPLOAD_ZONE_CODES = ["oa", "public"]; function resolveMindspaceStorageRoot(h5Root, env = process.env) { - return path6.resolve(env.MINDSPACE_STORAGE_ROOT ?? path6.join(h5Root, "data", "mindspace")); + return path7.resolve(env.MINDSPACE_STORAGE_ROOT ?? path7.join(h5Root, "data", "mindspace")); } function resolveUserWorkspaceRoot(h5Root, user) { return resolvePublishDir(h5Root, user); @@ -3456,41 +9541,41 @@ function renderUserSpaceBrandingBlock(userAddressName) { - \u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\uFF0C\u7528 **${name}** \u79F0\u547C\u7528\u6237\uFF08\u53EF\u8F85\u4EE5\u300C\u4F60/\u60A8\u300D\uFF09\uFF0C**\u7981\u6B62**\u628A\u7528\u6237\u53EB\u4F5C TKMind - \u95EE\u5019\u793A\u4F8B\uFF1A\u300C${name}\uFF0C\u4E0B\u5348\u597D\u300D\u2014\u2014\u4E0D\u8981\u7528\u300CTKMind\uFF0C\u4E0B\u5348\u597D\u300D - \u4E0D\u8981\u63CF\u8FF0\u672C\u5DE5\u4F5C\u533A\u4E3A\u300CRust goose \u9879\u76EE\u300D\u6216\u300Cgoose AI \u6846\u67B6\u300D -- \u672C\u5DE5\u4F5C\u533A\u662F TKMind **MindSpace \u7528\u6237\u7A7A\u95F4**\uFF0C\u7528\u4E8E OA/\u79C1\u4EBA/\u516C\u5F00\u6587\u4EF6\u7BA1\u7406\u4E0E\u9759\u6001\u9875\u9762\u751F\u6210 +- \u672C\u5DE5\u4F5C\u533A\u662F TKMind **MindSpace \u7528\u6237\u7A7A\u95F4**\uFF0C\u7528\u4E8E OA/\u516C\u5F00\u6587\u4EF6\u7BA1\u7406\u4E0E\u9759\u6001\u9875\u9762\u751F\u6210 `; } function resolveZoneDir(workspaceRoot, categoryCode) { - return path6.join(workspaceRoot, categoryCode); + return path7.join(workspaceRoot, categoryCode); } function resolveZoneFilePath(workspaceRoot, categoryCode, filename) { - return path6.join(resolveZoneDir(workspaceRoot, categoryCode), filename); + return path7.join(resolveZoneDir(workspaceRoot, categoryCode), filename); } function zoneLabel(categoryCode) { return SYSTEM_CATEGORIES.find((item) => item.code === categoryCode)?.name ?? categoryCode; } function ensureUserZoneDirs(workspaceRoot) { for (const code of UPLOAD_ZONE_CODES) { - fs5.mkdirSync(resolveZoneDir(workspaceRoot, code), { recursive: true }); + fs6.mkdirSync(resolveZoneDir(workspaceRoot, code), { recursive: true }); } } function mirrorAssetToZone2({ workspaceRoot, categoryCode, filename, sourcePath }) { if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return null; - if (!sourcePath || !fs5.existsSync(sourcePath)) return null; + if (!sourcePath || !fs6.existsSync(sourcePath)) return null; ensureUserZoneDirs(workspaceRoot); const dest = resolveZoneFilePath(workspaceRoot, categoryCode, filename); - fs5.mkdirSync(path6.dirname(dest), { recursive: true }); - fs5.copyFileSync(sourcePath, dest); + fs6.mkdirSync(path7.dirname(dest), { recursive: true }); + fs6.copyFileSync(sourcePath, dest); return dest; } function removeZoneMirror({ workspaceRoot, categoryCode, filename }) { if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return; const target = resolveZoneFilePath(workspaceRoot, categoryCode, filename); - if (fs5.existsSync(target)) fs5.unlinkSync(target); + if (fs6.existsSync(target)) fs6.unlinkSync(target); } function isPathInsideUserWorkspace(workspaceRoot, requestedPath) { - const base = path6.resolve(workspaceRoot); - const resolved = path6.resolve(requestedPath); - return resolved === base || resolved.startsWith(`${base}${path6.sep}`); + const base = path7.resolve(workspaceRoot); + const resolved = path7.resolve(requestedPath); + return resolved === base || resolved.startsWith(`${base}${path7.sep}`); } function renderUserSpaceHints({ username, workspaceRoot, displayName, slug }) { const addressName = resolveUserAddressName({ displayName, username, slug }); @@ -3527,8 +9612,9 @@ ${zoneLines.join("\n")} ## \u5DE5\u4F5C\u533A\u6587\u4EF6\u4E0E OA \u754C\u9762 -- \u5199\u5165 \`oa/\`\u3001\`private/\`\u3001\`public/\` \u6839\u76EE\u5F55\u7684\u652F\u6301\u7C7B\u578B\u6587\u4EF6\uFF08docx\u3001csv\u3001pdf\u3001\u56FE\u7247\u7B49\uFF09\u4F1A**\u81EA\u52A8\u540C\u6B65**\u5230 MindSpace \u8D44\u4EA7\u5E93 +- \u5199\u5165 \`oa/\`\u3001\`private/\`\u3001\`public/\` \u4E0B\u4EFB\u610F\u5C42\u7EA7\u7684\u652F\u6301\u7C7B\u578B\u6587\u4EF6\uFF08docx\u3001md\u3001txt\u3001csv\u3001pdf\u3001\u56FE\u7247\u7B49\uFF09\u4F1A**\u81EA\u52A8\u540C\u6B65**\u5230 MindSpace \u8D44\u4EA7\u5E93\uFF08\u542B\u5B50\u76EE\u5F55\uFF0C\u5982 \`oa/\u8BD7\u6B4C\u6563\u6587/\u590F\u65E5\u7684\u8BD7\u7BC7.md\`\uFF09 - \u6253\u5F00\u5BF9\u5E94\u5206\u533A\u6216\u4FDD\u5B58\u6587\u4EF6\u540E\u4F1A\u51FA\u73B0\u5728\u754C\u9762\u4E2D\uFF0C\u53EF\u76F4\u63A5\u9884\u89C8\u6216\u4E0B\u8F7D +- \u751F\u6210 docx/pdf \u7B49 OA \u8D44\u6599\u65F6\uFF0C\u53EF\u76F4\u63A5 \`write_file\` \u5230 \`oa/<\u5B50\u76EE\u5F55>/<\u6587\u4EF6\u540D>\`\uFF0C\u65E0\u9700\u624B\u52A8\u590D\u5236\u5230\u6839\u76EE\u5F55 - \u7528\u6237\u4E0A\u4F20\u7684\u6587\u4EF6\u4ECD\u4EE5\u754C\u9762\u5165\u5E93\u4E3A\u51C6\uFF0C\u5E76\u955C\u50CF\u5230\u4E0A\u8FF0\u5206\u533A `; } @@ -3550,19 +9636,19 @@ function buildUserSpaceConstraints({ username, workspaceRoot, publicBaseUrl, slu ].filter(Boolean).join("\n"); } function ensureUserSpaceHints(workspaceRoot, context) { - const hintsPath = path6.join(workspaceRoot, WORKSPACE_HINTS_FILENAME2); - const legacyPath = path6.join(workspaceRoot, LEGACY_WORKSPACE_HINTS_FILENAME2); + const hintsPath = path7.join(workspaceRoot, WORKSPACE_HINTS_FILENAME2); + const legacyPath = path7.join(workspaceRoot, LEGACY_WORKSPACE_HINTS_FILENAME2); const content = renderUserSpaceHints({ ...context, workspaceRoot }); - fs5.writeFileSync(hintsPath, content, "utf8"); - if (fs5.existsSync(legacyPath)) { - fs5.unlinkSync(legacyPath); + fs6.writeFileSync(hintsPath, content, "utf8"); + if (fs6.existsSync(legacyPath)) { + fs6.unlinkSync(legacyPath); } return hintsPath; } -async function syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot) { +async function syncUserZonesFromAssets(pool2, storageRoot, userId, workspaceRoot) { ensureUserZoneDirs(workspaceRoot); const placeholders = UPLOAD_ZONE_CODES.map(() => "?").join(", "); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT a.original_filename, c.category_code, v.storage_key FROM h5_assets a JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id @@ -3571,7 +9657,7 @@ async function syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot) [userId, ...UPLOAD_ZONE_CODES] ); for (const row of rows) { - const sourcePath = path6.join(storageRoot, row.storage_key); + const sourcePath = path7.join(storageRoot, row.storage_key); mirrorAssetToZone2({ workspaceRoot, categoryCode: row.category_code, @@ -3582,7 +9668,7 @@ async function syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot) return { workspaceRoot, mirrored: rows.length }; } async function ensureUserSpaceLayout({ - pool, + pool: pool2, storageRoot, userId, username, @@ -3592,8 +9678,8 @@ async function ensureUserSpaceLayout({ workspaceRoot }) { ensureUserZoneDirs(workspaceRoot); - if (pool && userId) { - await syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot); + if (pool2 && userId) { + await syncUserZonesFromAssets(pool2, storageRoot, userId, workspaceRoot); } const context = { username: username ?? slug, @@ -3613,10 +9699,10 @@ async function ensureUserSpaceLayout({ } // skills-registry.mjs -import fs6 from "node:fs"; -import path7 from "node:path"; +import fs7 from "node:fs"; +import path8 from "node:path"; import { fileURLToPath as fileURLToPath4 } from "node:url"; -var __dirname3 = path7.dirname(fileURLToPath4(import.meta.url)); +var __dirname3 = path8.dirname(fileURLToPath4(import.meta.url)); var DEFAULT_USER_SKILLS = { web: true, search: true, @@ -3624,6 +9710,7 @@ var DEFAULT_USER_SKILLS = { "form-builder": true, "table-viewer": true, "product-campaign-page": true, + "docx-generate": true, [PUBLISH_SKILL_NAME]: false }; var USER_ROLE_SKILL_PRESETS = { @@ -3651,14 +9738,14 @@ function parseSkillFrontmatter(content) { return { name, description }; } function listPlatformSkillCatalog(h5Root = __dirname3) { - const skillsRoot = path7.join(h5Root, "skills"); - if (!fs6.existsSync(skillsRoot)) return []; + const skillsRoot = path8.join(h5Root, "skills"); + if (!fs7.existsSync(skillsRoot)) return []; const catalog = []; - for (const entry of fs6.readdirSync(skillsRoot, { withFileTypes: true })) { + for (const entry of fs7.readdirSync(skillsRoot, { withFileTypes: true })) { if (!entry.isDirectory()) continue; - const skillPath = path7.join(skillsRoot, entry.name, "SKILL.md"); - if (!fs6.existsSync(skillPath)) continue; - const raw = fs6.readFileSync(skillPath, "utf8"); + const skillPath = path8.join(skillsRoot, entry.name, "SKILL.md"); + if (!fs7.existsSync(skillPath)) continue; + const raw = fs7.readFileSync(skillPath, "utf8"); const meta = parseSkillFrontmatter(raw); const name = meta.name || entry.name; catalog.push({ @@ -3712,14 +9799,14 @@ function applySkillGrantsToCapabilities(capabilities, skillMap) { return effective; } function copySkillTree(srcDir, destDir) { - fs6.mkdirSync(destDir, { recursive: true }); - for (const entry of fs6.readdirSync(srcDir, { withFileTypes: true })) { - const from = path7.join(srcDir, entry.name); - const to = path7.join(destDir, entry.name); + fs7.mkdirSync(destDir, { recursive: true }); + for (const entry of fs7.readdirSync(srcDir, { withFileTypes: true })) { + const from = path8.join(srcDir, entry.name); + const to = path8.join(destDir, entry.name); if (entry.isDirectory()) { copySkillTree(from, to); } else { - fs6.copyFileSync(from, to); + fs7.copyFileSync(from, to); } } } @@ -3733,22 +9820,22 @@ function syncSkillsToWorkspace({ }) { const enabled = new Set(grantedSkillNames(skillMap)); const platformNames = new Set(catalog.map((item) => item.name)); - const agentsSkills = path7.join(publishDir, ".agents", "skills"); - if (fs6.existsSync(agentsSkills)) { - for (const entry of fs6.readdirSync(agentsSkills, { withFileTypes: true })) { + const agentsSkills = path8.join(publishDir, ".agents", "skills"); + if (fs7.existsSync(agentsSkills)) { + for (const entry of fs7.readdirSync(agentsSkills, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const skillName = entry.name; const catalogItem = catalog.find((item) => item.name === skillName || item.dirName === skillName); const resolvedName = catalogItem?.name ?? skillName; if (platformNames.has(resolvedName) && !enabled.has(resolvedName)) { - fs6.rmSync(path7.join(agentsSkills, entry.name), { recursive: true, force: true }); + fs7.rmSync(path8.join(agentsSkills, entry.name), { recursive: true, force: true }); } } } for (const item of catalog) { if (!enabled.has(item.name)) continue; - const srcDir = path7.join(h5Root, "skills", item.dirName); - const destDir = path7.join(agentsSkills, item.name); + const srcDir = path8.join(h5Root, "skills", item.dirName); + const destDir = path8.join(agentsSkills, item.name); if (item.name === PUBLISH_SKILL_NAME && user) { ensurePublishSkillInstalled(publishDir, { slug: String(user.id).trim().toLowerCase(), @@ -3758,8 +9845,8 @@ function syncSkillsToWorkspace({ }); continue; } - if (fs6.existsSync(srcDir)) { - if (fs6.existsSync(destDir)) fs6.rmSync(destDir, { recursive: true, force: true }); + if (fs7.existsSync(srcDir)) { + if (fs7.existsSync(destDir)) fs7.rmSync(destDir, { recursive: true, force: true }); copySkillTree(srcDir, destDir); } } @@ -3770,7 +9857,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 && crypto4.timingSafeEqual(a, b); + return a.length === b.length && crypto5.timingSafeEqual(a, b); } var PASSWORD_ALGORITHM_PBKDF2 = "pbkdf2-sha512"; var PASSWORD_ALGORITHM_ARGON2ID = "argon2id"; @@ -3779,7 +9866,7 @@ var ARGON2_PASSES = 3; var ARGON2_PARALLELISM = 1; var ARGON2_TAG_LENGTH = 32; function hashPasswordPbkdf2(password, salt) { - return crypto4.pbkdf2Sync(password, salt, 1e5, 64, "sha512").toString("hex"); + return crypto5.pbkdf2Sync(password, salt, 1e5, 64, "sha512").toString("hex"); } function hashPasswordArgon2id(password, salt) { return argon2HashRawSync(password, { @@ -3792,7 +9879,7 @@ function hashPasswordArgon2id(password, salt) { }).toString("hex"); } function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) { - const salt = crypto4.randomBytes(16).toString("hex"); + const salt = crypto5.randomBytes(16).toString("hex"); if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) { return { salt, @@ -3823,11 +9910,11 @@ function isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } function hashSessionToken(token) { - return crypto4.createHash("sha256").update(token).digest("hex"); + return crypto5.createHash("sha256").update(token).digest("hex"); } -function createUserAuth(pool, options = {}) { - const usersRoot = path8.resolve(options.usersRoot ?? "/tmp/tkmind_go_users"); - const h5Root = path8.resolve(options.h5Root ?? path8.join(usersRoot, "..")); +function createUserAuth(pool2, options = {}) { + const usersRoot = path9.resolve(options.usersRoot ?? "/tmp/tkmind_go_users"); + const h5Root = path9.resolve(options.h5Root ?? path9.join(usersRoot, "..")); const env = options.env ?? process.env; const storageRoot = resolveMindspaceStorageRoot(h5Root, env); const publicBaseUrl = resolvePublicBaseUrl(env); @@ -3836,7 +9923,7 @@ function createUserAuth(pool, options = {}) { const sessionTtlMs = Number(options.sessionTtlMs ?? 7 * 24 * 60 * 60 * 1e3); const loginMaxFailures = Number(options.loginMaxFailures ?? 5); const loginFailureWindowMs = Number(options.loginFailureWindowMs ?? 5 * 60 * 1e3); - const persistSessions = options.persistSessions !== false && Boolean(pool); + const persistSessions = options.persistSessions !== false && Boolean(pool2); let rechargeNotifier = typeof options.onRechargeNotification === "function" ? options.onRechargeNotification : null; const subscriptionService2 = options.subscriptionService ?? null; const sessions = /* @__PURE__ */ new Map(); @@ -3855,10 +9942,10 @@ function createUserAuth(pool, options = {}) { const expiresAt = now + sessionTtlMs; sessions.set(token, { userId, role, expiresAt }); if (!persistSessions) return expiresAt; - await pool.query( + await pool2.query( `INSERT INTO h5_login_sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)`, - [crypto4.randomUUID(), userId, hashSessionToken(token), expiresAt, now] + [crypto5.randomUUID(), userId, hashSessionToken(token), expiresAt, now] ); return expiresAt; }; @@ -3867,17 +9954,17 @@ function createUserAuth(pool, options = {}) { if (session.userId === userId) sessions.delete(token); } if (!persistSessions) return; - await pool.query( + await pool2.query( `UPDATE h5_login_sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`, [now, userId] ); }; const ensureWorkspace = (workspaceRoot) => { - fs7.mkdirSync(workspaceRoot, { recursive: true }); + fs8.mkdirSync(workspaceRoot, { recursive: true }); }; const isAdminRole = (user) => user?.role === "admin"; const getUserById = async (userId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, u.plan_type, u.workspace_root, s.quota_bytes, s.used_bytes, s.reserved_bytes, @@ -3933,7 +10020,7 @@ function createUserAuth(pool, options = {}) { legacyUsersRoot: migrateLegacy ? usersRoot : null }); const space = await ensureUserSpaceLayout({ - pool, + pool: pool2, storageRoot, userId: user.id, username: user.username ?? web.slug, @@ -3952,7 +10039,7 @@ function createUserAuth(pool, options = {}) { ensurePublishSkillInstalled(web.publishDir, hintsContext); ensureWorkspaceHintsInstalled(web.publishDir, hintsContext); const legacyPublishDir = resolveLegacyPublishDir(h5Root, user); - if (legacyPublishDir && legacyPublishDir !== web.publishDir && fs7.existsSync(legacyPublishDir)) { + if (legacyPublishDir && legacyPublishDir !== web.publishDir && fs8.existsSync(legacyPublishDir)) { ensureWorkspaceHintsInstalled(legacyPublishDir, { ...hintsContext, publishDir: legacyPublishDir }); } ensureUserMemoryProfile(web.publishDir, { @@ -3969,7 +10056,7 @@ function createUserAuth(pool, options = {}) { }; }; const listSkillGrants = async (subjectType, subjectId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT skill_name, enabled FROM h5_user_skill_grants WHERE subject_type = ? AND subject_id = ?`, @@ -4001,17 +10088,17 @@ function createUserAuth(pool, options = {}) { const syncUserPublishWorkspace = async (user) => { if (!user) return null; const layout = await publishLayoutFor(user); - const current = path8.resolve(user.workspace_root); - const target = path8.resolve(layout.publishDir); + const current = path9.resolve(user.workspace_root); + const target = path9.resolve(layout.publishDir); if (current !== target) { const now = Date.now(); - await pool.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [ + await pool2.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [ layout.publishDir, now, user.id ]); - await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [user.id]); - await pool.query( + await pool2.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [user.id]); + await pool2.query( `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, [user.id, layout.publishDir] ); @@ -4040,11 +10127,11 @@ function createUserAuth(pool, options = {}) { return { ok: false, message: "\u8BF7\u8F93\u5165\u6709\u6548\u90AE\u7BB1" }; } const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); - const userId = crypto4.randomUUID(); + const userId = crypto5.randomUUID(); const layout = await publishLayoutFor({ id: userId, username: normalized }); const workspaceRoot = layout.publishDir; const now = Date.now(); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); await conn.query( @@ -4116,7 +10203,7 @@ function createUserAuth(pool, options = {}) { retryAfterMs: failure.resetAt - now }; } - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, u.plan_type, u.workspace_root, u.salt, u.password_hash, u.password_algorithm, w.balance_cents, w.tokens_used @@ -4143,7 +10230,7 @@ function createUserAuth(pool, options = {}) { } if ((row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2) !== PASSWORD_ALGORITHM_ARGON2ID) { const nextPassword = createPasswordRecord(password); - await pool.query( + await pool2.query( `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`, @@ -4154,7 +10241,7 @@ function createUserAuth(pool, options = {}) { row.password_algorithm = nextPassword.passwordAlgorithm; } loginFailures.delete(failureKey); - const token = crypto4.randomBytes(32).toString("base64url"); + const token = crypto5.randomBytes(32).toString("base64url"); await storeSession(row.id, row.role, token, now); return { ok: true, token, user: publicUser(row) }; }; @@ -4169,7 +10256,7 @@ function createUserAuth(pool, options = {}) { if (!password || password.length < 6) { return { ok: false, message: "\u65B0\u5BC6\u7801\u81F3\u5C11 6 \u4F4D" }; } - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id, email, status FROM h5_users WHERE username = ? LIMIT 1`, [normalized] ); @@ -4183,7 +10270,7 @@ function createUserAuth(pool, options = {}) { } const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); const now = Date.now(); - await pool.query( + await pool2.query( `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`, [salt, passwordHash, passwordAlgorithm, now, row.id] ); @@ -4206,7 +10293,7 @@ function createUserAuth(pool, options = {}) { } cached.expiresAt = now + sessionTtlMs; if (persistSessions) { - await pool.query( + await pool2.query( `UPDATE h5_login_sessions SET expires_at = ? WHERE token_hash = ? AND revoked_at IS NULL`, @@ -4217,7 +10304,7 @@ function createUserAuth(pool, options = {}) { } if (!persistSessions) return null; const tokenHash = hashSessionToken(token); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT s.user_id, s.expires_at, u.role, u.status FROM h5_login_sessions s JOIN h5_users u ON u.id = s.user_id @@ -4231,7 +10318,7 @@ function createUserAuth(pool, options = {}) { return null; } const expiresAt = now + sessionTtlMs; - await pool.query( + await pool2.query( `UPDATE h5_login_sessions SET expires_at = ? WHERE token_hash = ? AND revoked_at IS NULL`, [expiresAt, tokenHash] ); @@ -4243,7 +10330,7 @@ function createUserAuth(pool, options = {}) { if (!token) return; sessions.delete(token); if (!persistSessions) return; - await pool.query( + await pool2.query( `UPDATE h5_login_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`, [now, hashSessionToken(token)] ); @@ -4256,7 +10343,7 @@ function createUserAuth(pool, options = {}) { return publicUser(user); }; const listPathGrants = async (userId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT path, mode FROM h5_user_path_grants WHERE user_id = ? ORDER BY path`, [userId] ); @@ -4281,7 +10368,7 @@ function createUserAuth(pool, options = {}) { return isPathInsideUserWorkspace(layout.publishDir, requestedPath); }; const repairAllUserPublishDirs = async () => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'` ); for (const row of rows) { @@ -4291,7 +10378,7 @@ function createUserAuth(pool, options = {}) { const seedRoleSkillDefaults = async () => { const now = Date.now(); for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) { - await pool.query( + await pool2.query( `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) VALUES ('role', 'user', ?, ?, ?) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`, @@ -4299,37 +10386,48 @@ function createUserAuth(pool, options = {}) { ); } }; - const registerAgentSession = async (userId, agentSessionId, goosedNode = 0) => { - await pool.query( - `INSERT INTO h5_user_sessions (agent_session_id, user_id, goosed_node, created_at) - VALUES (?, ?, ?, ?) - ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), goosed_node = VALUES(goosed_node)`, - [agentSessionId, userId, goosedNode, Date.now()] + const registerAgentSession = async (userId, agentSessionId, goosedTarget = 0) => { + const isLegacyIndex = typeof goosedTarget === "number" || /^\d+$/.test(String(goosedTarget)); + const goosedNode = isLegacyIndex ? Number(goosedTarget) : 0; + const targetUrl = isLegacyIndex ? null : String(goosedTarget); + await pool2.query( + `INSERT INTO h5_user_sessions (agent_session_id, user_id, goosed_node, goosed_target, created_at) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), + goosed_node = VALUES(goosed_node), goosed_target = VALUES(goosed_target)`, + [agentSessionId, userId, goosedNode, targetUrl, Date.now()] ); }; const getSessionNode = async (agentSessionId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT goosed_node FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, [agentSessionId] ); return rows[0]?.goosed_node ?? 0; }; + const getSessionTarget = async (agentSessionId) => { + const [rows] = await pool2.query( + `SELECT goosed_node, goosed_target FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, + [agentSessionId] + ); + return { target: rows[0]?.goosed_target ?? null, node: rows[0]?.goosed_node ?? 0 }; + }; const ownsSession = async (userId, agentSessionId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT 1 FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ? LIMIT 1`, [agentSessionId, userId] ); return rows.length > 0; }; const listOwnedSessionIds = async (userId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT agent_session_id FROM h5_user_sessions WHERE user_id = ?`, [userId] ); return new Set(rows.map((row) => row.agent_session_id)); }; const unregisterAgentSession = async (userId, agentSessionId) => { - await pool.query( + await pool2.query( `DELETE FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ?`, [agentSessionId, userId] ); @@ -4394,11 +10492,11 @@ function createUserAuth(pool, options = {}) { params.push(status); } const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; - const [[{ total }]] = await pool.query( + const [[{ total }]] = await pool2.query( `SELECT COUNT(*) AS total FROM h5_users u ${where}`, params ); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, u.plan_type, u.workspace_root, s.quota_bytes, s.used_bytes, s.reserved_bytes, @@ -4439,11 +10537,11 @@ function createUserAuth(pool, options = {}) { return { ok: false, message: "\u5BC6\u7801\u81F3\u5C11 6 \u4F4D" }; } const isAdmin = role === "admin"; - const userId = crypto4.randomUUID(); + const userId = crypto5.randomUUID(); const root = (await publishLayoutFor({ id: userId, username: normalized })).publishDir; const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); const now = Date.now(); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); await conn.query( @@ -4514,12 +10612,12 @@ function createUserAuth(pool, options = {}) { values.push(patch.status); } if (patch.workspaceRoot !== void 0) { - const root = path8.resolve(patch.workspaceRoot); + const root = path9.resolve(patch.workspaceRoot); fields.push("workspace_root = ?"); values.push(root); ensureWorkspace(root); - await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [userId]); - await pool.query( + await pool2.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [userId]); + await pool2.query( `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, [userId, root] ); @@ -4531,13 +10629,13 @@ function createUserAuth(pool, options = {}) { if (fields.length > 0) { fields.push("updated_at = ?"); values.push(now, userId); - await pool.query(`UPDATE h5_users SET ${fields.join(", ")} WHERE id = ?`, values); + await pool2.query(`UPDATE h5_users SET ${fields.join(", ")} WHERE id = ?`, values); } if (patch.status === "disabled" || patch.status === "suspended") { await revokeAllSessionsForUser(userId, now); } if (patch.balanceCents !== void 0) { - await pool.query( + await pool2.query( `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) VALUES (?, ?, 0, ?) ON DUPLICATE KEY UPDATE balance_cents = VALUES(balance_cents), updated_at = VALUES(updated_at)`, @@ -4549,7 +10647,7 @@ function createUserAuth(pool, options = {}) { if (!Number.isFinite(quotaBytes) || quotaBytes <= 0) { return { ok: false, message: "\u7A7A\u95F4\u5927\u5C0F\u65E0\u6548" }; } - const [spaceRows] = await pool.query( + const [spaceRows] = await pool2.query( `SELECT id, quota_bytes, used_bytes, reserved_bytes FROM h5_user_spaces WHERE user_id = ? @@ -4565,14 +10663,14 @@ function createUserAuth(pool, options = {}) { }; } if (currentSpace?.id) { - await pool.query( + await pool2.query( `UPDATE h5_user_spaces SET quota_bytes = ?, updated_at = ? WHERE user_id = ?`, [quotaBytes, now, userId] ); } else { - await initializeDefaultSpace(pool, userId, { + await initializeDefaultSpace(pool2, userId, { quotaBytes, now }); @@ -4589,7 +10687,7 @@ function createUserAuth(pool, options = {}) { const deltaBytes = purchaseMb * 1024 * 1024; const costCents = purchaseMb * 200; const now = Date.now(); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [spaceRows] = await conn.query( @@ -4645,7 +10743,7 @@ function createUserAuth(pool, options = {}) { (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at) VALUES (?, ?, 'web', 'space_purchase', ?, ?, ?, 'unread', NULL, ?, ?)`, [ - crypto4.randomUUID(), + crypto5.randomUUID(), userId, "\u7A7A\u95F4\u6269\u5BB9\u6210\u529F", `\u5DF2\u8D2D\u4E70 ${purchaseMb} MB \u7A7A\u95F4\uFF0C\u652F\u4ED8 \xA5${(costCents / 100).toFixed(2)}\u3002`, @@ -4655,7 +10753,7 @@ function createUserAuth(pool, options = {}) { ] ); await conn.commit(); - const [updatedSpaceRows] = await pool.query( + const [updatedSpaceRows] = await pool2.query( `SELECT quota_bytes, used_bytes, reserved_bytes FROM h5_user_spaces WHERE user_id = ? @@ -4696,7 +10794,7 @@ function createUserAuth(pool, options = {}) { const rechargeType = paymentOrderId ? "self_recharge" : operatorId ? "admin_recharge" : "recharge"; const now = Date.now(); const ownsConnection = !options2.conn; - const conn = options2.conn ?? await pool.getConnection(); + const conn = options2.conn ?? await pool2.getConnection(); try { if (ownsConnection) await conn.beginTransaction(); await conn.query( @@ -4720,7 +10818,7 @@ function createUserAuth(pool, options = {}) { (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at) VALUES (?, ?, 'web', ?, ?, ?, ?, 'unread', NULL, ?, ?)`, [ - crypto4.randomUUID(), + crypto5.randomUUID(), userId, rechargeType, title, @@ -4770,7 +10868,7 @@ function createUserAuth(pool, options = {}) { } }; const getBillingState = async (agentSessionId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at FROM h5_session_billing_state @@ -4801,33 +10899,78 @@ function createUserAuth(pool, options = {}) { }; } const tokenState = normalizeTokenState(tokenStateRaw); - const previous = await getBillingState(agentSessionId); - if (previous && tokenState.accumulatedInputTokens <= Number(previous.lastInputTokens ?? 0) && tokenState.accumulatedOutputTokens <= Number(previous.lastOutputTokens ?? 0)) { - const user2 = await getUserById(userId); - return { - ok: true, - costCents: 0, - balanceCents: user2 ? Number(user2.balance_cents) : null, - tokensUsed: user2 ? Number(user2.tokens_used ?? 0) : null, - deltaInputTokens: 0, - deltaOutputTokens: 0 - }; - } const config = loadBillingConfig(); - let costCents = computeDeltaCostCents(previous, tokenState, config); - const deltaIn = Math.max( - 0, - tokenState.accumulatedInputTokens - Number(previous?.lastInputTokens ?? 0) - ); - const deltaOut = Math.max( - 0, - tokenState.accumulatedOutputTokens - Number(previous?.lastOutputTokens ?? 0) - ); - const deltaTokens = deltaIn + deltaOut; + const normalizedRequestId = requestId ? String(requestId).trim() || null : null; const now = Date.now(); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); + if (normalizedRequestId) { + const [existingUsage] = await conn.query( + `SELECT cost_cents FROM h5_usage_records WHERE request_id = ? LIMIT 1`, + [normalizedRequestId] + ); + if (existingUsage[0]) { + const [walletRows] = await conn.query( + `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ?`, + [userId] + ); + await conn.commit(); + return { + ok: true, + costCents: 0, + balanceCents: walletRows[0] ? Number(walletRows[0].balance_cents) : null, + tokensUsed: walletRows[0] ? Number(walletRows[0].tokens_used ?? 0) : null, + deltaInputTokens: 0, + deltaOutputTokens: 0 + }; + } + } + await conn.query( + `INSERT INTO h5_session_billing_state + (agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at) + VALUES (?, ?, NULL, 0, 0, ?) + ON DUPLICATE KEY UPDATE agent_session_id = agent_session_id`, + [agentSessionId, userId, now] + ); + const [stateRows] = await conn.query( + `SELECT last_accumulated_cost, last_input_tokens, last_output_tokens + FROM h5_session_billing_state + WHERE agent_session_id = ? + FOR UPDATE`, + [agentSessionId] + ); + const stateRow = stateRows[0]; + const previous = stateRow ? { + lastAccumulatedCost: stateRow.last_accumulated_cost, + lastInputTokens: Number(stateRow.last_input_tokens ?? 0), + lastOutputTokens: Number(stateRow.last_output_tokens ?? 0) + } : null; + if (previous && tokenState.accumulatedInputTokens <= Number(previous.lastInputTokens ?? 0) && tokenState.accumulatedOutputTokens <= Number(previous.lastOutputTokens ?? 0)) { + const [walletRows] = await conn.query( + `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ?`, + [userId] + ); + await conn.commit(); + return { + ok: true, + costCents: 0, + balanceCents: walletRows[0] ? Number(walletRows[0].balance_cents) : null, + tokensUsed: walletRows[0] ? Number(walletRows[0].tokens_used ?? 0) : null, + deltaInputTokens: 0, + deltaOutputTokens: 0 + }; + } + let costCents = computeDeltaCostCents(previous, tokenState, config); + const deltaIn = Math.max( + 0, + tokenState.accumulatedInputTokens - Number(previous?.lastInputTokens ?? 0) + ); + const deltaOut = Math.max( + 0, + tokenState.accumulatedOutputTokens - Number(previous?.lastOutputTokens ?? 0) + ); + const deltaTokens = deltaIn + deltaOut; await conn.query( `INSERT INTO h5_session_billing_state (agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at) @@ -4880,7 +11023,16 @@ function createUserAuth(pool, options = {}) { `INSERT INTO h5_usage_records (user_id, agent_session_id, request_id, input_tokens, output_tokens, cost_cents, balance_after_cents, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [userId, agentSessionId, requestId, deltaIn, deltaOut, costCents, nextBalance, now] + [ + userId, + agentSessionId, + normalizedRequestId, + deltaIn, + deltaOut, + costCents, + nextBalance, + now + ] ); await conn.query( `INSERT INTO h5_billing_ledger @@ -4928,7 +11080,7 @@ function createUserAuth(pool, options = {}) { const params2 = []; const where2 = userId ? "WHERE r.user_id = ?" : ""; if (userId) params2.push(userId); - const [rows2] = await pool.query( + const [rows2] = await pool2.query( `SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id, r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id @@ -4943,11 +11095,11 @@ function createUserAuth(pool, options = {}) { const params = []; const where = userId ? "WHERE r.user_id = ?" : ""; if (userId) params.push(userId); - const [[{ total }]] = await pool.query( + const [[{ total }]] = await pool2.query( `SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`, params ); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id, r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id @@ -4963,16 +11115,16 @@ function createUserAuth(pool, options = {}) { pageSize: safePageSize }; }; - const listBillingLedger = async ({ userId = null, page = 1, pageSize = 20, limit = null, types = null } = {}) => { + const listBillingLedger = async ({ userId = null, page = 1, pageSize = 20, limit = null, types: types2 = null } = {}) => { const buildWhere = (params) => { const clauses = []; if (userId) { clauses.push("l.user_id = ?"); params.push(userId); } - if (Array.isArray(types) && types.length) { - clauses.push(`l.type IN (${types.map(() => "?").join(", ")})`); - params.push(...types); + if (Array.isArray(types2) && types2.length) { + clauses.push(`l.type IN (${types2.map(() => "?").join(", ")})`); + params.push(...types2); } return clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; }; @@ -4981,7 +11133,7 @@ function createUserAuth(pool, options = {}) { const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200); const params = []; const where2 = buildWhere(params); - const [rows2] = await pool.query(`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens, l.session_id, l.note, l.created_at FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id ${where2} ORDER BY l.created_at DESC LIMIT ${safeLimit}`, params); + const [rows2] = await pool2.query(`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens, l.session_id, l.note, l.created_at FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id ${where2} ORDER BY l.created_at DESC LIMIT ${safeLimit}`, params); return rows2.map(mapRow); } const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200); @@ -4989,13 +11141,13 @@ function createUserAuth(pool, options = {}) { const offset = (safePage - 1) * safePageSize; const countParams = []; const where = buildWhere(countParams); - const [[{ total }]] = await pool.query( + const [[{ total }]] = await pool2.query( `SELECT COUNT(*) AS total FROM h5_billing_ledger l ${where}`, countParams ); const dataParams = []; const dataWhere = buildWhere(dataParams); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens, l.session_id, l.note, l.created_at FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id @@ -5008,7 +11160,7 @@ function createUserAuth(pool, options = {}) { }; const getAdminSummary = async () => { const since24h = Date.now() - 24 * 60 * 60 * 1e3; - const [userRows] = await pool.query( + const [userRows] = await pool2.query( `SELECT u.id, u.username, u.display_name, u.role, u.status, COALESCE(w.balance_cents, 0) AS balance_cents FROM h5_users u @@ -5036,7 +11188,7 @@ function createUserAuth(pool, options = {}) { } } } - const [[usage24h]] = await pool.query( + const [[usage24h]] = await pool2.query( `SELECT COUNT(*) AS count, COALESCE(SUM(cost_cents), 0) AS cost_cents FROM h5_usage_records WHERE created_at >= ?`, @@ -5059,14 +11211,14 @@ function createUserAuth(pool, options = {}) { const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? "admin"); const adminPassword = process.env.H5_ADMIN_PASSWORD; if (!adminPassword) return; - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id FROM h5_users WHERE username = ? AND role = 'admin' LIMIT 1`, [adminUsername] ); if (rows.length === 0) return; const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(adminPassword); const now = Date.now(); - await pool.query( + await pool2.query( `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`, [salt, passwordHash, passwordAlgorithm, now, rows[0].id] ); @@ -5074,7 +11226,7 @@ function createUserAuth(pool, options = {}) { const seedRoleCapabilityDefaults = async () => { const now = Date.now(); for (const [key, allowed] of Object.entries(DEFAULT_USER_CAPABILITIES)) { - await pool.query( + await pool2.query( `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) VALUES ('role', 'user', ?, ?, ?) ON DUPLICATE KEY UPDATE capability_key = capability_key`, @@ -5084,7 +11236,7 @@ function createUserAuth(pool, options = {}) { }; const upgradeMemoryStoreCapability = async () => { const now = Date.now(); - await pool.query( + await pool2.query( `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) VALUES ('role', 'user', 'memory_store', 1, ?) ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`, @@ -5095,7 +11247,7 @@ function createUserAuth(pool, options = {}) { const now = Date.now(); for (const key of ["skills", "chat_recall"]) { if (!DEFAULT_USER_CAPABILITIES[key]) continue; - await pool.query( + await pool2.query( `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) VALUES ('role', 'user', ?, 1, ?) ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`, @@ -5107,7 +11259,7 @@ function createUserAuth(pool, options = {}) { const now = Date.now(); for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) { if (!enabled) continue; - await pool.query( + await pool2.query( `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) VALUES ('role', 'user', ?, 1, ?) ON DUPLICATE KEY UPDATE enabled = 1, updated_at = VALUES(updated_at)`, @@ -5128,7 +11280,7 @@ function createUserAuth(pool, options = {}) { const seedRolePolicyDefaults = async () => { const now = Date.now(); for (const [key, value] of Object.entries(DEFAULT_USER_POLICIES)) { - await pool.query( + await pool2.query( `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at) VALUES ('role', 'user', ?, ?, ?) ON DUPLICATE KEY UPDATE policy_key = policy_key`, @@ -5137,7 +11289,7 @@ function createUserAuth(pool, options = {}) { } }; const listPolicyEntries = async (subjectType, subjectId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT policy_key, policy_value FROM h5_user_policies WHERE subject_type = ? AND subject_id = ?`, @@ -5159,7 +11311,7 @@ function createUserAuth(pool, options = {}) { }; }; const listCapabilityGrants = async (subjectType, subjectId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT capability_key, allowed FROM h5_capability_grants WHERE subject_type = ? AND subject_id = ?`, @@ -5223,9 +11375,15 @@ function createUserAuth(pool, options = {}) { try { const layout = await publishLayoutFor(user, { migrateLegacy: false }); sandboxMcp = { - serverPath: resolveSandboxMcpServerPath(), - sandboxRoot: layout.publishDir, - userId: user.id + // When goosed runs in a container its filesystem is split from the portal's, + // so the host paths the portal would otherwise send (node binary, MCP script) + // are not resolvable. These env overrides let the portal send container-canonical + // paths instead. Unset (e.g. local dev, co-located native goosed) => fall back to + // the portal's own paths, so behavior is unchanged. + serverPath: resolveSandboxMcpServerPath(process.env.GOOSED_MCP_SERVER_PATH), + sandboxRoot: resolveGoosedSandboxRoot(h5Root, user), + userId: user.id, + nodeExecPath: process.env.GOOSED_MCP_NODE_PATH }; } catch (err) { console.warn("[getAgentSessionPolicy] sandbox MCP setup failed, falling back:", err?.message); @@ -5258,7 +11416,7 @@ function createUserAuth(pool, options = {}) { const now = Date.now(); for (const [key, allowed] of Object.entries(normalized)) { const effectiveAllowed = USER_NON_GRANTABLE_CAPABILITIES.has(key) ? false : allowed; - await pool.query( + await pool2.query( `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) VALUES ('role', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`, @@ -5292,7 +11450,7 @@ function createUserAuth(pool, options = {}) { for (const [key, allowed] of Object.entries(normalized)) { if (!isValidCapabilityKey(key)) continue; const effectiveAllowed = USER_NON_GRANTABLE_CAPABILITIES.has(key) ? false : allowed; - await pool.query( + await pool2.query( `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) VALUES ('user', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`, @@ -5302,7 +11460,7 @@ function createUserAuth(pool, options = {}) { return getUserCapabilities(userId); }; const clearUserCapabilityOverrides = async (userId) => { - await pool.query( + await pool2.query( `DELETE FROM h5_capability_grants WHERE subject_type = 'user' AND subject_id = ?`, [userId] ); @@ -5320,7 +11478,7 @@ function createUserAuth(pool, options = {}) { const normalized = normalizePolicyPatch(patch); const now = Date.now(); for (const [key, value] of Object.entries(normalized)) { - await pool.query( + await pool2.query( `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at) VALUES ('role', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`, @@ -5353,7 +11511,7 @@ function createUserAuth(pool, options = {}) { const now = Date.now(); for (const [key, value] of Object.entries(normalized)) { if (!policyKeys().includes(key)) continue; - await pool.query( + await pool2.query( `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at) VALUES ('user', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`, @@ -5363,7 +11521,7 @@ function createUserAuth(pool, options = {}) { return getUserPolicies(userId); }; const clearUserPolicyOverrides = async (userId) => { - await pool.query(`DELETE FROM h5_user_policies WHERE subject_type = 'user' AND subject_id = ?`, [ + await pool2.query(`DELETE FROM h5_user_policies WHERE subject_type = 'user' AND subject_id = ?`, [ userId ]); return getUserPolicies(userId); @@ -5380,14 +11538,14 @@ function createUserAuth(pool, options = {}) { const normalized = normalizeSkillPatch(skillCatalog, patch); const now = Date.now(); for (const [name, enabled] of Object.entries(normalized)) { - await pool.query( + await pool2.query( `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) VALUES ('role', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`, [role, name, enabled ? 1 : 0, now] ); } - const [users] = await pool.query(`SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'`); + const [users] = await pool2.query(`SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'`); for (const row of users) { await syncUserSkillsForUser(row); } @@ -5416,7 +11574,7 @@ function createUserAuth(pool, options = {}) { const normalized = normalizeSkillPatch(skillCatalog, patch); const now = Date.now(); for (const [name, enabled] of Object.entries(normalized)) { - await pool.query( + await pool2.query( `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) VALUES ('user', ?, ?, ?, ?) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`, @@ -5427,7 +11585,7 @@ function createUserAuth(pool, options = {}) { return getUserSkills(userId); }; const clearUserSkillOverrides = async (userId) => { - await pool.query(`DELETE FROM h5_user_skill_grants WHERE subject_type = 'user' AND subject_id = ?`, [ + await pool2.query(`DELETE FROM h5_user_skill_grants WHERE subject_type = 'user' AND subject_id = ?`, [ userId ]); const user = await getUserById(userId); @@ -5437,7 +11595,7 @@ function createUserAuth(pool, options = {}) { const ensureAdminUser = async () => { const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? "admin"); const adminPassword = process.env.H5_ADMIN_PASSWORD; - const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ + const [rows] = await pool2.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ adminUsername ]); if (rows.length === 0) { @@ -5457,17 +11615,17 @@ function createUserAuth(pool, options = {}) { username: adminUsername, displayName: "\u7BA1\u7406\u5458" }); - await pool.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [ + await pool2.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [ adminLayout.publishDir, now, adminId ]); - await pool.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [adminId]); - await pool.query( + await pool2.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [adminId]); + await pool2.query( `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, [adminId, adminLayout.publishDir] ); - await pool.query( + await pool2.query( `UPDATE h5_user_wallets SET balance_cents = GREATEST(balance_cents, ?), updated_at = ? WHERE user_id = ?`, [999999999, now, adminId] ); @@ -5485,12 +11643,12 @@ function createUserAuth(pool, options = {}) { }; const PENDING_BIND_TTL_MS = 15 * 60 * 1e3; const issueUserSession = async (userId, role, now = Date.now()) => { - const token = crypto4.randomBytes(32).toString("base64url"); + const token = crypto5.randomBytes(32).toString("base64url"); await storeSession(userId, role, token, now); return token; }; const findBindingByOpenid = async (appId, openid) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT wi.user_id, u.status FROM h5_user_wechat_identities wi JOIN h5_users u ON u.id = wi.user_id @@ -5502,7 +11660,7 @@ function createUserAuth(pool, options = {}) { }; const findBindingByUnionid = async (unionid) => { if (!unionid) return null; - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT wi.user_id, wi.app_id, u.status FROM h5_user_wechat_identities wi JOIN h5_users u ON u.id = wi.user_id @@ -5513,7 +11671,7 @@ function createUserAuth(pool, options = {}) { return rows[0] ?? null; }; const getWechatBindingForUser = async (userId, appId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id, nickname, avatar_url, last_login_at, created_at FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? @@ -5523,7 +11681,7 @@ function createUserAuth(pool, options = {}) { return rows[0] ?? null; }; const getWechatOpenidForUser = async (userId, appId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? @@ -5533,7 +11691,7 @@ function createUserAuth(pool, options = {}) { return rows[0]?.openid ?? null; }; const findWechatUserByOpenid = async (appId, openid) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT wi.user_id, wi.nickname, u.username, u.slug, u.display_name, u.status FROM h5_user_wechat_identities wi JOIN h5_users u ON u.id = wi.user_id @@ -5551,7 +11709,7 @@ function createUserAuth(pool, options = {}) { } : null; }; const getWechatAgentRoute = async (appId, openid) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id, user_id, agent_session_id, status, created_at, updated_at FROM h5_wechat_agent_routes WHERE app_id = ? AND openid = ? @@ -5577,8 +11735,8 @@ function createUserAuth(pool, options = {}) { status = "active", now = Date.now() }) => { - const id = crypto4.randomUUID(); - await pool.query( + const id = crypto5.randomUUID(); + await pool2.query( `INSERT INTO h5_wechat_agent_routes (id, user_id, app_id, openid, agent_session_id, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) @@ -5593,7 +11751,7 @@ function createUserAuth(pool, options = {}) { return route?.id ?? id; }; const clearWechatAgentRoute = async (appId, openid) => { - await pool.query(`DELETE FROM h5_wechat_agent_routes WHERE app_id = ? AND openid = ?`, [ + await pool2.query(`DELETE FROM h5_wechat_agent_routes WHERE app_id = ? AND openid = ?`, [ appId, openid ]); @@ -5605,7 +11763,7 @@ function createUserAuth(pool, options = {}) { now = Date.now() }) => { if (!appId || !openid || !msgId) return { inserted: true }; - const [result] = await pool.query( + const [result] = await pool2.query( `INSERT IGNORE INTO h5_wechat_mp_messages (app_id, openid, msg_id, status, created_at, updated_at) VALUES (?, ?, ?, 'processing', ?, ?)`, @@ -5613,7 +11771,7 @@ function createUserAuth(pool, options = {}) { ); if (Number(result?.affectedRows ?? 0) > 0) return { inserted: true }; const retryCutoff = now - 10 * 60 * 1e3; - const [retryResult] = await pool.query( + const [retryResult] = await pool2.query( `UPDATE h5_wechat_mp_messages SET status = 'processing', agent_session_id = NULL, updated_at = ? WHERE app_id = ? AND openid = ? AND msg_id = ? @@ -5635,7 +11793,7 @@ function createUserAuth(pool, options = {}) { }) => { if (!appId || !openid || !msgId) return; const safeStatus = status === "failed" ? "failed" : "done"; - await pool.query( + await pool2.query( `UPDATE h5_wechat_mp_messages SET status = ?, agent_session_id = COALESCE(?, agent_session_id), updated_at = ? WHERE app_id = ? AND openid = ? AND msg_id = ?`, @@ -5664,8 +11822,8 @@ function createUserAuth(pool, options = {}) { now = Date.now() }) => { if (!appId || !openid || !msgType) return null; - const id = crypto4.randomUUID(); - await pool.query( + const id = crypto5.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, media_id, media_url, media_public_url, media_format, @@ -5715,12 +11873,12 @@ function createUserAuth(pool, options = {}) { return { ok: false, message: "\u4F60\u7684\u8D26\u53F7\u5DF2\u7ED1\u5B9A\u5176\u4ED6\u5FAE\u4FE1\uFF0C\u9700\u5148\u89E3\u7ED1\u540E\u518D\u8BD5" }; } try { - await pool.query( + await pool2.query( `INSERT INTO h5_user_wechat_identities (id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ - crypto4.randomUUID(), + crypto5.randomUUID(), userId, appId, openid, @@ -5748,7 +11906,7 @@ function createUserAuth(pool, options = {}) { avatarUrl, now = Date.now() }) => { - await pool.query( + await pool2.query( `UPDATE h5_user_wechat_identities SET nickname = COALESCE(?, nickname), avatar_url = COALESCE(?, avatar_url), @@ -5760,7 +11918,7 @@ function createUserAuth(pool, options = {}) { ); }; const pruneWechatPendingBinds = async (now = Date.now()) => { - await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE expires_at <= ?`, [now]); + await pool2.query(`DELETE FROM h5_wechat_pending_binds WHERE expires_at <= ?`, [now]); }; const createWechatPendingBind = async ({ appId, @@ -5775,8 +11933,8 @@ function createUserAuth(pool, options = {}) { now = Date.now() }) => { await pruneWechatPendingBinds(now); - const token = crypto4.randomBytes(24).toString("base64url"); - await pool.query( + const token = crypto5.randomBytes(24).toString("base64url"); + await pool2.query( `INSERT INTO h5_wechat_pending_binds (token, app_id, openid, unionid, nickname, avatar_url, return_to, utm_source, utm_medium, utm_campaign, expires_at, created_at) @@ -5801,7 +11959,7 @@ function createUserAuth(pool, options = {}) { const getWechatPendingBind = async (token, now = Date.now()) => { if (!token) return null; await pruneWechatPendingBinds(now); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT token, app_id, openid, unionid, nickname, avatar_url, return_to, utm_source, utm_medium, utm_campaign, expires_at FROM h5_wechat_pending_binds @@ -5814,7 +11972,7 @@ function createUserAuth(pool, options = {}) { return row; }; const consumeWechatPendingBind = async (token) => { - await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE token = ?`, [token]); + await pool2.query(`DELETE FROM h5_wechat_pending_binds WHERE token = ?`, [token]); }; const loginBoundWechatUser = async ({ userId, @@ -5835,22 +11993,22 @@ function createUserAuth(pool, options = {}) { }; const generateWechatUsername = async (openid) => { const cleaned = String(openid).replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); - const suffix = cleaned.slice(-8) || crypto4.randomBytes(4).toString("hex"); + const suffix = cleaned.slice(-8) || crypto5.randomBytes(4).toString("hex"); let candidate = `wx_${suffix}`.slice(0, 32); if (!isValidUsername(candidate)) { - candidate = `wx_${crypto4.randomBytes(4).toString("hex")}`; + candidate = `wx_${crypto5.randomBytes(4).toString("hex")}`; } for (let attempt = 0; attempt < 8; attempt += 1) { - const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 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))}${crypto4.randomBytes(2).toString("hex")}`.slice( + candidate = `wx_${suffix.slice(0, Math.max(1, 8 - attempt))}${crypto5.randomBytes(2).toString("hex")}`.slice( 0, 32 ); } - return `wx_${crypto4.randomBytes(6).toString("hex")}`.slice(0, 32); + return `wx_${crypto5.randomBytes(6).toString("hex")}`.slice(0, 32); }; const registerViaWechat = async ({ appId, @@ -5861,13 +12019,13 @@ function createUserAuth(pool, options = {}) { now = Date.now() }) => { const normalized = await generateWechatUsername(openid); - const randomPassword = crypto4.randomBytes(24).toString("base64url"); + const randomPassword = crypto5.randomBytes(24).toString("base64url"); const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword); - const userId = crypto4.randomUUID(); + const userId = crypto5.randomUUID(); const layout = await publishLayoutFor({ id: userId, username: normalized }); const workspaceRoot = layout.publishDir; const displayName = nickname?.trim() || "\u5FAE\u4FE1\u7528\u6237"; - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); await conn.query( @@ -5903,7 +12061,7 @@ function createUserAuth(pool, options = {}) { (id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ - crypto4.randomUUID(), + crypto5.randomUUID(), userId, appId, openid, @@ -6171,6 +12329,7 @@ function createUserAuth(pool, options = {}) { repairAllUserPublishDirs, registerAgentSession, getSessionNode, + getSessionTarget, unregisterAgentSession, ownsSession, listOwnedSessionIds, @@ -6210,8 +12369,7 @@ function createUserAuth(pool, options = {}) { capabilityCatalog: CAPABILITY_CATALOG, policyCatalog: POLICY_CATALOG, skillCatalog, - publicUser, - getUserById + publicUser }; } function buildUserSessionCookie(token, secure, { domain, maxAge }) { @@ -6291,36 +12449,36 @@ function resolveCookieDomainForRequest(req) { } // wiki-auth.mjs -import crypto5 from "node:crypto"; -import fs8 from "node:fs"; -import path9 from "node:path"; +import crypto6 from "node:crypto"; +import fs9 from "node:fs"; +import path10 from "node:path"; var DB_DIR = ""; var USERS_FILE = ""; var PAGES_DIR = ""; function initPaths(dataDir) { DB_DIR = dataDir; - USERS_FILE = path9.join(DB_DIR, "users.json"); - PAGES_DIR = path9.join(DB_DIR, "pages"); + USERS_FILE = path10.join(DB_DIR, "users.json"); + PAGES_DIR = path10.join(DB_DIR, "pages"); } function ensureDb() { - fs8.mkdirSync(DB_DIR, { recursive: true }); - fs8.mkdirSync(PAGES_DIR, { recursive: true }); - if (!fs8.existsSync(USERS_FILE)) { - fs8.writeFileSync(USERS_FILE, JSON.stringify({ users: [] }, null, 2), "utf-8"); + fs9.mkdirSync(DB_DIR, { recursive: true }); + fs9.mkdirSync(PAGES_DIR, { recursive: true }); + if (!fs9.existsSync(USERS_FILE)) { + fs9.writeFileSync(USERS_FILE, JSON.stringify({ users: [] }, null, 2), "utf-8"); } } function readUsers() { ensureDb(); - return JSON.parse(fs8.readFileSync(USERS_FILE, "utf-8")); + return JSON.parse(fs9.readFileSync(USERS_FILE, "utf-8")); } function writeUsers(data) { ensureDb(); - fs8.writeFileSync(USERS_FILE, JSON.stringify(data, null, 2), "utf-8"); + fs9.writeFileSync(USERS_FILE, JSON.stringify(data, null, 2), "utf-8"); } function safeEqual3(a, b) { const ba = Buffer.from(a); const bb = Buffer.from(b); - return ba.length === bb.length && crypto5.timingSafeEqual(ba, bb); + return ba.length === bb.length && crypto6.timingSafeEqual(ba, bb); } function createWikiAuth(dataDir) { initPaths(dataDir); @@ -6328,17 +12486,17 @@ function createWikiAuth(dataDir) { const sessions = /* @__PURE__ */ new Map(); const COOKIE_NAME = "wiki_session"; function hashPassword2(password, salt) { - return crypto5.pbkdf2Sync(password, salt, 1e5, 64, "sha512").toString("hex"); + return crypto6.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 = crypto5.randomBytes(16).toString("hex"); + const salt = crypto6.randomBytes(16).toString("hex"); const hashed = hashPassword2(password, salt); const user = { - id: crypto5.randomUUID(), + id: crypto6.randomUUID(), username, displayName: displayName || username, salt, @@ -6357,7 +12515,7 @@ function createWikiAuth(dataDir) { if (!safeEqual3(hashed, user.hashedPassword)) { return { ok: false, message: "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" }; } - const token = crypto5.randomBytes(32).toString("base64url"); + const token = crypto6.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 } }; } @@ -6386,10 +12544,10 @@ function createWikiAuth(dataDir) { } function listPages(username) { ensureDb(); - const userPagesDir = path9.join(PAGES_DIR, username); - if (!fs8.existsSync(userPagesDir)) return []; - return fs8.readdirSync(userPagesDir).filter((f) => f.endsWith(".json")).map((f) => { - const data = JSON.parse(fs8.readFileSync(path9.join(userPagesDir, f), "utf-8")); + const userPagesDir = path10.join(PAGES_DIR, username); + if (!fs9.existsSync(userPagesDir)) return []; + return fs9.readdirSync(userPagesDir).filter((f) => f.endsWith(".json")).map((f) => { + const data = JSON.parse(fs9.readFileSync(path10.join(userPagesDir, f), "utf-8")); return { id: data.id, title: data.title, @@ -6402,18 +12560,18 @@ function createWikiAuth(dataDir) { } function getPage(username, slug) { ensureDb(); - const filePath = path9.join(PAGES_DIR, username, `${slug}.json`); - if (!fs8.existsSync(filePath)) return null; - return JSON.parse(fs8.readFileSync(filePath, "utf-8")); + const filePath = path10.join(PAGES_DIR, username, `${slug}.json`); + if (!fs9.existsSync(filePath)) return null; + return JSON.parse(fs9.readFileSync(filePath, "utf-8")); } function savePage(username, slug, title, content, tags) { ensureDb(); - const userPagesDir = path9.join(PAGES_DIR, username); - fs8.mkdirSync(userPagesDir, { recursive: true }); - const filePath = path9.join(userPagesDir, `${slug}.json`); - const existing = fs8.existsSync(filePath) ? JSON.parse(fs8.readFileSync(filePath, "utf-8")) : null; + const userPagesDir = path10.join(PAGES_DIR, username); + fs9.mkdirSync(userPagesDir, { recursive: true }); + 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 || crypto5.randomUUID(), + id: existing?.id || crypto6.randomUUID(), slug, title: title || slug, content: content || "", @@ -6422,14 +12580,14 @@ function createWikiAuth(dataDir) { createdAt: existing?.createdAt || Date.now(), updatedAt: Date.now() }; - fs8.writeFileSync(filePath, JSON.stringify(page, null, 2), "utf-8"); + fs9.writeFileSync(filePath, JSON.stringify(page, null, 2), "utf-8"); return page; } function deletePage(username, slug) { ensureDb(); - const filePath = path9.join(PAGES_DIR, username, `${slug}.json`); - if (fs8.existsSync(filePath)) { - fs8.unlinkSync(filePath); + const filePath = path10.join(PAGES_DIR, username, `${slug}.json`); + if (fs9.existsSync(filePath)) { + fs9.unlinkSync(filePath); return true; } return false; @@ -6470,10 +12628,10 @@ var PUBLIC_SCHEME = (process.env.LOCAL_TEST_SCHEME ?? (String(process.env.LOCAL_ var PUBLIC_PORT = Number( process.env.LOCAL_TEST_PUBLIC_PORT ?? (USES_LOCALHOST ? 8443 : PUBLIC_SCHEME === "https" ? 443 : 80) ); -function publicUrl(host, { path: path23 = "" } = {}) { +function publicUrl(host, { path: path29 = "" } = {}) { const defaultPort = PUBLIC_SCHEME === "https" ? 443 : 80; const portSuffix = PUBLIC_PORT === defaultPort ? "" : `:${PUBLIC_PORT}`; - const normalizedPath = path23.startsWith("/") ? path23 : path23 ? `/${path23}` : ""; + const normalizedPath = path29.startsWith("/") ? path29 : path29 ? `/${path29}` : ""; return `${PUBLIC_SCHEME}://${host}${portSuffix}${normalizedPath}`; } var H5_PUBLIC_BASE = (process.env.H5_PUBLIC_BASE_URL ?? publicUrl(H5_HOST)).replace(/\/$/, ""); @@ -6485,13 +12643,13 @@ function isLocalDevHostname(hostname) { } // mindspace-workspace-thumbnails.mjs -import fs10 from "node:fs"; +import fs11 from "node:fs"; import fsPromises from "node:fs/promises"; -import path11 from "node:path"; +import path12 from "node:path"; // mindspace-thumbnails.mjs -import fs9 from "node:fs/promises"; -import path10 from "node:path"; +import fs10 from "node:fs/promises"; +import path11 from "node:path"; var FEED_WIDTH = 540; var FEED_HEIGHT = 720; var MAX_COVER_BYTES = 1.5 * 1024 * 1024; @@ -6562,7 +12720,7 @@ function bufferToImageDataUri(buffer) { return bytesToDataUri(buffer, mimeFromImageBytes(buffer)); } async function readLocalImageDataUri(filePath) { - const buffer = await fs9.readFile(filePath); + const buffer = await fs10.readFile(filePath); if (buffer.length === 0 || buffer.length > MAX_COVER_BYTES) return null; return bytesToDataUri(buffer, mimeFromImageBytes(buffer)); } @@ -6584,18 +12742,24 @@ function resolveLocalImagePath({ storageRoot, contentStorageKey, contentBaseDir, if (!imageUrl || /^data:|^https?:/i.test(imageUrl)) return null; let baseDir = null; if (contentStorageKey) { - baseDir = path10.dirname(path10.resolve(storageRoot, contentStorageKey)); - const root = path10.resolve(storageRoot); - if (baseDir !== root && !baseDir.startsWith(`${root}${path10.sep}`)) return null; + baseDir = path11.dirname(path11.resolve(storageRoot, contentStorageKey)); + const root = path11.resolve(storageRoot); + if (baseDir !== root && !baseDir.startsWith(`${root}${path11.sep}`)) return null; } else if (contentBaseDir) { - baseDir = path10.resolve(contentBaseDir); + baseDir = path11.resolve(contentBaseDir); } if (!baseDir) return null; - const target = path10.resolve(baseDir, imageUrl.replace(/^\.\//, "")); - if (target !== baseDir && !target.startsWith(`${baseDir}${path10.sep}`)) return null; + const target = path11.resolve(baseDir, imageUrl.replace(/^\.\//, "")); + if (target !== baseDir && !target.startsWith(`${baseDir}${path11.sep}`)) return null; return target; } -async function resolveCoverDataUri({ storageRoot, contentStorageKey, contentBaseDir, imageUrl }) { +async function resolveCoverDataUri({ + storageRoot, + contentStorageKey, + contentBaseDir, + imageUrl, + resolveAssetDataUri +}) { const raw = String(imageUrl ?? "").trim(); if (!raw) return null; if (raw.startsWith("data:")) return raw.length <= MAX_COVER_BYTES * 2 ? raw : null; @@ -6606,6 +12770,14 @@ async function resolveCoverDataUri({ storageRoot, contentStorageKey, contentBase return null; } } + const assetMatch = raw.match(/\/mindspace\/v1\/assets\/([a-z0-9-]+)(?:\/download)?/i); + if (assetMatch && resolveAssetDataUri) { + try { + return await resolveAssetDataUri(assetMatch[1]); + } catch { + return null; + } + } const localPath = resolveLocalImagePath({ storageRoot, contentStorageKey, contentBaseDir, imageUrl: raw }); if (!localPath) return null; try { @@ -6838,19 +13010,19 @@ function buildFeedThumbnailSvg(signals, options = {}) { `; } function assetThumbnailKey(userId, assetId) { - return path10.posix.join("users", userId, "assets", assetId, "thumbnail.svg"); + return path11.posix.join("users", userId, "assets", assetId, "thumbnail.svg"); } function pageThumbnailKey(userId, pageId) { - return path10.posix.join("users", userId, "pages", pageId, "thumbnail.svg"); + return path11.posix.join("users", userId, "pages", pageId, "thumbnail.svg"); } async function writeThumbnail(storageRoot, storageKey, svg) { - const target = path10.resolve(storageRoot, storageKey); - const root = path10.resolve(storageRoot); - if (target !== root && !target.startsWith(`${root}${path10.sep}`)) { + const target = path11.resolve(storageRoot, storageKey); + const root = path11.resolve(storageRoot); + if (target !== root && !target.startsWith(`${root}${path11.sep}`)) { throw new Error("\u7F29\u7565\u56FE\u8DEF\u5F84\u8D8A\u754C"); } - await fs9.mkdir(path10.dirname(target), { recursive: true }); - await fs9.writeFile(target, svg, "utf8"); + await fs10.mkdir(path11.dirname(target), { recursive: true }); + await fs10.writeFile(target, svg, "utf8"); return target; } function isModernFeedThumbnail(svg) { @@ -6863,7 +13035,8 @@ async function generateHtmlThumbnail(storageRoot, storageKey, html, meta = {}) { storageRoot, contentStorageKey: meta.contentStorageKey, contentBaseDir: meta.contentBaseDir, - imageUrl: signals.image + imageUrl: signals.image, + resolveAssetDataUri: meta.resolveAssetDataUri }); const svg = buildFeedThumbnailSvg(signals, { coverDataUri }); await writeThumbnail(storageRoot, storageKey, svg); @@ -6898,7 +13071,7 @@ async function ensurePageThumbnail({ } async function readThumbnailIfExists(storageRoot, storageKey) { try { - return await fs9.readFile(path10.resolve(storageRoot, storageKey), "utf8"); + return await fs10.readFile(path11.resolve(storageRoot, storageKey), "utf8"); } catch { return null; } @@ -6913,26 +13086,26 @@ function scheduleHtmlThumbnail(storageRoot, storageKey, html, meta = {}) { // mindspace-workspace-thumbnails.mjs function workspaceThumbnailRelativePath(htmlRelativePath) { const normalized = String(htmlRelativePath ?? "").replace(/^\/+/, ""); - const dir = path11.posix.dirname(normalized); - const base = path11.basename(normalized, path11.extname(normalized)); + const dir = path12.posix.dirname(normalized); + const base = path12.basename(normalized, path12.extname(normalized)); const thumb = `${base}.thumbnail.svg`; - return dir === "." ? thumb : path11.posix.join(dir, thumb); + return dir === "." ? thumb : path12.posix.join(dir, thumb); } async function ensureWorkspaceHtmlThumbnail(publishDir, htmlRelativePath, html, meta = {}) { - const htmlPath = path11.join(publishDir, htmlRelativePath); + const htmlPath = path12.join(publishDir, htmlRelativePath); const content = html ?? await fsPromises.readFile(htmlPath, "utf8"); const thumbRel = workspaceThumbnailRelativePath(htmlRelativePath); return ensureHtmlThumbnail(publishDir, thumbRel, content, { ...meta, - contentBaseDir: path11.dirname(htmlPath) + contentBaseDir: path12.dirname(htmlPath) }); } async function scanPublishTree(publishRoot) { - if (!fs10.existsSync(publishRoot)) return; + if (!fs11.existsSync(publishRoot)) return; const entries = await fsPromises.readdir(publishRoot, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory() || entry.name === "wiki" || entry.name.startsWith(".")) continue; - const userDir = path11.join(publishRoot, entry.name); + const userDir = path12.join(publishRoot, entry.name); await scanUserHtmlFiles(userDir); } } @@ -6940,14 +13113,14 @@ async function scanUserHtmlFiles(userDir) { const walk = async (dir) => { const entries = await fsPromises.readdir(dir, { withFileTypes: true }); for (const entry of entries) { - const full = path11.join(dir, entry.name); + const full = path12.join(dir, entry.name); if (entry.isDirectory()) { if (entry.name === ".agents" || entry.name === "node_modules") continue; await walk(full); continue; } if (!entry.name.endsWith(".html") || entry.name.endsWith(".thumbnail.svg")) continue; - const rel = path11.relative(userDir, full); + const rel = path12.relative(userDir, full); await ensureWorkspaceHtmlThumbnail(userDir, rel).catch(() => { }); } @@ -6955,13 +13128,13 @@ async function scanUserHtmlFiles(userDir) { await walk(userDir); } function startWorkspaceThumbnailWatcher(publishRoot) { - if (!fs10.existsSync(publishRoot)) { - fs10.mkdirSync(publishRoot, { recursive: true }); + if (!fs11.existsSync(publishRoot)) { + fs11.mkdirSync(publishRoot, { recursive: true }); } void scanPublishTree(publishRoot); const pending = /* @__PURE__ */ new Map(); const schedule = (userDir, relativePath) => { - const key = path11.join(userDir, relativePath); + const key = path12.join(userDir, relativePath); const existing = pending.get(key); if (existing) clearTimeout(existing); pending.set( @@ -6974,10 +13147,10 @@ function startWorkspaceThumbnailWatcher(publishRoot) { ); }; const attachUserWatcher = (userDir) => { - if (!fs10.existsSync(userDir)) return; + if (!fs11.existsSync(userDir)) return; void scanUserHtmlFiles(userDir); try { - fs10.watch(userDir, { recursive: true }, (_event, filename) => { + fs11.watch(userDir, { recursive: true }, (_event, filename) => { if (!filename || !String(filename).endsWith(".html")) return; if (String(filename).endsWith(".thumbnail.svg")) return; schedule(userDir, filename); @@ -6985,16 +13158,16 @@ function startWorkspaceThumbnailWatcher(publishRoot) { } catch { } }; - for (const entry of fs10.readdirSync(publishRoot, { withFileTypes: true })) { + for (const entry of fs11.readdirSync(publishRoot, { withFileTypes: true })) { if (entry.isDirectory() && entry.name !== "wiki" && !entry.name.startsWith(".")) { - attachUserWatcher(path11.join(publishRoot, entry.name)); + attachUserWatcher(path12.join(publishRoot, entry.name)); } } try { - fs10.watch(publishRoot, (_event, filename) => { + fs11.watch(publishRoot, (_event, filename) => { if (!filename) return; - const userDir = path11.join(publishRoot, filename); - if (fs10.existsSync(userDir) && fs10.statSync(userDir).isDirectory()) { + const userDir = path12.join(publishRoot, filename); + if (fs11.existsSync(userDir) && fs11.statSync(userDir).isDirectory()) { attachUserWatcher(userDir); } }); @@ -7003,15 +13176,15 @@ function startWorkspaceThumbnailWatcher(publishRoot) { } // mindspace-workspace-sync.mjs -import crypto7 from "node:crypto"; -import fs12 from "node:fs"; +import crypto8 from "node:crypto"; +import fs13 from "node:fs"; import fsPromises2 from "node:fs/promises"; -import path13 from "node:path"; +import path17 from "node:path"; // mindspace-assets.mjs -import crypto6 from "node:crypto"; -import fs11 from "node:fs/promises"; -import path12 from "node:path"; +import crypto7 from "node:crypto"; +import fs12 from "node:fs/promises"; +import path16 from "node:path"; // mindspace-scan.mjs var SCRIPT_PATTERNS = [ @@ -7231,6 +13404,91 @@ function extractAttachmentText(buffer, mimeType, filename = "") { return { text: "", format: "unsupported", warnings: ["unsupported_attachment_type"] }; } +// user-image-url.mjs +import path13 from "node:path"; +var PUBLIC_IMAGES_DIR = "images"; +var cachedSigner = null; +var signerInitAttempted = false; +function resolveImgproxyBaseUrl(env = process.env) { + return (env.IMGPROXY_BASE_URL ?? "http://localhost:20081").replace(/\/$/, ""); +} +function getImgproxySigner(env = process.env) { + if (signerInitAttempted) return cachedSigner; + signerInitAttempted = true; + const key = env.IMGPROXY_SIGNING_KEY?.trim(); + const salt = env.IMGPROXY_SIGNING_SALT?.trim(); + if (!key || !salt) return null; + try { + cachedSigner = createImgproxySigner(key, salt); + } catch { + cachedSigner = null; + } + return cachedSigner; +} +function formatImageDateFolder(date = /* @__PURE__ */ new Date()) { + const value = date instanceof Date ? date : new Date(date); + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} +function extensionForMime(mimeType, fallbackFilename = "") { + const ext = path13.extname(String(fallbackFilename)).toLowerCase(); + if (ext && /^\.[a-z0-9]{1,8}$/.test(ext)) return ext === ".jpeg" ? ".jpg" : ext; + if (mimeType === "image/jpeg") return ".jpg"; + if (mimeType === "image/png") return ".png"; + if (mimeType === "image/webp") return ".webp"; + if (mimeType === "image/gif") return ".gif"; + return ".img"; +} +function sanitizeImageBasename(filename, assetId) { + const raw = path13.basename(String(filename ?? ""), path13.extname(String(filename ?? ""))).trim(); + const normalized = raw.normalize("NFKC").replace(/[^\w\u4e00-\u9fff.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80); + return normalized || assetId.slice(0, 8); +} +function buildPublicImagePaths({ + userId, + assetId, + mimeType, + originalFilename, + date = /* @__PURE__ */ new Date(), + uniqueSuffix = null +}) { + const dateFolder = formatImageDateFolder(date); + const extension = extensionForMime(mimeType, originalFilename); + const basename = sanitizeImageBasename(originalFilename, assetId); + const suffix = uniqueSuffix ? `-${uniqueSuffix}` : ""; + const filename = `${basename}${suffix}${extension}`; + const storageKey = path13.posix.join("users", userId, PUBLIC_IMAGES_DIR, dateFolder, filename); + const workspaceRelativePath = path13.posix.join("public", PUBLIC_IMAGES_DIR, dateFolder, filename); + return { storageKey, workspaceRelativePath, filename, dateFolder }; +} +function buildImgproxyDisplayUrl(storageKey, options = {}) { + const baseUrl = (options.baseUrl ?? resolveImgproxyBaseUrl()).replace(/\/$/, ""); + const signer = options.signer ?? getImgproxySigner(); + if (signer) { + return signer.buildUrl(baseUrl, storageKey, options.preset ?? "display"); + } + const preset = options.preset ?? "display"; + if (preset === "vision") { + return `${baseUrl}/unsafe/rs:fit:768:768:0/q:60/plain/local:///${storageKey}@jpg`; + } + if (preset === "thumb") { + return `${baseUrl}/unsafe/rs:fit:256:256:0/q:70/plain/local:///${storageKey}@jpg`; + } + return `${baseUrl}/unsafe/rs:fit:1280:1280:0/q:85/plain/local:///${storageKey}@jpg`; +} +function buildUserImagePublicUrl({ userId, storageKey, mimeType, originalFilename }) { + void userId; + void mimeType; + void originalFilename; + return buildImgproxyDisplayUrl(storageKey); +} +function isPublicImageStorageKey(storageKey) { + const normalized = String(storageKey ?? "").replace(/\\/g, "/"); + return /\/images\/\d{4}-\d{2}-\d{2}\//.test(normalized); +} + // mindspace-asset-preview.mjs import zlib2 from "node:zlib"; var PREVIEWABLE_MIME_TYPES = /* @__PURE__ */ new Set([ @@ -7515,6 +13773,194 @@ function renderAssetPreviewHtml({ asset, buffer, downloadUrl }) { throw Object.assign(new Error("\u8BE5\u8D44\u4EA7\u4E0D\u652F\u6301\u9884\u89C8"), { code: "preview_not_supported" }); } +// workspace-storage.mjs +import path14 from "node:path"; +var WORKSPACE_STORAGE_PREFIX = "workspace://"; +function buildWorkspaceStorageKey(userId, categoryCode, relativeFilename) { + const category = String(categoryCode ?? "").trim(); + const relativePath = String(relativeFilename ?? "").replace(/\\/g, "/").replace(/^\/+/, ""); + if (!userId || !category || !relativePath) { + throw new Error("invalid workspace storage key parts"); + } + if (relativePath.split("/").some((part) => part === ".." || part === ".")) { + throw new Error("invalid workspace relative path"); + } + return `${WORKSPACE_STORAGE_PREFIX}${userId}/${category}/${relativePath}`; +} +function isWorkspaceStorageKey(storageKey) { + return String(storageKey ?? "").startsWith(WORKSPACE_STORAGE_PREFIX); +} +function resolveWorkspaceStoragePath(h5Root, storageKey) { + if (!h5Root || !isWorkspaceStorageKey(storageKey)) return null; + const rest = storageKey.slice(WORKSPACE_STORAGE_PREFIX.length); + const slash = rest.indexOf("/"); + if (slash <= 0) return null; + const userId = rest.slice(0, slash); + const remainder = rest.slice(slash + 1); + const slash2 = remainder.indexOf("/"); + if (slash2 <= 0) return null; + const categoryCode = remainder.slice(0, slash2); + const relativeFilename = remainder.slice(slash2 + 1); + if (!relativeFilename) return null; + const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); + const resolved = path14.resolve(resolveZoneFilePath(workspaceRoot, categoryCode, relativeFilename)); + const zoneRoot = path14.resolve(resolveZoneFilePath(workspaceRoot, categoryCode, "")); + if (resolved !== zoneRoot && !resolved.startsWith(`${zoneRoot}${path14.sep}`)) { + return null; + } + return resolved; +} + +// user-image-normalize.mjs +import path15 from "node:path"; +import sharp from "sharp"; +var DEFAULT_IMAGE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; +var DEFAULT_IMAGE_MASTER_MAX_BYTES = 6 * 1024 * 1024; +var DEFAULT_IMAGE_MASTER_MAX_SIDE = 2560; +var DEFAULT_IMAGE_INPUT_MAX_PIXELS = 4e7; +var JPEG_MIN_QUALITY = 62; +var WEBP_MIN_QUALITY = 64; +var MIN_SIDE = 1280; +function fitDimensions(width, height, maxSide, maxPixels) { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return { width: maxSide, height: maxSide }; + } + if (width <= maxSide && height <= maxSide && width * height <= maxPixels) { + return { width, height }; + } + const scale = Math.min(maxSide / width, maxSide / height, Math.sqrt(maxPixels / (width * height))); + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)) + }; +} +function buildOutputProfile(mimeType) { + if (mimeType === "image/png") { + return { + extension: ".png", + mimeType: "image/png", + baseQuality: 90, + minQuality: 70, + encode: (pipeline, quality) => pipeline.png({ + compressionLevel: 9, + adaptiveFiltering: true, + palette: true, + quality, + effort: 8 + }) + }; + } + if (mimeType === "image/webp") { + return { + extension: ".webp", + mimeType: "image/webp", + baseQuality: 84, + minQuality: WEBP_MIN_QUALITY, + encode: (pipeline, quality) => pipeline.webp({ + quality, + alphaQuality: Math.min(100, quality + 8), + effort: 5 + }) + }; + } + return { + extension: ".jpg", + mimeType: "image/jpeg", + baseQuality: 84, + minQuality: JPEG_MIN_QUALITY, + encode: (pipeline, quality) => pipeline.jpeg({ + quality, + mozjpeg: true, + chromaSubsampling: "4:4:4" + }) + }; +} +function deriveOutputFilename(filename, extension) { + const raw = path15.basename(String(filename ?? ""), path15.extname(String(filename ?? ""))).trim() || "image"; + return `${raw}${extension}`; +} +async function renderVariant(buffer, metadata, profile, { width, height, quality, maxPixels }) { + const pipeline = sharp(buffer, { + failOn: "error", + limitInputPixels: maxPixels, + sequentialRead: true + }).rotate().resize({ + width, + height, + fit: "inside", + withoutEnlargement: true + }); + const encoded = await profile.encode(pipeline, quality).toBuffer(); + return encoded; +} +async function normalizeImageForStorage({ + buffer, + mimeType, + filename, + maxUploadBytes = DEFAULT_IMAGE_UPLOAD_MAX_BYTES, + maxOutputBytes = DEFAULT_IMAGE_MASTER_MAX_BYTES, + maxSide = DEFAULT_IMAGE_MASTER_MAX_SIDE, + maxPixels = DEFAULT_IMAGE_INPUT_MAX_PIXELS +} = {}) { + if (!Buffer.isBuffer(buffer) || buffer.length === 0) { + throw Object.assign(new Error("\u56FE\u7247\u5185\u5BB9\u4E3A\u7A7A"), { code: "invalid_file_size" }); + } + if (!mimeType?.startsWith("image/")) { + throw Object.assign(new Error("\u53EA\u652F\u6301\u56FE\u7247\u6587\u4EF6"), { code: "unsupported_file_type" }); + } + if (buffer.length > maxUploadBytes) { + throw Object.assign(new Error("\u56FE\u7247\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), { code: "file_too_large" }); + } + const metadata = await sharp(buffer, { + failOn: "error", + limitInputPixels: maxPixels, + sequentialRead: true + }).metadata(); + if (!metadata.width || !metadata.height) { + throw Object.assign(new Error("\u65E0\u6CD5\u8BC6\u522B\u56FE\u7247\u5C3A\u5BF8"), { code: "image_metadata_invalid" }); + } + if (metadata.pages && metadata.pages > 1) { + throw Object.assign(new Error("\u6682\u4E0D\u652F\u6301\u52A8\u6001\u56FE\u50CF\u4E0A\u4F20"), { code: "unsupported_animated_image" }); + } + const profile = buildOutputProfile(mimeType); + const initial = fitDimensions(metadata.width, metadata.height, maxSide, maxPixels); + let width = initial.width; + let height = initial.height; + let quality = profile.baseQuality; + let candidate = await renderVariant(buffer, metadata, profile, { width, height, quality, maxPixels }); + while (candidate.length > maxOutputBytes) { + if (quality > profile.minQuality) { + quality = Math.max(profile.minQuality, quality - 6); + } else if (Math.max(width, height) > MIN_SIDE) { + width = Math.max(1, Math.round(width * 0.85)); + height = Math.max(1, Math.round(height * 0.85)); + } else { + break; + } + candidate = await renderVariant(buffer, metadata, profile, { width, height, quality, maxPixels }); + } + const outputFilename = deriveOutputFilename(filename, profile.extension); + const output = { + buffer: candidate, + mimeType: profile.mimeType, + filename: outputFilename, + width, + height + }; + const preservesFormat = profile.mimeType === mimeType; + const alreadyWithinBounds = metadata.width <= maxSide && metadata.height <= maxSide && metadata.width * metadata.height <= maxPixels && buffer.length <= maxOutputBytes; + if (preservesFormat && alreadyWithinBounds && candidate.length >= buffer.length) { + return { + buffer, + mimeType, + filename, + width: metadata.width, + height: metadata.height + }; + } + return output; +} + // mindspace-assets.mjs var ALLOWED_EXTENSIONS = /* @__PURE__ */ new Map([ [".txt", "text/plain"], @@ -7534,7 +13980,7 @@ var ALLOWED_EXTENSIONS = /* @__PURE__ */ new Map([ [".html", "text/html"], [".htm", "text/html"] ]); -var MAX_IMAGE_UPLOAD_BYTES = 1536 * 1024; +var MAX_IMAGE_UPLOAD_BYTES = DEFAULT_IMAGE_UPLOAD_MAX_BYTES; var PUBLIC_TEMP_IMAGE_DIR = ".tmp-images"; var PUBLIC_IMAGE_EXTENSIONS = /* @__PURE__ */ new Map([ ["image/png", ".png"], @@ -7552,7 +13998,54 @@ function normalizeFilename(filename) { return normalized.slice(0, 255); } function expectedMimeType(filename) { - return ALLOWED_EXTENSIONS.get(path12.extname(filename).toLowerCase()) ?? null; + return ALLOWED_EXTENSIONS.get(path16.extname(filename).toLowerCase()) ?? null; +} +function extractImageDimensions(buffer, mimeType) { + if (mimeType === "image/png" && buffer.length >= 24) { + const width = buffer.readUInt32BE(16); + const height = buffer.readUInt32BE(20); + return { width, height }; + } + if (mimeType === "image/jpeg" && buffer.length >= 2) { + let offset = 2; + while (offset < buffer.length - 9) { + if (buffer[offset] !== 255) break; + const marker = buffer[offset + 1]; + if (marker === 217) break; + if (marker >= 208 && marker <= 216) { + offset += 2; + continue; + } + const segmentLength = buffer.readUInt16BE(offset + 2); + if (marker === 192 || marker === 193 || marker === 194) { + const height = buffer.readUInt16BE(offset + 5); + const width = buffer.readUInt16BE(offset + 7); + return { width, height }; + } + offset += segmentLength + 2; + } + } + if (mimeType === "image/webp" && buffer.length >= 30) { + const width = buffer.readUInt32LE(24) + 1; + const height = buffer.readUInt32LE(28) + 1; + return { width, height }; + } + return null; +} +function validateImagePixels(buffer, mimeType) { + if (!mimeType?.startsWith("image/")) return null; + const dims = extractImageDimensions(buffer, mimeType); + if (!dims) return { ok: true }; + const { width, height } = dims; + const megapixels = width * height / 1e6; + const maxMegapixels = DEFAULT_IMAGE_INPUT_MAX_PIXELS / 1e6; + if (megapixels > maxMegapixels) { + throw Object.assign( + new Error(`\u56FE\u7247\u50CF\u7D20\u8D85\u8FC7\u9650\u5236\uFF08${megapixels.toFixed(1)}MP > ${maxMegapixels.toFixed(0)}MP\uFF09`), + { code: "image_pixel_exceeded" } + ); + } + return { ok: true, width, height }; } function detectMimeType(buffer, filename) { const head = buffer.subarray(0, 256).toString("utf8").trimStart().toLowerCase(); @@ -7568,7 +14061,7 @@ function detectMimeType(buffer, filename) { const extensionMime = expectedMimeType(filename); if (buffer.subarray(0, 2).toString("ascii") === "PK") return extensionMime; if (extensionMime?.startsWith("text/")) return extensionMime; - if ([".doc", ".xls", ".ppt"].includes(path12.extname(filename).toLowerCase())) { + if ([".doc", ".xls", ".ppt"].includes(path16.extname(filename).toLowerCase())) { return extensionMime; } return null; @@ -7586,23 +14079,44 @@ function assetTypeForMime(mimeType) { function visibilityForCategory(categoryCode) { return categoryCode === "public" ? "public_candidate" : "private"; } +var UPLOADABLE_CATEGORY_CODES = ["oa", "public"]; +function resolvePublicImagePaths(userId, assetId, mimeType, filename, uniqueSuffix = null) { + const suffix = uniqueSuffix ?? String(assetId ?? "").replace(/-/g, "").slice(0, 12); + return buildPublicImagePaths({ + userId, + assetId, + mimeType, + originalFilename: filename, + uniqueSuffix: suffix + }); +} function isPublicImageAsset(row) { - return row?.category_code === "public" && String(row?.mime_type ?? "").startsWith("image/"); + const mime = String(row?.mime_type ?? ""); + if (!mime.startsWith("image/")) return false; + if (isPublicImageStorageKey(row?.storage_key)) return true; + return row?.category_code === "public"; +} +function publicUrlForAsset(row) { + if (!isPublicImageAsset(row)) return null; + if (row?.storage_key && isPublicImageStorageKey(row.storage_key)) { + return buildUserImagePublicUrl({ + userId: row.user_id, + storageKey: row.storage_key, + mimeType: row.mime_type, + originalFilename: row.original_filename + }); + } + if (row?.category_code === "public" && row?.id) { + const legacy = publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename); + return legacy; + } + return null; } function publicTempImageFilename(assetId, mimeType, fallbackFilename = "") { - const fallbackExtension = path12.extname(String(fallbackFilename)).toLowerCase(); + const fallbackExtension = path16.extname(String(fallbackFilename)).toLowerCase(); const extension = PUBLIC_IMAGE_EXTENSIONS.get(mimeType) || fallbackExtension || ".img"; return `${assetId}${extension}`; } -function publicTempImageStorageKey(userId, assetId, mimeType, fallbackFilename = "") { - return path12.posix.join( - "users", - userId, - PUBLIC_ZONE_DIR, - PUBLIC_TEMP_IMAGE_DIR, - publicTempImageFilename(assetId, mimeType, fallbackFilename) - ); -} function publicTempImageUrl(userId, assetId, mimeType, fallbackFilename = "") { const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename); return `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(userId)}/${PUBLIC_ZONE_DIR}/${PUBLIC_TEMP_IMAGE_DIR}/${encodeURIComponent(filename)}`; @@ -7624,7 +14138,7 @@ function assetResponse(row) { scanStatus: row.scan_status ?? "passed", sourceType: row.source_type, hasThumbnail: Boolean(row.has_thumbnail ?? row.mime_type === "text/html"), - publicUrl: isPublicImageAsset(row) ? publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename) : null, + publicUrl: publicUrlForAsset(row), sourcePageId: row.source_page_id ?? null, createdAt: asNumber2(row.created_at), updatedAt: asNumber2(row.updated_at) @@ -7647,26 +14161,31 @@ function validateUploadRequest({ filename, sizeBytes, maxFileBytes = DEFAULT_MAX if (!Number.isSafeInteger(normalizedSize) || normalizedSize <= 0) { throw Object.assign(new Error("\u6587\u4EF6\u4E0D\u80FD\u4E3A\u7A7A"), { code: "invalid_file_size" }); } - if (normalizedSize > maxFileBytes) { - throw Object.assign(new Error("\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), { code: "file_too_large" }); - } const mimeType = expectedMimeType(normalizedFilename); if (!mimeType) { throw Object.assign(new Error("\u6682\u4E0D\u652F\u6301\u8BE5\u6587\u4EF6\u7C7B\u578B"), { code: "unsupported_file_type" }); } + const effectiveMaxBytes = mimeType.startsWith("image/") ? MAX_IMAGE_UPLOAD_BYTES : maxFileBytes; + if (normalizedSize > effectiveMaxBytes) { + throw Object.assign( + new Error(mimeType.startsWith("image/") ? "\u56FE\u7247\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236" : "\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), + { code: "file_too_large" } + ); + } if (mimeType.startsWith("image/") && normalizedSize > MAX_IMAGE_UPLOAD_BYTES) { throw Object.assign(new Error("\u56FE\u7247\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), { code: "file_too_large" }); } return { filename: normalizedFilename, sizeBytes: normalizedSize, expectedMimeType: mimeType }; } -function createAssetService(pool, options = {}) { - const storageRoot = path12.resolve(options.storageRoot ?? path12.join(process.cwd(), "data", "mindspace")); - const h5Root = options.h5Root ? path12.resolve(options.h5Root) : null; +function createAssetService(pool2, options = {}) { + const storageRoot = path16.resolve(options.storageRoot ?? path16.join(process.cwd(), "data", "mindspace")); + 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 ?? (() => crypto6.randomUUID()); + const idFactory = options.idFactory ?? (() => crypto7.randomUUID()); + const normalizeStoredImage = options.normalizeStoredImage ?? normalizeImageForStorage; const workspaceSync = createWorkspaceAssetSync({ - pool, + pool: pool2, storageRoot, h5Root, maxFileBytes, @@ -7681,25 +14200,33 @@ function createAssetService(pool, options = {}) { sourcePath }); }; - const writePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename, sourcePath) => { + const writePublicImageMirror = async (userId, workspaceRelativePath, sourcePath) => { if (!h5Root) return null; - const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename); const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); - const target = path12.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename); - await fs11.mkdir(path12.dirname(target), { recursive: true }); - await fs11.copyFile(sourcePath, target); + const target = path16.join(workspaceRoot, workspaceRelativePath); + await fs12.mkdir(path16.dirname(target), { recursive: true }); + await fs12.copyFile(sourcePath, target); return target; }; - const removePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename) => { - if (!h5Root) return; - const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename); + const removePublicImageMirror = async (userId, workspaceRelativePath) => { + if (!h5Root || !workspaceRelativePath) return; const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); - const target = path12.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename); - await fs11.rm(target, { force: true }); + const target = path16.join(workspaceRoot, workspaceRelativePath); + await fs12.rm(target, { force: true }); }; - const absoluteStoragePath = (storageKey) => { - const resolved = path12.resolve(storageRoot, storageKey); - if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path12.sep}`)) { + const resolvePublicCategory = async (conn, userId) => { + const [rows] = await conn.query( + `SELECT id, space_id, category_code + FROM h5_space_categories + WHERE user_id = ? AND category_code = 'public' + LIMIT 1`, + [userId] + ); + return rows[0] ?? null; + }; + const absoluteStoragePath2 = (storageKey) => { + const resolved = path16.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path16.sep}`)) { throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); } return resolved; @@ -7717,11 +14244,22 @@ function createAssetService(pool, options = {}) { ].filter((candidate, index, list) => list.indexOf(candidate) === index); }; const resolveReadableStoragePath = async (storageKey) => { + if (h5Root && isWorkspaceStorageKey(storageKey)) { + const workspacePath = resolveWorkspaceStoragePath(h5Root, storageKey); + if (workspacePath) { + try { + await fs12.stat(workspacePath); + return workspacePath; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + } let lastError = null; for (const candidate of resolveStoragePathCandidates(storageKey)) { - const absolutePath = absoluteStoragePath(candidate); + const absolutePath = absoluteStoragePath2(candidate); try { - await fs11.stat(absolutePath); + await fs12.stat(absolutePath); return absolutePath; } catch (error) { if (error?.code === "ENOENT") { @@ -7733,9 +14271,21 @@ function createAssetService(pool, options = {}) { } throw lastError ?? Object.assign(new Error("\u5B58\u50A8\u6587\u4EF6\u4E0D\u5B58\u5728"), { code: "storage_not_found" }); }; + const normalizeStoredAssetImage = async ({ buffer, mimeType, filename }) => { + if (!mimeType?.startsWith("image/")) { + return { buffer, mimeType, filename }; + } + return normalizeStoredImage({ + buffer, + mimeType, + filename, + maxUploadBytes: MAX_IMAGE_UPLOAD_BYTES, + maxPixels: DEFAULT_IMAGE_INPUT_MAX_PIXELS + }); + }; const createUpload = async (userId, input) => { const validated = validateUploadRequest({ ...input, maxFileBytes }); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [categories] = await conn.query( @@ -7750,11 +14300,16 @@ function createAssetService(pool, options = {}) { if (!category) { throw Object.assign(new Error("\u5206\u7C7B\u4E0D\u5B58\u5728"), { code: "category_not_found" }); } - if (!["oa", "private", "public"].includes(category.category_code)) { + if (!UPLOADABLE_CATEGORY_CODES.includes(category.category_code)) { throw Object.assign(new Error("\u8BE5\u5206\u7C7B\u4E0D\u5141\u8BB8\u76F4\u63A5\u4E0A\u4F20"), { code: "category_not_uploadable" }); } + let targetCategory = category; + if (validated.expectedMimeType.startsWith("image/")) { + const publicCategory = await resolvePublicCategory(conn, userId); + if (publicCategory) targetCategory = publicCategory; + } const [spaces] = await conn.query( `SELECT id, quota_bytes, used_bytes, reserved_bytes, status FROM h5_user_spaces @@ -7776,7 +14331,7 @@ function createAssetService(pool, options = {}) { } const uploadId = idFactory(); const now = Date.now(); - const temporaryStorageKey = path12.posix.join("users", userId, "temp", `${uploadId}.upload`); + const temporaryStorageKey = path16.posix.join("users", userId, "temp", `${uploadId}.upload`); await conn.query( `UPDATE h5_user_spaces SET reserved_bytes = reserved_bytes + ?, updated_at = ? @@ -7791,8 +14346,8 @@ function createAssetService(pool, options = {}) { [ uploadId, userId, - space.id, - category.id, + targetCategory.space_id, + targetCategory.id, validated.filename, validated.sizeBytes, input.declaredMimeType || null, @@ -7822,7 +14377,7 @@ function createAssetService(pool, options = {}) { if (!Buffer.isBuffer(buffer) || buffer.length === 0) { throw Object.assign(new Error("\u4E0A\u4F20\u5185\u5BB9\u4E3A\u7A7A"), { code: "invalid_file_size" }); } - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id, filename, expected_size, temporary_storage_key, status, expires_at FROM h5_upload_sessions WHERE id = ? AND user_id = ? @@ -7837,7 +14392,7 @@ function createAssetService(pool, options = {}) { if (asNumber2(upload.expires_at) <= Date.now()) { throw Object.assign(new Error("\u4E0A\u4F20\u4F1A\u8BDD\u5DF2\u8FC7\u671F"), { code: "upload_expired" }); } - if (buffer.length !== asNumber2(upload.expected_size) || buffer.length > maxFileBytes) { + if (buffer.length !== asNumber2(upload.expected_size)) { throw Object.assign(new Error("\u4E0A\u4F20\u5185\u5BB9\u5927\u5C0F\u4E0E\u9884\u671F\u4E0D\u4E00\u81F4"), { code: "file_size_mismatch" }); @@ -7846,17 +14401,27 @@ function createAssetService(pool, options = {}) { if (!detectedMimeType) { throw Object.assign(new Error("\u65E0\u6CD5\u786E\u8BA4\u6587\u4EF6\u7C7B\u578B"), { code: "unsupported_file_type" }); } + const effectiveMaxBytes = detectedMimeType.startsWith("image/") ? MAX_IMAGE_UPLOAD_BYTES : maxFileBytes; + if (buffer.length > effectiveMaxBytes) { + throw Object.assign( + new Error(detectedMimeType.startsWith("image/") ? "\u56FE\u7247\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236" : "\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), + { code: "file_too_large" } + ); + } if (detectedMimeType.startsWith("image/") && buffer.length > MAX_IMAGE_UPLOAD_BYTES) { throw Object.assign(new Error("\u56FE\u7247\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), { code: "file_too_large" }); } - const target = absoluteStoragePath(upload.temporary_storage_key); - await fs11.mkdir(path12.dirname(target), { recursive: true }); - await fs11.writeFile(target, buffer, { flag: "wx" }).catch(async (error) => { + if (detectedMimeType.startsWith("image/")) { + validateImagePixels(buffer, detectedMimeType); + } + const target = absoluteStoragePath2(upload.temporary_storage_key); + await fs12.mkdir(path16.dirname(target), { recursive: true }); + await fs12.writeFile(target, buffer, { flag: "wx" }).catch(async (error) => { if (error?.code !== "EEXIST") throw error; - await fs11.writeFile(target, buffer); + await fs12.writeFile(target, buffer); }); - const checksum = crypto6.createHash("sha256").update(buffer).digest("hex"); - await pool.query( + const checksum = crypto7.createHash("sha256").update(buffer).digest("hex"); + await pool2.query( `UPDATE h5_upload_sessions SET actual_size = ?, detected_mime_type = ?, checksum = ?, status = 'uploaded' WHERE id = ? AND user_id = ? AND status = 'reserved'`, @@ -7865,7 +14430,7 @@ function createAssetService(pool, options = {}) { return { sizeBytes: buffer.length, mimeType: detectedMimeType, checksum }; }; const completeUpload = async (userId, uploadId) => { - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); let temporaryPath; let finalPath; let publicMirror = null; @@ -7901,32 +14466,52 @@ function createAssetService(pool, options = {}) { } const assetId = idFactory(); const versionId = idFactory(); - temporaryPath = absoluteStoragePath(upload.temporary_storage_key); - const fileBuffer = await fs11.readFile(temporaryPath); - const scan = runBasicFileScan(fileBuffer, { - filename: upload.filename, - mimeType: upload.detected_mime_type + temporaryPath = absoluteStoragePath2(upload.temporary_storage_key); + const sourceBuffer = await fs12.readFile(temporaryPath); + const normalizedImage = await normalizeStoredAssetImage({ + buffer: sourceBuffer, + mimeType: upload.detected_mime_type, + filename: upload.filename + }); + const storedBuffer = normalizedImage.buffer; + const storedMimeType = normalizedImage.mimeType; + const storedUploadFilename = normalizedImage.filename; + const storedSizeBytes = storedBuffer.length; + const storedChecksum = crypto7.createHash("sha256").update(storedBuffer).digest("hex"); + const scan = runBasicFileScan(storedBuffer, { + filename: storedUploadFilename, + mimeType: storedMimeType }); const assetStatus = scan.scanStatus === "passed" ? "ready" : "quarantined"; const versionScanStatus = scan.scanStatus === "passed" ? "passed" : "blocked"; - const shouldPublishTempImage = upload.category_code === "public" && upload.detected_mime_type.startsWith("image/") && scan.scanStatus === "passed"; - const finalStorageKey = shouldPublishTempImage ? publicTempImageStorageKey(userId, assetId, upload.detected_mime_type, upload.filename) : upload.temporary_storage_key; - finalPath = absoluteStoragePath(finalStorageKey); - if (finalStorageKey !== upload.temporary_storage_key) { - await fs11.mkdir(path12.dirname(finalPath), { recursive: true }); - await fs11.rename(temporaryPath, finalPath); - } - if (shouldPublishTempImage) { - publicMirror = await writePublicTempImageMirror( + const isImage = storedMimeType.startsWith("image/"); + const shouldPublishImage = isImage && scan.scanStatus === "passed"; + let finalStorageKey = upload.temporary_storage_key; + let workspaceRelativePath = null; + let storedFilename = storedUploadFilename; + if (shouldPublishImage) { + const imagePaths = resolvePublicImagePaths( userId, assetId, - upload.detected_mime_type, - upload.filename, - finalPath + storedMimeType, + storedUploadFilename ); + finalStorageKey = imagePaths.storageKey; + workspaceRelativePath = imagePaths.workspaceRelativePath; + storedFilename = imagePaths.workspaceRelativePath; + } + finalPath = absoluteStoragePath2(finalStorageKey); + if (finalStorageKey !== upload.temporary_storage_key) { + await fs12.mkdir(path16.dirname(finalPath), { recursive: true }); + await fs12.writeFile(finalPath, storedBuffer); + } else if (isImage) { + await fs12.writeFile(finalPath, storedBuffer); + } + if (shouldPublishImage && workspaceRelativePath) { + publicMirror = await writePublicImageMirror(userId, workspaceRelativePath, finalPath); } const now = Date.now(); - const visibility = visibilityForCategory(upload.category_code); + const visibility = shouldPublishImage ? "public_candidate" : visibilityForCategory(upload.category_code); await conn.query( `INSERT INTO h5_assets (id, user_id, space_id, category_id, asset_type, mime_type, original_filename, @@ -7938,13 +14523,13 @@ function createAssetService(pool, options = {}) { userId, upload.space_id, upload.category_id, - assetTypeForMime(upload.detected_mime_type), - upload.detected_mime_type, - upload.filename, + assetTypeForMime(storedMimeType), + storedMimeType, + storedFilename, upload.filename, versionId, - upload.actual_size, - upload.checksum, + storedSizeBytes, + storedChecksum, scan.riskLevel, visibility, assetStatus, @@ -7961,9 +14546,9 @@ function createAssetService(pool, options = {}) { versionId, assetId, finalStorageKey, - upload.actual_size, - upload.checksum, - upload.detected_mime_type, + storedSizeBytes, + storedChecksum, + storedMimeType, userId, versionScanStatus, now @@ -7975,7 +14560,7 @@ function createAssetService(pool, options = {}) { used_bytes = used_bytes + ?, updated_at = ? WHERE id = ? AND user_id = ?`, - [upload.reserved_bytes, upload.actual_size, now, upload.space_id, userId] + [upload.reserved_bytes, storedSizeBytes, now, upload.space_id, userId] ); await conn.query( `UPDATE h5_upload_sessions @@ -7984,17 +14569,17 @@ function createAssetService(pool, options = {}) { [now, assetId, uploadId, userId] ); await conn.commit(); - return { + const result = { id: assetId, user_id: userId, categoryId: upload.category_id, categoryCode: upload.category_code, - assetType: assetTypeForMime(upload.detected_mime_type), - mimeType: upload.detected_mime_type, + assetType: assetTypeForMime(storedMimeType), + mimeType: storedMimeType, filename: upload.filename, displayName: upload.filename, - sizeBytes: asNumber2(upload.actual_size), - checksum: upload.checksum, + sizeBytes: storedSizeBytes, + checksum: storedChecksum, riskLevel: scan.riskLevel, visibility, status: assetStatus, @@ -8002,13 +14587,23 @@ function createAssetService(pool, options = {}) { sourceType: "upload", createdAt: now, updatedAt: now, - publicUrl: shouldPublishTempImage ? publicTempImageUrl(userId, assetId, upload.detected_mime_type, upload.filename) : null + publicUrl: shouldPublishImage ? buildUserImagePublicUrl({ + userId, + storageKey: finalStorageKey, + mimeType: storedMimeType, + originalFilename: storedUploadFilename + }) : null }; + if (finalStorageKey !== upload.temporary_storage_key) { + await fs12.rm(temporaryPath, { force: true }).catch(() => { + }); + } + return result; } catch (error) { await conn.rollback(); - if (finalPath && finalPath !== temporaryPath) await fs11.rm(finalPath, { force: true }).catch(() => { + if (finalPath && finalPath !== temporaryPath) await fs12.rm(finalPath, { force: true }).catch(() => { }); - if (publicMirror) await fs11.rm(publicMirror, { force: true }).catch(() => { + if (publicMirror) await fs12.rm(publicMirror, { force: true }).catch(() => { }); throw error; } finally { @@ -8016,7 +14611,7 @@ function createAssetService(pool, options = {}) { } }; const cancelUpload = async (userId, uploadId) => { - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); let storageKey; try { await conn.beginTransaction(); @@ -8047,7 +14642,7 @@ function createAssetService(pool, options = {}) { [uploadId, userId] ); await conn.commit(); - if (storageKey) await fs11.rm(absoluteStoragePath(storageKey), { force: true }); + if (storageKey) await fs12.rm(absoluteStoragePath2(storageKey), { force: true }); return { cancelled: true }; } catch (error) { await conn.rollback(); @@ -8057,8 +14652,12 @@ function createAssetService(pool, options = {}) { } }; const listAssets = async (userId, { categoryId, categoryCode, syncWorkspace = true } = {}) => { - if (syncWorkspace && categoryCode && ["oa", "private", "public"].includes(categoryCode)) { - await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch(() => { + if (syncWorkspace && categoryCode && UPLOADABLE_CATEGORY_CODES.includes(categoryCode)) { + await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch((error) => { + console.warn( + `[MindSpace] workspace sync failed (${categoryCode}):`, + error?.message ?? error + ); }); } const filters = [`a.user_id = ?`, `a.status <> 'deleted'`]; @@ -8071,15 +14670,16 @@ function createAssetService(pool, options = {}) { filters.push(`c.category_code = ?`); params.push(categoryCode); } - const [rows] = await pool.query( - `SELECT a.*, c.category_code, + const [rows] = await pool2.query( + `SELECT a.*, c.category_code, v.storage_key, ANY_VALUE(p.id) AS source_page_id FROM h5_assets a JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + JOIN h5_asset_versions v ON v.id = a.current_version_id LEFT JOIN h5_page_versions pv ON pv.bundle_asset_id = a.id LEFT JOIN h5_page_records p ON p.id = pv.page_id AND p.status <> 'deleted' AND p.user_id = a.user_id WHERE ${filters.join(" AND ")} - GROUP BY a.id + GROUP BY a.id, v.storage_key ORDER BY a.updated_at DESC LIMIT 100`, params @@ -8087,7 +14687,7 @@ function createAssetService(pool, options = {}) { return rows.map(assetResponse); }; const deleteAsset = async (userId, assetId) => { - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); let mirrorCleanup = null; let storageCleanup = null; try { @@ -8178,27 +14778,22 @@ function createAssetService(pool, options = {}) { filename: thumbName }); } - if (mirrorCleanup.categoryCode === "public" && mirrorCleanup.mimeType?.startsWith?.("image/")) { - await removePublicTempImageMirror( - userId, - mirrorCleanup.id, - mirrorCleanup.mimeType, - mirrorCleanup.filename - ); + if (mirrorCleanup.mimeType?.startsWith?.("image/")) { + await removePublicImageMirror(userId, mirrorCleanup.filename); } } catch { } } if (storageCleanup) { try { - await fs11.rm(absoluteStoragePath(storageCleanup), { force: true }); + await fs12.rm(absoluteStoragePath2(storageCleanup), { force: true }); } catch { } } return { deleted: true }; }; const readAsset = async (userId, assetId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT a.*, c.category_code, v.storage_key, v.scan_status FROM h5_assets a JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id @@ -8215,22 +14810,56 @@ function createAssetService(pool, options = {}) { path: await resolveReadableStoragePath(asset.storage_key) }; }; + const readPublicAsset = async (assetId) => { + const [rows] = await pool2.query( + `SELECT a.*, c.category_code, v.storage_key, v.scan_status + FROM h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.id = ? AND a.status <> 'deleted' + LIMIT 1`, + [assetId] + ); + const asset = rows[0]; + if (!asset) throw Object.assign(new Error("\u8D44\u4EA7\u4E0D\u5B58\u5728"), { code: "asset_not_found" }); + assertAssetDownloadable(asset); + return { + asset: assetResponse(asset), + path: await resolveReadableStoragePath(asset.storage_key) + }; + }; const createChatAsset = async (userId, { categoryCode, buffer, filename, displayName, sourceType = "chat" }) => { if (!Buffer.isBuffer(buffer) || buffer.length === 0) { throw Object.assign(new Error("\u8D44\u4EA7\u5185\u5BB9\u4E3A\u7A7A"), { code: "invalid_file_size" }); } - if (buffer.length > maxFileBytes) { - throw Object.assign(new Error("\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), { code: "file_too_large" }); - } const normalizedFilename = normalizeFilename(filename); const detectedMimeType = detectMimeType(buffer, normalizedFilename); if (!detectedMimeType) { throw Object.assign(new Error("\u65E0\u6CD5\u786E\u8BA4\u6587\u4EF6\u7C7B\u578B"), { code: "unsupported_file_type" }); } + const effectiveMaxBytes = detectedMimeType.startsWith("image/") ? MAX_IMAGE_UPLOAD_BYTES : maxFileBytes; + if (buffer.length > effectiveMaxBytes) { + throw Object.assign( + new Error(detectedMimeType.startsWith("image/") ? "\u56FE\u7247\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236" : "\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), + { code: "file_too_large" } + ); + } if (detectedMimeType.startsWith("image/") && buffer.length > MAX_IMAGE_UPLOAD_BYTES) { throw Object.assign(new Error("\u56FE\u7247\u6587\u4EF6\u8D85\u8FC7\u5355\u6587\u4EF6\u5927\u5C0F\u9650\u5236"), { code: "file_too_large" }); } - const conn = await pool.getConnection(); + const normalizedImage = await normalizeStoredAssetImage({ + buffer, + mimeType: detectedMimeType, + filename: normalizedFilename + }); + const storedBuffer = normalizedImage.buffer; + const storedMimeType = normalizedImage.mimeType; + const storedFilenameInput = normalizedImage.filename; + const storedSizeBytes = storedBuffer.length; + const storedChecksum = crypto7.createHash("sha256").update(storedBuffer).digest("hex"); + const isImage = storedMimeType.startsWith("image/"); + const effectiveCategoryCode = isImage ? "public" : categoryCode; + const conn = await pool2.getConnection(); let finalPath; let publicMirror = null; try { @@ -8241,13 +14870,13 @@ function createAssetService(pool, options = {}) { WHERE c.user_id = ? AND c.category_code = ? LIMIT 1 FOR UPDATE`, - [userId, categoryCode] + [userId, effectiveCategoryCode] ); const category = categories[0]; if (!category) { throw Object.assign(new Error("\u5206\u7C7B\u4E0D\u5B58\u5728"), { code: "category_not_found" }); } - if (!["oa", "private", "public"].includes(category.category_code)) { + if (!UPLOADABLE_CATEGORY_CODES.includes(category.category_code) && category.category_code !== "draft") { throw Object.assign(new Error("\u8BE5\u5206\u7C7B\u4E0D\u5141\u8BB8\u4FDD\u5B58\u804A\u5929\u8D44\u4EA7"), { code: "category_not_uploadable" }); @@ -8265,37 +14894,43 @@ function createAssetService(pool, options = {}) { throw Object.assign(new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u7528"), { code: "space_unavailable" }); } const available = asNumber2(space.quota_bytes) - asNumber2(space.used_bytes) - asNumber2(space.reserved_bytes); - if (available < buffer.length) { + if (available < storedSizeBytes) { throw Object.assign(new Error("\u5269\u4F59\u7A7A\u95F4\u4E0D\u8DB3"), { code: "quota_exceeded", - details: { requiredBytes: buffer.length, availableBytes: Math.max(0, available) } + details: { requiredBytes: storedSizeBytes, availableBytes: Math.max(0, available) } }); } const assetId = idFactory(); const versionId = idFactory(); - const checksum = crypto6.createHash("sha256").update(buffer).digest("hex"); - const scan = runBasicFileScan(buffer, { - filename: normalizedFilename, - mimeType: detectedMimeType + const scan = runBasicFileScan(storedBuffer, { + filename: storedFilenameInput, + mimeType: storedMimeType }); const assetStatus = scan.scanStatus === "passed" ? "ready" : "quarantined"; const versionScanStatus = scan.scanStatus === "passed" ? "passed" : "blocked"; - const shouldPublishTempImage = category.category_code === "public" && detectedMimeType.startsWith("image/") && scan.scanStatus === "passed"; - const finalStorageKey = shouldPublishTempImage ? publicTempImageStorageKey(userId, assetId, detectedMimeType, normalizedFilename) : path12.posix.join("users", userId, "assets", assetId, "versions", versionId); - finalPath = absoluteStoragePath(finalStorageKey); - await fs11.mkdir(path12.dirname(finalPath), { recursive: true }); - await fs11.writeFile(finalPath, buffer, { flag: "wx" }); - if (shouldPublishTempImage) { - publicMirror = await writePublicTempImageMirror( + const shouldPublishImage = isImage && scan.scanStatus === "passed"; + let finalStorageKey = path16.posix.join("users", userId, "assets", assetId, "versions", versionId); + let workspaceRelativePath = null; + let storedFilename = storedFilenameInput; + if (shouldPublishImage) { + const imagePaths = resolvePublicImagePaths( userId, assetId, - detectedMimeType, - normalizedFilename, - finalPath + storedMimeType, + storedFilenameInput ); + finalStorageKey = imagePaths.storageKey; + workspaceRelativePath = imagePaths.workspaceRelativePath; + storedFilename = imagePaths.workspaceRelativePath; + } + finalPath = absoluteStoragePath2(finalStorageKey); + await fs12.mkdir(path16.dirname(finalPath), { recursive: true }); + await fs12.writeFile(finalPath, storedBuffer, { flag: "wx" }); + if (shouldPublishImage && workspaceRelativePath) { + publicMirror = await writePublicImageMirror(userId, workspaceRelativePath, finalPath); } const now = Date.now(); - const visibility = visibilityForCategory(category.category_code); + const visibility = shouldPublishImage ? "public_candidate" : visibilityForCategory(category.category_code); await conn.query( `INSERT INTO h5_assets (id, user_id, space_id, category_id, asset_type, mime_type, original_filename, @@ -8307,13 +14942,13 @@ function createAssetService(pool, options = {}) { userId, category.space_id, category.id, - assetTypeForMime(detectedMimeType), - detectedMimeType, - normalizedFilename, - displayName || normalizedFilename, + assetTypeForMime(storedMimeType), + storedMimeType, + storedFilename, + displayName || path16.basename(storedFilename), versionId, - buffer.length, - checksum, + storedSizeBytes, + storedChecksum, scan.riskLevel, visibility, assetStatus, @@ -8331,9 +14966,9 @@ function createAssetService(pool, options = {}) { versionId, assetId, finalStorageKey, - buffer.length, - checksum, - detectedMimeType, + storedSizeBytes, + storedChecksum, + storedMimeType, userId, versionScanStatus, now @@ -8342,11 +14977,11 @@ function createAssetService(pool, options = {}) { await conn.query( `UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ? WHERE id = ? AND user_id = ?`, - [buffer.length, now, category.space_id, userId] + [storedSizeBytes, now, category.space_id, userId] ); await conn.commit(); - if (detectedMimeType === "text/html") { - scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), buffer.toString("utf8"), { + if (storedMimeType === "text/html") { + scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), storedBuffer.toString("utf8"), { title: displayName || normalizedFilename, subtitle: category.category_code.toUpperCase(), contentStorageKey: finalStorageKey @@ -8357,12 +14992,13 @@ function createAssetService(pool, options = {}) { user_id: userId, category_id: category.id, category_code: category.category_code, - asset_type: assetTypeForMime(detectedMimeType), - mime_type: detectedMimeType, - original_filename: normalizedFilename, - display_name: displayName || normalizedFilename, - size_bytes: buffer.length, - checksum, + asset_type: assetTypeForMime(storedMimeType), + mime_type: storedMimeType, + original_filename: storedFilename, + display_name: displayName || path16.basename(storedFilename), + storage_key: finalStorageKey, + size_bytes: storedSizeBytes, + checksum: storedChecksum, risk_level: scan.riskLevel, visibility, status: assetStatus, @@ -8370,13 +15006,13 @@ function createAssetService(pool, options = {}) { source_type: sourceType, created_at: now, updated_at: now, - has_thumbnail: detectedMimeType === "text/html" + has_thumbnail: storedMimeType === "text/html" }); } catch (error) { await conn.rollback(); - if (finalPath) await fs11.rm(finalPath, { force: true }).catch(() => { + if (finalPath) await fs12.rm(finalPath, { force: true }).catch(() => { }); - if (publicMirror) await fs11.rm(publicMirror, { force: true }).catch(() => { + if (publicMirror) await fs12.rm(publicMirror, { force: true }).catch(() => { }); throw error; } finally { @@ -8388,13 +15024,13 @@ function createAssetService(pool, options = {}) { if (asset.mimeType !== "text/html") { throw Object.assign(new Error("\u8BE5\u8D44\u4EA7\u4E0D\u652F\u6301\u7F29\u7565\u56FE"), { code: "thumbnail_not_supported" }); } - const [versions] = await pool.query( + const [versions] = await pool2.query( `SELECT storage_key FROM h5_asset_versions WHERE asset_id = ? AND version_no = 1 LIMIT 1`, [assetId] ); - const html = await fs11.readFile(assetPath, "utf8"); + const html = await fs12.readFile(assetPath, "utf8"); return ensureHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), html, { title: asset.displayName, subtitle: asset.categoryCode?.toUpperCase?.() ?? "HTML", @@ -8406,13 +15042,13 @@ function createAssetService(pool, options = {}) { if (!canPreviewAsset(asset.mimeType)) { throw Object.assign(new Error("\u8BE5\u8D44\u4EA7\u4E0D\u652F\u6301\u9884\u89C8"), { code: "preview_not_supported" }); } - const buffer = await fs11.readFile(assetPath); + const buffer = await fs12.readFile(assetPath); const downloadUrl = downloadPath ?? `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download?inline=1`; return renderAssetPreviewHtml({ asset, buffer, downloadUrl }); }; const extractAssetText = async (userId, assetId) => { const { asset, path: assetPath } = await readAsset(userId, assetId); - const buffer = await fs11.readFile(assetPath); + const buffer = await fs12.readFile(assetPath); const extracted = extractAttachmentText(buffer, asset.mimeType, asset.filename); return { asset: assetResponse(asset), @@ -8421,7 +15057,7 @@ function createAssetService(pool, options = {}) { }; }; const expireStaleUploads = async (now = Date.now()) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id, user_id, space_id, reserved_bytes, temporary_storage_key, status FROM h5_upload_sessions WHERE status IN ('reserved', 'uploaded') AND expires_at <= ? @@ -8430,7 +15066,7 @@ function createAssetService(pool, options = {}) { ); let expired = 0; for (const upload of rows) { - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [locked] = await conn.query( @@ -8458,7 +15094,7 @@ function createAssetService(pool, options = {}) { ); await conn.commit(); if (row.temporary_storage_key) { - await fs11.rm(absoluteStoragePath(row.temporary_storage_key), { force: true }); + await fs12.rm(absoluteStoragePath2(row.temporary_storage_key), { force: true }); } expired += 1; } catch { @@ -8479,6 +15115,7 @@ function createAssetService(pool, options = {}) { listAssets, deleteAsset, readAsset, + readPublicAsset, renderAssetPreview, extractAssetText, renderAssetThumbnail, @@ -8495,51 +15132,88 @@ var assetInternals = { // mindspace-workspace-sync.mjs var SKIP_FILENAMES = /* @__PURE__ */ new Set(["index.html", ".tkmindhints", ".goosehints", ".ls_output"]); +var SKIP_ZONE_DIR_NAMES = /* @__PURE__ */ new Set([ + "node_modules", + ".venv", + ".venv2", + "__pycache__", + ".git", + "dist", + "build", + ".next", + "target", + "backend", + "frontend" +]); +var NESTED_SKIP_BASENAMES = /* @__PURE__ */ new Set([ + "README.md", + "requirements.txt", + "package.json", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock" +]); function asNumber3(value) { return Number(value ?? 0); } -function shouldSyncWorkspaceFilename(filename) { - const normalized = String(filename ?? "").trim(); - if (!normalized || normalized.startsWith(".")) return false; - if (SKIP_FILENAMES.has(normalized)) return false; - if (normalized.endsWith(".thumbnail.svg")) return false; - if (normalized.endsWith(".sh")) return false; - return assetInternals.expectedMimeType(normalized) !== null; +function normalizeWorkspaceRelativePath(relativePath) { + const normalized = String(relativePath ?? "").normalize("NFKC").trim().replace(/\\/g, "/"); + if (!normalized || normalized.startsWith("/") || normalized.includes("\0")) return null; + const parts = normalized.split("/").filter(Boolean); + if (parts.some((part) => part === "." || part === "..")) return null; + if (parts.some((part) => part.startsWith("."))) return null; + return parts.join("/").slice(0, 255); } -async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) { - if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return []; - const zoneDir = resolveZoneDir(workspaceRoot, categoryCode); +function shouldSyncWorkspaceFilename(filename) { + const relativePath = normalizeWorkspaceRelativePath(filename); + if (!relativePath) return false; + const basename = path17.posix.basename(relativePath); + if (!basename || basename.startsWith(".")) return false; + if (SKIP_FILENAMES.has(basename)) return false; + if (basename.endsWith(".thumbnail.svg")) return false; + if (basename.endsWith(".sh")) return false; + if (relativePath.includes("/") && NESTED_SKIP_BASENAMES.has(basename)) return false; + return assetInternals.expectedMimeType(basename) !== null; +} +async function walkWorkspaceZoneDir(zoneDir, relativePrefix, files) { let entries; try { entries = await fsPromises2.readdir(zoneDir, { withFileTypes: true }); } catch { - return []; + return; } - const files = []; for (const entry of entries) { + if (entry.name.startsWith(".")) continue; + const absolutePath = path17.join(zoneDir, entry.name); + if (entry.isDirectory()) { + if (SKIP_ZONE_DIR_NAMES.has(entry.name)) continue; + const nextPrefix = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name; + await walkWorkspaceZoneDir(absolutePath, nextPrefix, files); + continue; + } if (!entry.isFile()) continue; - if (!shouldSyncWorkspaceFilename(entry.name)) continue; - const absolutePath = path13.join(zoneDir, entry.name); + const filename = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name; + if (!shouldSyncWorkspaceFilename(filename)) continue; const stat = await fsPromises2.stat(absolutePath); files.push({ - filename: entry.name, + filename, absolutePath, sizeBytes: stat.size, mtimeMs: stat.mtimeMs }); } +} +async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) { + if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return []; + const zoneDir = resolveZoneDir(workspaceRoot, categoryCode); + const files = []; + await walkWorkspaceZoneDir(zoneDir, "", files); return files; } -function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idFactory }) { - const absoluteStoragePath = (storageKey) => { - const resolved = path13.resolve(storageRoot, storageKey); - if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path13.sep}`)) { - throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); - } - return resolved; - }; +function createWorkspaceAssetSync({ pool: pool2, storageRoot, h5Root, maxFileBytes, idFactory }) { + void storageRoot; const loadExistingAssets = async (userId, categoryId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT a.id, a.original_filename, a.checksum, a.size_bytes, a.current_version_id, a.status FROM h5_assets a WHERE a.user_id = ? AND a.category_id = ? AND a.status <> 'deleted'`, @@ -8548,7 +15222,7 @@ function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idF return new Map(rows.map((row) => [row.original_filename, row])); }; const loadDeletedWorkspaceChecksums = async (userId, categoryId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT original_filename, checksum FROM h5_assets WHERE user_id = ? AND category_id = ? AND status = 'deleted' AND source_type = 'workspace'`, @@ -8563,8 +15237,7 @@ function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idF return byFilename; }; const importWorkspaceFile = async (userId, category, file, buffer) => { - const conn = await pool.getConnection(); - let finalPath; + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [spaces] = await conn.query( @@ -8593,20 +15266,14 @@ function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idF }); const assetStatus = scan.scanStatus === "passed" ? "ready" : "quarantined"; const versionScanStatus = scan.scanStatus === "passed" ? "passed" : "blocked"; - const checksum = crypto7.createHash("sha256").update(buffer).digest("hex"); + const checksum = crypto8.createHash("sha256").update(buffer).digest("hex"); const assetId = idFactory(); const versionId = idFactory(); - const finalStorageKey = path13.posix.join( - "users", + const finalStorageKey = buildWorkspaceStorageKey( userId, - "assets", - assetId, - "versions", - versionId + category.category_code, + file.filename ); - finalPath = absoluteStoragePath(finalStorageKey); - await fsPromises2.mkdir(path13.dirname(finalPath), { recursive: true }); - await fsPromises2.writeFile(finalPath, buffer, { flag: "wx" }); const now = Date.now(); const visibility = category.category_code === "public" ? "public_candidate" : "private"; await conn.query( @@ -8623,7 +15290,7 @@ function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idF assetInternals.assetTypeForMime(detectedMimeType), detectedMimeType, file.filename, - path13.basename(file.filename, path13.extname(file.filename)) || file.filename, + path17.basename(file.filename, path17.extname(file.filename)) || file.filename, versionId, buffer.length, checksum, @@ -8660,16 +15327,13 @@ function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idF return { action: "imported", assetId, filename: file.filename, checksum }; } catch (error) { await conn.rollback(); - if (finalPath) await fsPromises2.rm(finalPath, { force: true }).catch(() => { - }); throw error; } finally { conn.release(); } }; const updateWorkspaceFile = async (userId, category, existing, file, buffer) => { - const conn = await pool.getConnection(); - let finalPath; + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [spaces] = await conn.query( @@ -8699,7 +15363,7 @@ function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idF }); const assetStatus = scan.scanStatus === "passed" ? "ready" : "quarantined"; const versionScanStatus = scan.scanStatus === "passed" ? "passed" : "blocked"; - const checksum = crypto7.createHash("sha256").update(buffer).digest("hex"); + const checksum = crypto8.createHash("sha256").update(buffer).digest("hex"); const [versionRows] = await conn.query( `SELECT COALESCE(MAX(version_no), 0) AS max_version FROM h5_asset_versions @@ -8708,17 +15372,11 @@ function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idF ); const versionNo = asNumber3(versionRows[0]?.max_version) + 1; const versionId = idFactory(); - const finalStorageKey = path13.posix.join( - "users", + const finalStorageKey = buildWorkspaceStorageKey( userId, - "assets", - existing.id, - "versions", - versionId + category.category_code, + file.filename ); - finalPath = absoluteStoragePath(finalStorageKey); - await fsPromises2.mkdir(path13.dirname(finalPath), { recursive: true }); - await fsPromises2.writeFile(finalPath, buffer, { flag: "wx" }); const now = Date.now(); await conn.query( `UPDATE h5_assets @@ -8767,8 +15425,6 @@ function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idF return { action: "updated", assetId: existing.id, filename: file.filename, checksum }; } catch (error) { await conn.rollback(); - if (finalPath) await fsPromises2.rm(finalPath, { force: true }).catch(() => { - }); throw error; } finally { conn.release(); @@ -8778,7 +15434,7 @@ function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idF if (!h5Root) return { imported: 0, updated: 0, skipped: 0 }; const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); const files = await listWorkspaceZoneFiles(workspaceRoot, categoryCode); - const [categories] = await pool.query( + const [categories] = await pool2.query( `SELECT c.id, c.space_id, c.category_code FROM h5_space_categories c WHERE c.user_id = ? AND c.category_code = ? @@ -8798,7 +15454,7 @@ function createWorkspaceAssetSync({ pool, storageRoot, h5Root, maxFileBytes, idF continue; } const buffer = await fsPromises2.readFile(file.absolutePath); - const checksum = crypto7.createHash("sha256").update(buffer).digest("hex"); + const checksum = crypto8.createHash("sha256").update(buffer).digest("hex"); if (deletedWorkspaceChecksums.get(file.filename)?.has(checksum)) { skipped += 1; continue; @@ -8854,15 +15510,25 @@ function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceByDirKey }; const watchZoneDir = (dirKey, zoneDir, categoryCode) => { try { - fs12.watch(zoneDir, (_event, filename) => { - if (!filename || !shouldSyncWorkspaceFilename(filename)) return; - schedule(dirKey, categoryCode); - }); + fs13.watch( + zoneDir, + { recursive: true }, + (_event, filename) => { + if (!filename) { + schedule(dirKey, categoryCode); + return; + } + const normalized = String(filename).replace(/\\/g, "/"); + if (shouldSyncWorkspaceFilename(normalized)) { + schedule(dirKey, categoryCode); + } + } + ); } catch { } }; const attachUser = async (dirKey) => { - const userDir = path13.join(publishRoot, dirKey); + const userDir = path17.join(publishRoot, dirKey); try { await fsPromises2.access(userDir); } catch { @@ -8872,15 +15538,15 @@ function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceByDirKey watchZoneDir(dirKey, resolveZoneDir(userDir, categoryCode), categoryCode); } }; - for (const entry of fs12.readdirSync(publishRoot, { withFileTypes: true })) { + for (const entry of fs13.readdirSync(publishRoot, { withFileTypes: true })) { if (!entry.isDirectory() || entry.name === "wiki" || entry.name.startsWith(".")) continue; void attachUser(entry.name); } try { - fs12.watch(publishRoot, (_event, filename) => { + fs13.watch(publishRoot, (_event, filename) => { if (!filename) return; - const userDir = path13.join(publishRoot, filename); - if (fs12.existsSync(userDir) && fs12.statSync(userDir).isDirectory()) { + const userDir = path17.join(publishRoot, filename); + if (fs13.existsSync(userDir) && fs13.statSync(userDir).isDirectory()) { void attachUser(filename); } }); @@ -8893,10 +15559,10 @@ function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceByDirKey } // api-response.mjs -import crypto8 from "node:crypto"; +import crypto9 from "node:crypto"; function createRequestId(existing) { if (typeof existing === "string" && existing.trim()) return existing.trim(); - return `req_${crypto8.randomUUID()}`; + return `req_${crypto9.randomUUID()}`; } function attachRequestId(req, res, next) { const requestId = createRequestId(req.get("x-request-id")); @@ -8922,7 +15588,7 @@ function sendError(res, req, status, code, message, details) { } // mindspace-audit.mjs -function createMindSpaceAuditWriter(pool) { +function createMindSpaceAuditWriter(pool2) { const write = async ({ userId, action, @@ -8933,8 +15599,9 @@ function createMindSpaceAuditWriter(pool) { riskLevel = null, now = Date.now() }) => { - if (!pool) return; - await pool.query( + if (!pool2) return; + if (!userId) return; + await pool2.query( `INSERT INTO h5_mindspace_audit_logs (user_id, action, object_type, object_id, ip, result, risk_level, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, @@ -8951,7 +15618,7 @@ function mindspaceFlags(env = process.env) { mindspace_enabled: enabled, mindspace_upload_enabled: enabled && env.MINDSPACE_UPLOAD_ENABLED !== "false", mindspace_publish_enabled: enabled && env.MINDSPACE_PUBLISH_ENABLED === "true", - mindspace_private_enabled: enabled && env.MINDSPACE_PRIVATE_ENABLED !== "false", + mindspace_private_enabled: false, mindspace_agent_jobs_enabled: enabled && env.MINDSPACE_AGENT_JOBS_ENABLED === "true" }; } @@ -8968,12 +15635,12 @@ function assertMindSpaceRoute(flags, route) { } // mindspace-pages.mjs -import crypto10 from "node:crypto"; -import fs13 from "node:fs/promises"; -import path14 from "node:path"; +import crypto11 from "node:crypto"; +import fs14 from "node:fs/promises"; +import path18 from "node:path"; // mindspace-content-scan.mjs -import crypto9 from "node:crypto"; +import crypto10 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; @@ -9163,7 +15830,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: crypto9.createHash("sha256").update(`${rule.type}:${matches[0][0]}`).digest("hex").slice(0, 24), + id: crypto10.createHash("sha256").update(`${rule.type}:${matches[0][0]}`).digest("hex").slice(0, 24), type: rule.type, label: rule.label, riskLevel: rule.riskLevel, @@ -9313,8 +15980,8 @@ function normalizePageInput(input) { categoryCode: SAVE_CATEGORY_CODES.has(input.categoryCode) ? input.categoryCode : "draft" }; } -function extensionForMime(mimeType, filename = "") { - const ext = path14.extname(String(filename ?? "")).replace(/^\./, "").toLowerCase(); +function extensionForMime2(mimeType, filename = "") { + const ext = path18.extname(String(filename ?? "")).replace(/^\./, "").toLowerCase(); if (ext && /^[a-z0-9]{1,8}$/.test(ext)) return ext === "jpeg" ? "jpg" : ext; if (mimeType === "image/jpeg") return "jpg"; if (mimeType === "image/png") return "png"; @@ -9431,13 +16098,13 @@ function renderPublicationHtml(page) { `MINDSPACE \xB7 V${page.version_no}` ); } -function createPageService(pool, options = {}) { - const storageRoot = path14.resolve(options.storageRoot ?? path14.join(process.cwd(), "data", "mindspace")); - const h5Root = options.h5Root ? path14.resolve(options.h5Root) : null; - const idFactory = options.idFactory ?? (() => crypto10.randomUUID()); +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 resolveWorkspacePublishDir = async (userId) => { if (!h5Root) return null; - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT username FROM h5_users WHERE id = ? LIMIT 1`, [userId] ); @@ -9445,9 +16112,9 @@ function createPageService(pool, options = {}) { if (!username) return null; return resolvePublishDir(h5Root, { id: userId }); }; - const absoluteStoragePath = (storageKey) => { - const resolved = path14.resolve(storageRoot, storageKey); - if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path14.sep}`)) { + const absoluteStoragePath2 = (storageKey) => { + const resolved = path18.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path18.sep}`)) { throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); } return resolved; @@ -9467,9 +16134,9 @@ function createPageService(pool, options = {}) { const resolveReadableStoragePath = async (storageKey) => { let lastError = null; for (const candidate of resolveStoragePathCandidates(storageKey)) { - const absolutePath = absoluteStoragePath(candidate); + const absolutePath = absoluteStoragePath2(candidate); try { - await fs13.stat(absolutePath); + await fs14.stat(absolutePath); return absolutePath; } catch (error) { if (error?.code === "ENOENT") { @@ -9482,7 +16149,7 @@ function createPageService(pool, options = {}) { throw lastError ?? Object.assign(new Error("\u5B58\u50A8\u6587\u4EF6\u4E0D\u5B58\u5728"), { code: "storage_not_found" }); }; const writeVersionContent = async (userId, assetId, versionId, content) => { - const storageKey = path14.posix.join( + const storageKey = path18.posix.join( "users", userId, "pages", @@ -9490,15 +16157,15 @@ function createPageService(pool, options = {}) { "versions", `${versionId}.md` ); - const target = absoluteStoragePath(storageKey); - await fs13.mkdir(path14.dirname(target), { recursive: true }); - await fs13.writeFile(target, content, { flag: "wx" }); + const target = absoluteStoragePath2(storageKey); + await fs14.mkdir(path18.dirname(target), { recursive: true }); + await fs14.writeFile(target, content, { flag: "wx" }); return { storageKey, target }; }; const createVersion = async (userId, input, source = {}) => { let pageInput = input; if (input.pageId && input.contentFormat == null) { - const [existingPages] = await pool.query( + const [existingPages] = await pool2.query( `SELECT page_type FROM h5_page_records WHERE id = ? AND user_id = ? AND status <> 'deleted' LIMIT 1`, [input.pageId, userId] ); @@ -9507,7 +16174,7 @@ function createPageService(pool, options = {}) { } } const normalized = normalizePageInput(pageInput); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); let writtenPath; try { await conn.beginTransaction(); @@ -9563,7 +16230,7 @@ function createPageService(pool, options = {}) { const contentAssetId = idFactory(); const assetVersionId = idFactory(); const pageVersionId = idFactory(); - const checksum = crypto10.createHash("sha256").update(normalized.content).digest("hex"); + const checksum = crypto11.createHash("sha256").update(normalized.content).digest("hex"); const stored = await writeVersionContent( userId, contentAssetId, @@ -9571,7 +16238,7 @@ function createPageService(pool, options = {}) { normalized.content ); writtenPath = stored.target; - const now = Date.now(); + const now = source.createdAt != null && Number.isFinite(Number(source.createdAt)) ? Math.floor(Number(source.createdAt)) : Date.now(); const contentAssetType = normalized.contentFormat === "html" ? "html" : "markdown"; const contentMimeType = normalized.contentFormat === "html" ? "text/html" : "text/markdown"; const contentExtension = normalized.contentFormat === "html" ? "html" : "md"; @@ -9719,7 +16386,7 @@ function createPageService(pool, options = {}) { return getPage(userId, pageId); } catch (error) { await conn.rollback(); - if (writtenPath) await fs13.rm(writtenPath, { force: true }).catch(() => { + if (writtenPath) await fs14.rm(writtenPath, { force: true }).catch(() => { }); throw error; } finally { @@ -9728,7 +16395,7 @@ function createPageService(pool, options = {}) { }; const findPageBySourceAsset = async (userId, assetId) => { if (!assetId) return null; - const [rows] = await pool.query( + const [rows] = await pool2.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 JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id @@ -9741,6 +16408,21 @@ function createPageService(pool, options = {}) { ); return rows[0] ? pageResponse(rows[0]) : null; }; + const findPageBySourceMessage = async (userId, sessionId, messageId) => { + if (!sessionId || !messageId) return null; + const [rows] = await pool2.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 + 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 p.user_id = ? AND p.source_session_id = ? AND p.source_message_id = ? AND p.status <> 'deleted' + ORDER BY p.updated_at DESC + LIMIT 1`, + [userId, sessionId, messageId] + ); + return rows[0] ? pageResponse(rows[0]) : null; + }; const listPages = async (userId, filters = {}) => { const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`]; const params = [userId]; @@ -9748,20 +16430,20 @@ function createPageService(pool, options = {}) { clauses.push(`p.status = ?`); params.push(filters.status); } - const [rows] = await pool.query( + const [rows] = await pool2.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 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.updated_at DESC LIMIT 100`, + ORDER BY p.created_at DESC, p.updated_at DESC LIMIT 100`, params ); return rows.map(pageResponse); }; async function getPage(userId, pageId) { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT p.*, c.category_code, pv.version_no, av.storage_key FROM h5_page_records p JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id @@ -9774,12 +16456,12 @@ function createPageService(pool, options = {}) { const row = rows[0]; if (!row) throw pageError("\u9875\u9762\u4E0D\u5B58\u5728", "page_not_found"); const storagePath = await resolveReadableStoragePath(row.storage_key); - const content = await fs13.readFile(storagePath, "utf8"); + const content = await fs14.readFile(storagePath, "utf8"); return pageResponse({ ...row, content }); } const listVersions = async (userId, pageId) => { await getPage(userId, pageId); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pv.id, pv.version_no, pv.change_note, pv.immutable, pv.created_at FROM h5_page_versions pv JOIN h5_page_records p ON p.id = pv.page_id @@ -9891,7 +16573,7 @@ ${updated.content ?? ""}`, throw pageError("\u672A\u53D1\u73B0\u53EF\u5B89\u5168\u4FEE\u590D\u7684\u79C1\u6709\u56FE\u7247\u5F15\u7528", "publish_fix_not_needed"); } const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))]; - const [assets] = await pool.query( + const [assets] = await pool2.query( `SELECT a.id, a.mime_type, a.original_filename, v.storage_key FROM h5_assets a JOIN h5_asset_versions v ON v.id = a.current_version_id @@ -9904,9 +16586,9 @@ ${updated.content ?? ""}`, for (const assetId of assetIds) { const asset = byId.get(assetId); if (!asset) continue; - const buffer = await fs13.readFile(absoluteStoragePath(asset.storage_key)); + const buffer = await fs14.readFile(absoluteStoragePath2(asset.storage_key)); const mimeType = String(asset.mime_type || "application/octet-stream"); - const ext = extensionForMime(mimeType, asset.original_filename); + const ext = extensionForMime2(mimeType, asset.original_filename); const dataUri = `data:${mimeType};base64,${buffer.toString("base64")}`; replacements.set(assetId, { dataUri, ext }); } @@ -10070,7 +16752,7 @@ ${updated.content ?? ""}`, return { deletedAssetCount, freedBytes }; }; const getDeletePreview = async (userId, pageId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT p.id, p.title, p.source_asset_id, p.status FROM h5_page_records p WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted' @@ -10079,7 +16761,7 @@ ${updated.content ?? ""}`, ); const page = rows[0]; if (!page) throw pageError("\u9875\u9762\u4E0D\u5B58\u5728", "page_not_found"); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { const [versionRows] = await conn.query( `SELECT COUNT(*) AS version_count FROM h5_page_versions WHERE page_id = ?`, @@ -10126,7 +16808,7 @@ ${updated.content ?? ""}`, } }; const deletePage = async (userId, pageId) => { - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [pages] = await conn.query( @@ -10186,7 +16868,7 @@ ${updated.content ?? ""}`, } }; const loadPageThumbnailContext = async (userId, pageId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT p.title, p.summary, p.page_type, pv.version_no, av.storage_key, pv.source_snapshot_json FROM h5_page_records p JOIN h5_page_versions pv ON pv.id = p.current_version_id @@ -10201,7 +16883,7 @@ ${updated.content ?? ""}`, throw pageError("\u8BE5\u9875\u9762\u4E0D\u652F\u6301\u7F29\u7565\u56FE", "thumbnail_not_supported"); } const storagePath = await resolveReadableStoragePath(row.storage_key); - const content = await fs13.readFile(storagePath, "utf8"); + const content = await fs14.readFile(storagePath, "utf8"); let snapshot = {}; try { snapshot = JSON.parse(row.source_snapshot_json ?? "{}"); @@ -10303,7 +16985,8 @@ ${updated.content ?? ""}`, sessionId: source.sessionId, messageId: source.messageId, assetId: source.assetId ?? null, - snapshot: source.snapshot + snapshot: source.snapshot, + createdAt: source.createdAt }), createFromAgent: (userId, input, source) => createVersion(userId, input, { type: "agent", @@ -10320,6 +17003,7 @@ ${updated.content ?? ""}`, createRedactedCopy: redactPage, listPages, findPageBySourceAsset, + findPageBySourceMessage, getPage, getDeletePreview, deletePage, @@ -10375,6 +17059,44 @@ var pageInternals = { redactContent, buildPageDeleteSummary }; +async function inlinePrivateAssetsInHtml(pool2, storageRoot, userId, html) { + const PATTERN = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; + const resolvedRoot = path18.resolve(storageRoot); + const absoluteStoragePath2 = (key) => { + const resolved = path18.resolve(resolvedRoot, key); + if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path18.sep}`)) { + throw Object.assign(new Error("\u8DEF\u5F84\u8D8A\u754C"), { code: "invalid_storage_path" }); + } + return resolved; + }; + const matches = [...String(html).matchAll(PATTERN)]; + if (matches.length === 0) return { html, count: 0 }; + const assetIds = [...new Set(matches.map((m) => m[1]).filter(Boolean))]; + const [assets] = await pool2.query( + `SELECT a.id, a.mime_type, a.original_filename, v.storage_key + FROM h5_assets a + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%' + AND a.status <> 'deleted'`, + [userId, assetIds] + ); + const dataUriMap = /* @__PURE__ */ new Map(); + for (const asset of assets) { + try { + const buffer = await fs14.readFile(absoluteStoragePath2(asset.storage_key)); + dataUriMap.set(asset.id, `data:${asset.mime_type};base64,${buffer.toString("base64")}`); + } catch { + } + } + let count = 0; + const result = String(html).replace(PATTERN, (_match, assetId) => { + const uri = dataUriMap.get(assetId); + if (!uri) return ""; + count += 1; + return uri; + }); + return { html: result, count }; +} function buildPageDeleteSummary(preview) { const lines = [ `\u9875\u9762\u300C${preview.page.title}\u300D\u53CA\u5176 ${preview.page.versionCount} \u4E2A\u7248\u672C` @@ -10797,10 +17519,10 @@ import { Agent as Agent4, fetch as undiciFetch4 } from "undici"; import { jsonrepair } from "jsonrepair"; // llm-providers.mjs -import crypto11 from "node:crypto"; -import fs14 from "node:fs"; +import crypto12 from "node:crypto"; +import fs15 from "node:fs"; import os from "node:os"; -import path15 from "node:path"; +import path19 from "node:path"; import { spawn, spawnSync } from "node:child_process"; import { Agent as Agent3, fetch as undiciFetch3 } from "undici"; var CUSTOM_PROVIDER_ID = "__custom__"; @@ -10888,12 +17610,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 crypto11.createHash("sha256").update(raw).digest(); + return crypto12.createHash("sha256").update(raw).digest(); } function encryptSecret(plaintext, encryptionKey) { const key = resolveEncryptionKey(encryptionKey); - const iv = crypto11.randomBytes(12); - const cipher = crypto11.createCipheriv("aes-256-gcm", key, iv); + const iv = crypto12.randomBytes(12); + const cipher = crypto12.createCipheriv("aes-256-gcm", key, iv); const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); return { ciphertext: encrypted.toString("base64"), @@ -10903,7 +17625,7 @@ function encryptSecret(plaintext, encryptionKey) { } function decryptSecret({ ciphertext, iv, tag }, encryptionKey) { const key = resolveEncryptionKey(encryptionKey); - const decipher = crypto11.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64")); + const decipher = crypto12.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")), @@ -11154,7 +17876,7 @@ function executorEnvForProfile(executor, profile, model, { includeSecret = false }; } function launchLogDir() { - return path15.join(os.tmpdir(), "memindadm-launches"); + return path19.join(os.tmpdir(), "memindadm-launches"); } function normalizeLaunchMode(mode) { const normalized = String(mode ?? "headless").trim().toLowerCase(); @@ -11184,13 +17906,13 @@ function spawnDetachedExecutor(plan, { logDir = launchLogDir() } = {}) { message: `${plan.command} \u672A\u5B89\u88C5\u6216\u4E0D\u5728 PATH \u4E2D` }; } - fs14.mkdirSync(logDir, { recursive: true }); + fs15.mkdirSync(logDir, { recursive: true }); const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); - const logFile = path15.join( + const logFile = path19.join( logDir, - `${plan.executor ?? "executor"}-${stamp}-${crypto11.randomUUID().slice(0, 8)}.log` + `${plan.executor ?? "executor"}-${stamp}-${crypto12.randomUUID().slice(0, 8)}.log` ); - const fd = fs14.openSync(logFile, "a"); + const fd = fs15.openSync(logFile, "a"); try { const child = spawn(plan.command, plan.args ?? [], { cwd: plan.cwd ?? process.cwd(), @@ -11221,7 +17943,7 @@ function spawnDetachedExecutor(plan, { logDir = launchLogDir() } = {}) { }; } finally { try { - fs14.closeSync(fd); + fs15.closeSync(fd); } catch { } } @@ -11546,8 +18268,11 @@ function validateCustomPayload(payload) { } }; } -function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, apiFetchImpl = undiciFetch3 } = {}) { +function createLlmProviderService(pool2, { apiTarget, apiTargets = [], apiSecret, encryptionKey, apiFetchImpl = undiciFetch3 } = {}) { const launchStates = /* @__PURE__ */ new Map(); + const syncTargets = [ + ...new Set([...Array.isArray(apiTargets) ? apiTargets : [], apiTarget].filter(Boolean)) + ]; function catalogItem(providerId) { return catalogById[providerId] ?? null; } @@ -11562,13 +18287,13 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a ); } async function getRowById(id) { - const [rows] = await pool.query("SELECT * FROM h5_llm_provider_keys WHERE id = ? LIMIT 1", [ + const [rows] = await pool2.query("SELECT * FROM h5_llm_provider_keys WHERE id = ? LIMIT 1", [ id ]); return rows[0] ?? null; } async function getSelectedRow() { - const [rows] = await pool.query( + const [rows] = await pool2.query( "SELECT * FROM h5_llm_provider_keys WHERE is_selected = 1 AND status = ? LIMIT 1", ["active"] ); @@ -11577,26 +18302,26 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a async function getExecutorBindingRow(executor, purpose = "default") { const normalizedExecutor = normalizeExecutor(executor); if (!normalizedExecutor) return null; - const [rows] = await pool.query( + const [rows] = await pool2.query( "SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1", [normalizedExecutor, normalizePurpose(purpose)] ); return rows[0] ?? null; } async function clearSelected() { - await pool.query("UPDATE h5_llm_provider_keys SET is_selected = 0, updated_at = ?", [ + await pool2.query("UPDATE h5_llm_provider_keys SET is_selected = 0, updated_at = ?", [ Date.now() ]); } async function getVisionRow() { - const [rows] = await pool.query( + const [rows] = await pool2.query( "SELECT * FROM h5_llm_provider_keys WHERE is_vision_selected = 1 AND status = ? LIMIT 1", ["active"] ); return rows[0] ?? null; } async function clearVisionSelected() { - await pool.query("UPDATE h5_llm_provider_keys SET is_vision_selected = 0, updated_at = ?", [ + await pool2.query("UPDATE h5_llm_provider_keys SET is_vision_selected = 0, updated_at = ?", [ Date.now() ]); } @@ -11617,7 +18342,7 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a fetchImpl ); if (profile.providerKind === "custom" && goosedProviderId !== row.goosed_provider_id) { - await pool.query( + await pool2.query( "UPDATE h5_llm_provider_keys SET goosed_provider_id = ?, provider_id = ?, updated_at = ? WHERE id = ?", [goosedProviderId, goosedProviderId, Date.now(), row.id] ); @@ -11886,7 +18611,7 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a catalog: LLM_PROVIDER_CATALOG, executorCatalog: LLM_EXECUTOR_CATALOG, async listKeys() { - const [rows] = await pool.query( + const [rows] = await pool2.query( "SELECT * FROM h5_llm_provider_keys ORDER BY is_selected DESC, updated_at DESC" ); return rows.map( @@ -11896,7 +18621,7 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a async listExecutorBindings() { const keys = await this.listKeys(); const keyMap = new Map(keys.map((key) => [key.id, key])); - const [rows] = await pool.query( + const [rows] = await pool2.query( 'SELECT * FROM h5_llm_executor_bindings ORDER BY FIELD(executor, "goose", "aider", "openhands"), purpose' ); const rowMap = new Map( @@ -11947,18 +18672,18 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a const existing = await getExecutorBindingRow(normalizedExecutor, purpose); const now = Date.now(); if (existing) { - await pool.query( + await pool2.query( `UPDATE h5_llm_executor_bindings SET provider_key_id = ?, model = ?, enabled = ?, updated_at = ? WHERE id = ?`, [keyId, model, enabled ? 1 : 0, now, existing.id] ); } else { - await pool.query( + await pool2.query( `INSERT INTO h5_llm_executor_bindings (id, executor, purpose, provider_key_id, model, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [crypto11.randomUUID(), normalizedExecutor, purpose, keyId, model, enabled ? 1 : 0, now, now] + [crypto12.randomUUID(), normalizedExecutor, purpose, keyId, model, enabled ? 1 : 0, now, now] ); } const row = await getExecutorBindingRow(normalizedExecutor, purpose); @@ -12157,18 +18882,18 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6A21\u578B" }; } } - const [existing] = await pool.query( + const [existing] = await pool2.query( "SELECT id FROM h5_llm_provider_keys WHERE name = ? LIMIT 1", [insertName] ); if (existing.length > 0) return { ok: false, message: "\u914D\u7F6E\u540D\u79F0\u5DF2\u5B58\u5728" }; - const [countRows] = await pool.query("SELECT COUNT(*) AS total FROM h5_llm_provider_keys"); + const [countRows] = await pool2.query("SELECT COUNT(*) AS total FROM h5_llm_provider_keys"); const shouldSelect = Number(countRows[0]?.total ?? 0) === 0; const encrypted = encryptSecret(apiKey, encryptionKey); const now = Date.now(); - const id = crypto11.randomUUID(); + const id = crypto12.randomUUID(); if (shouldSelect) await clearSelected(); - await pool.query( + await pool2.query( `INSERT INTO h5_llm_provider_keys (id, provider_id, provider_kind, api_url, base_path, models_json, goosed_provider_id, engine, relay_provider, name, api_key_ciphertext, api_key_iv, api_key_tag, default_model, status, is_selected, created_at, updated_at) @@ -12207,7 +18932,7 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a const nextApiKey = payload?.apiKey !== void 0 ? String(payload.apiKey).trim() : null; const nextStatus = payload?.status === "disabled" || payload?.status === "active" ? payload.status : row.status; if (!nextName) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" }; - const [existing] = await pool.query( + const [existing] = await pool2.query( "SELECT id FROM h5_llm_provider_keys WHERE name = ? AND id <> ? LIMIT 1", [nextName, id] ); @@ -12248,7 +18973,7 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a tag: row.api_key_tag }; const now = Date.now(); - await pool.query( + await pool2.query( `UPDATE h5_llm_provider_keys SET name = ?, api_url = ?, base_path = ?, models_json = ?, engine = ?, relay_provider = ?, api_key_ciphertext = ?, api_key_iv = ?, api_key_tag = ?, @@ -12287,7 +19012,7 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a } await clearSelected(); const now = Date.now(); - await pool.query( + await pool2.query( "UPDATE h5_llm_provider_keys SET is_selected = 1, updated_at = ? WHERE id = ?", [now, id] ); @@ -12312,23 +19037,33 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a apiFetchImpl ); } - await pool.query("DELETE FROM h5_llm_provider_keys WHERE id = ?", [id]); + await pool2.query("DELETE FROM h5_llm_provider_keys WHERE id = ?", [id]); return { ok: true }; }, async syncSelectedToGoosed() { - let resolved = await resolveExecutorProvider("goose"); - if (!resolved.ok) { - resolved = await resolveSelectedProvider(); - } - if (!resolved.ok) { - return { ok: false, synced: false, message: resolved.message }; + const targets = syncTargets.length ? syncTargets : [apiTarget].filter(Boolean); + let lastResolved = null; + for (const target of targets) { + const targetFetch = (url, init) => { + const pathname = `${url.pathname}${url.search}`; + return goosedApiFetch(target, apiSecret, pathname, init, apiFetchImpl); + }; + let resolved = await resolveExecutorProvider("goose", "default", targetFetch); + if (!resolved.ok) { + resolved = await resolveSelectedProvider(targetFetch); + } + if (!resolved.ok) { + return { ok: false, synced: false, target, message: resolved.message }; + } + lastResolved = resolved; } return { ok: true, synced: true, - source: resolved.source, - providerId: resolved.providerId, - model: resolved.model + targets, + source: lastResolved.source, + providerId: lastResolved.providerId, + model: lastResolved.model }; }, async applyBestProviderForSession(sessionId, fetchImpl = apiFetchImpl) { @@ -12385,7 +19120,7 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a } await clearVisionSelected(); const now = Date.now(); - await pool.query( + await pool2.query( "UPDATE h5_llm_provider_keys SET is_vision_selected = 1, default_model = ?, updated_at = ? WHERE id = ?", [nextModel, now, keyId] ); @@ -12480,7 +19215,7 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a if (!publicRow.models.includes(nextModel)) { return { ok: false, message: "\u6A21\u578B\u4E0D\u5728\u5F53\u524D Provider \u652F\u6301\u5217\u8868\u4E2D" }; } - await pool.query( + await pool2.query( "UPDATE h5_llm_provider_keys SET default_model = ?, updated_at = ? WHERE id = ?", [nextModel, Date.now(), row.id] ); @@ -12544,7 +19279,7 @@ function createLlmProviderService(pool, { apiTarget, apiSecret, encryptionKey, a ); }, async ensureBootstrapRelay() { - const [existing] = await pool.query( + const [existing] = await pool2.query( "SELECT id FROM h5_llm_provider_keys WHERE name = ? LIMIT 1", [RELAY_BOOTSTRAP.name] ); @@ -12629,8 +19364,8 @@ function buildCoverPrompt({ title, summary, html, instruction, currentCover }) { instruction ? `\u7528\u6237\u8865\u5145\u8981\u6C42\uFF1A${instruction}` : "" ].filter(Boolean).join("\n"); } -async function suggestCoverMetaWithAi(pool, { title, summary, html, instruction, encryptionKey }) { - const [rows] = await pool.query( +async function suggestCoverMetaWithAi(pool2, { title, summary, html, instruction, encryptionKey }) { + const [rows] = await pool2.query( `SELECT * FROM h5_llm_provider_keys WHERE is_selected = 1 AND status = 'active' LIMIT 1` ); const row = rows[0]; @@ -12705,9 +19440,9 @@ async function suggestCoverMetaWithAi(pool, { title, summary, html, instruction, } // mindspace-publications.mjs -import crypto12 from "node:crypto"; -import fs15 from "node:fs/promises"; -import path16 from "node:path"; +import crypto13 from "node:crypto"; +import fs16 from "node:fs/promises"; +import path20 from "node:path"; // mindspace-html-localize.mjs var GOOGLE_FONTS_CSS_IMPORT_RE = /@import\s+url\(\s*['"]?(https:\/\/fonts\.googleapis\.com\/[^'")\s]+)['"]?\s*\)\s*;?/gi; @@ -12788,7 +19523,7 @@ function normalizeSlug(value) { } return slug; } -async function findAvailableSlug(pool, userId, preferredSlug, excludePageId) { +async function findAvailableSlug(pool2, userId, preferredSlug, excludePageId) { const normalized = normalizeSlug(preferredSlug); const candidates = [normalized]; for (let index = 2; index <= 20; index += 1) { @@ -12797,7 +19532,7 @@ async function findAvailableSlug(pool, userId, preferredSlug, excludePageId) { candidates.push(`${normalized}-${excludePageId.replace(/-/g, "").slice(0, 8)}`); for (const candidate of candidates) { if (!SLUG_PATTERN.test(candidate)) continue; - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id FROM h5_publish_records WHERE user_id = ? AND url_slug = ? AND status = 'online' AND page_id <> ? LIMIT 1`, @@ -12805,7 +19540,7 @@ async function findAvailableSlug(pool, userId, preferredSlug, excludePageId) { ); if (!rows[0]) return candidate; } - return `${normalized}-${crypto12.randomBytes(3).toString("hex")}`; + return `${normalized}-${crypto13.randomBytes(3).toString("hex")}`; } function normalizeAccessMode(value) { const mode = String(value ?? "public"); @@ -12831,16 +19566,16 @@ function normalizeExpiresAt(value, required) { return expiresAt; } function hashPassword(password) { - const salt = crypto12.randomBytes(16); - const hash = crypto12.scryptSync(password, salt, 32); + const salt = crypto13.randomBytes(16); + const hash = crypto13.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 = crypto12.scryptSync(String(password ?? ""), Buffer.from(saltHex, "hex"), expected.length); - return expected.length === actual.length && crypto12.timingSafeEqual(expected, actual); + const actual = crypto13.scryptSync(String(password ?? ""), Buffer.from(saltHex, "hex"), expected.length); + return expected.length === actual.length && crypto13.timingSafeEqual(expected, actual); } function deviceType(userAgent) { const value = String(userAgent ?? ""); @@ -12904,13 +19639,48 @@ function publicHomepageResponse(owner, pages) { function buildPublicationThumbnailFallback(ownerSlug, urlSlug) { return `/u/${encodeURIComponent(ownerSlug)}/pages/${encodeURIComponent(urlSlug)}.thumbnail.png`; } -async function localizePrivateImageReferences({ pool, userId, html, absoluteStoragePath }) { +var PUBLIC_IMAGE_STORAGE_KEY_PATTERN2 = /^users\/([^/]+)\/images\/(\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)$/i; +function storageKeyToPublicStandardImageUrl(storageKey, publicBaseUrl) { + const match = String(storageKey ?? "").match(PUBLIC_IMAGE_STORAGE_KEY_PATTERN2); + if (!match) return null; + return buildPublicUrl(publicBaseUrl, match[1], `public/images/${match[2]}`); +} +function replaceImgproxyStandardImageReferences(html, publicBaseUrl) { + const source = String(html ?? ""); + if (!source) return source; + return source.replace( + /(?:https?:\/\/[^/"'<>?\s,)]+)?\/[^"'<>?\s,)]*plain\/local:\/\/\/(users\/[^"'<>?\s,)]*?\/images\/\d{4}-\d{2}-\d{2}\/[^"'<>?\s,@)]+)(?:@[a-z0-9]+)?(?:\?[^"'<>),\s]*)?/gi, + (value, storageKey) => storageKeyToPublicStandardImageUrl(storageKey, publicBaseUrl) ?? value + ); +} +function workspacePublicAssetRelativeUrl(htmlRelativePath, assetRelativePath) { + const htmlPath = String(htmlRelativePath ?? "").replace(/^\/+/, "") || "public/index.html"; + const assetPath = String(assetRelativePath ?? "").replace(/^\/+/, ""); + const htmlDir = path20.posix.dirname(htmlPath); + const relativePath = path20.posix.relative(htmlDir === "." ? "" : htmlDir, assetPath).replace(/\\/g, "/"); + return relativePath || path20.posix.basename(assetPath); +} +function rewriteWorkspacePublicAssetReferences(html, htmlRelativePath) { + const source = String(html ?? ""); + if (!source) return source; + return source.replace( + /public\/((?:images|\.tmp-images)\/[^"'<>@\s)]+(?:\?[^"'<>)\s]*)?)/gi, + (value, assetRelativePath) => workspacePublicAssetRelativeUrl(htmlRelativePath, `public/${assetRelativePath}`) + ); +} +async function localizePrivateImageReferences({ + pool: pool2, + userId, + html, + absoluteStoragePath: absoluteStoragePath2, + imgproxySigner = null +}) { const source = String(html ?? ""); const matches = [...source.matchAll(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN)]; if (matches.length === 0) return source; const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))]; if (assetIds.length === 0) return source; - const [assets] = await pool.query( + const [assets] = await pool2.query( `SELECT a.id, a.mime_type, v.storage_key FROM h5_assets a JOIN h5_asset_versions v ON v.id = a.current_version_id @@ -12923,9 +19693,13 @@ async function localizePrivateImageReferences({ pool, userId, html, absoluteStor for (const assetId of assetIds) { const asset = byId.get(assetId); if (!asset) continue; - const mimeType = String(asset.mime_type || "application/octet-stream"); - const buffer = await fs15.readFile(absoluteStoragePath(asset.storage_key)); - replacements.set(assetId, `data:${mimeType};base64,${buffer.toString("base64")}`); + if (imgproxySigner) { + replacements.set(assetId, imgproxySigner.buildUrl(imgproxySigner.baseUrl, asset.storage_key, "display")); + } else { + const mimeType = String(asset.mime_type || "application/octet-stream"); + const buffer = await fs16.readFile(absoluteStoragePath2(asset.storage_key)); + replacements.set(assetId, `data:${mimeType};base64,${buffer.toString("base64")}`); + } } if (replacements.size === 0) return source; return source.replace(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN, (value, assetId) => { @@ -12933,40 +19707,61 @@ async function localizePrivateImageReferences({ pool, userId, html, absoluteStor }); } async function prepareHtmlPublishContent({ - pool, + pool: pool2, userId, html, ownerSlug, urlSlug, - absoluteStoragePath + htmlRelativePath = `public/${urlSlug}.html`, + absoluteStoragePath: absoluteStoragePath2, + imgproxySigner = null }) { let publishContent = (await localizeGoogleFontsCss(html)).html; + publishContent = replaceImgproxyStandardImageReferences( + publishContent, + resolvePublicBaseUrl() + ); publishContent = await localizePrivateImageReferences({ - pool, + pool: pool2, userId, html: publishContent, - absoluteStoragePath + absoluteStoragePath: absoluteStoragePath2, + imgproxySigner }); + publishContent = rewriteWorkspacePublicAssetReferences(publishContent, htmlRelativePath); return replacePrivateResourceReferences( publishContent, buildPublicationThumbnailFallback(ownerSlug, urlSlug) ); } -function createPublicationService(pool, options = {}) { - const storageRoot = path16.resolve(options.storageRoot ?? path16.join(process.cwd(), "data", "mindspace")); - const idFactory = options.idFactory ?? (() => crypto12.randomUUID()); +function createPublicationService(pool2, options = {}) { + const storageRoot = path20.resolve(options.storageRoot ?? path20.join(process.cwd(), "data", "mindspace")); + const idFactory = options.idFactory ?? (() => crypto13.randomUUID()); const publicPageLimitFallback = Number(options.publicPageLimit ?? 5); + let imgproxySigner = null; + if (process.env.IMGPROXY_BASE_URL && process.env.IMGPROXY_SIGNING_KEY && process.env.IMGPROXY_SIGNING_SALT) { + try { + imgproxySigner = createImgproxySigner( + process.env.IMGPROXY_SIGNING_KEY, + process.env.IMGPROXY_SIGNING_SALT + ); + imgproxySigner.baseUrl = process.env.IMGPROXY_BASE_URL; + console.log("[Publication] imgproxy signer initialized"); + } catch (err) { + console.warn("[Publication] imgproxy signer init failed:", err instanceof Error ? err.message : err); + } + } const resolvePublicPageLimit = async () => { try { - const config = await loadMindSpaceConfig(pool); + const config = await loadMindSpaceConfig(pool2); return Number(config.publicPageLimit ?? publicPageLimitFallback); } catch { return publicPageLimitFallback; } }; - const absoluteStoragePath = (storageKey) => { - const resolved = path16.resolve(storageRoot, storageKey); - if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path16.sep}`)) { + const absoluteStoragePath2 = (storageKey) => { + const resolved = path20.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path20.sep}`)) { throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); } return resolved; @@ -12986,9 +19781,9 @@ function createPublicationService(pool, options = {}) { const resolveReadableStoragePath = async (storageKey) => { let lastError = null; for (const candidate of resolveStoragePathCandidates(storageKey)) { - const absolutePath = absoluteStoragePath(candidate); + const absolutePath = absoluteStoragePath2(candidate); try { - await fs15.stat(absolutePath); + await fs16.stat(absolutePath); return absolutePath; } catch (error) { if (error?.code === "ENOENT") { @@ -13001,7 +19796,7 @@ function createPublicationService(pool, options = {}) { throw lastError ?? Object.assign(new Error("\u5B58\u50A8\u6587\u4EF6\u4E0D\u5B58\u5728"), { code: "storage_not_found" }); }; const loadVersion = async (userId, pageId, pageVersionId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT p.id AS page_id, p.title, p.summary, p.page_type, p.template_id, p.current_version_id, p.user_id, p.space_id, pv.id AS page_version_id, pv.version_no, pv.bundle_asset_id, av.storage_key @@ -13017,11 +19812,11 @@ function createPublicationService(pool, options = {}) { return { ...row, version_no: Number(row.version_no), - content: await fs15.readFile(await resolveReadableStoragePath(row.storage_key), "utf8") + content: await fs16.readFile(await resolveReadableStoragePath(row.storage_key), "utf8") }; }; const loadOwnerSlug = async (userId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT COALESCE(slug, username) AS public_slug FROM h5_users WHERE id = ? @@ -13036,12 +19831,13 @@ function createPublicationService(pool, options = {}) { let publishContent = page.content; if (page.page_type !== "html") return publishContent; return prepareHtmlPublishContent({ - pool, + pool: pool2, userId: page.user_id, html: publishContent, ownerSlug, urlSlug, - absoluteStoragePath + absoluteStoragePath: absoluteStoragePath2, + imgproxySigner: imgproxySigner ? { buildUrl: (path29, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path29, preset) } : null }); }; const persistScan = async (conn, userId, pageVersionId, scan, now) => { @@ -13095,7 +19891,7 @@ function createPublicationService(pool, options = {}) { if (page.page_version_id !== page.current_version_id) { throw publicationError("\u53EA\u80FD\u53D1\u5E03\u9875\u9762\u7684\u5F53\u524D\u7248\u672C", "invalid_state_transition"); } - const [conflicts] = await pool.query( + const [conflicts] = await pool2.query( `SELECT pr.id, pr.page_id, p.title FROM h5_publish_records pr JOIN h5_page_records p ON p.id = pr.page_id @@ -13112,7 +19908,7 @@ ${publishContent}`, { format: page.page_type === "html" ? "html" : "text", allowHtmlActiveContent: page.page_type === "html" }); - const suggestedUrlSlug = conflict && accessMode !== "private_link" ? await findAvailableSlug(pool, userId, slug, pageId) : null; + const suggestedUrlSlug = conflict && accessMode !== "private_link" ? await findAvailableSlug(pool2, userId, slug, pageId) : null; return { pageVersionId: page.page_version_id, urlSlug: slug, @@ -13147,7 +19943,7 @@ ${publishContent}`, { const publishContent = await preparePublishContent(page, ownerSlug, result.urlSlug); const html = pageInternals.renderPublicationHtml({ ...page, content: publishContent }); const htmlBytes = Buffer.byteLength(html); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); let writtenPath; try { await conn.beginTransaction(); @@ -13187,20 +19983,20 @@ ${publishContent}`, { const bundleAssetId = idFactory(); const assetVersionId = idFactory(); const publishId = idFactory(); - const privateToken = result.accessMode === "private_link" ? crypto12.randomBytes(24).toString("base64url") : null; - const tokenHash = privateToken ? crypto12.createHash("sha256").update(privateToken).digest("hex") : null; + const privateToken = result.accessMode === "private_link" ? crypto13.randomBytes(24).toString("base64url") : null; + const tokenHash = privateToken ? crypto13.createHash("sha256").update(privateToken).digest("hex") : null; const passwordHash = result.accessMode === "password" ? hashPassword(input.password) : null; - const storageKey = path16.posix.join( + const storageKey = path20.posix.join( "users", userId, "publications", publishId, "index.html" ); - writtenPath = absoluteStoragePath(storageKey); - await fs15.mkdir(path16.dirname(writtenPath), { recursive: true }); - await fs15.writeFile(writtenPath, html, { flag: "wx" }); - const checksum = crypto12.createHash("sha256").update(html).digest("hex"); + 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 publicBaseUrl = resolvePublicBaseUrl(); const publicUrl2 = privateToken ? `/s/${privateToken}` : `${publicBaseUrl}/u/${encodeURIComponent(ownerSlug)}/pages/${result.urlSlug}`; await conn.query( @@ -13260,9 +20056,9 @@ ${publishContent}`, { await conn.query( `INSERT INTO h5_publish_records (id, user_id, page_id, page_version_id, publish_type, url_slug, public_url, - access_mode, password_hash, token_hash, token_prefix, expires_at, published_at, + access_mode, password_hash, token_hash, token_prefix, expires_at, user_confirmed_at, published_at, status, view_count, security_scan_id, created_at, updated_at) - VALUES (?, ?, ?, ?, 'page', ?, ?, ?, ?, ?, ?, ?, ?, 'online', 0, ?, ?, ?)`, + VALUES (?, ?, ?, ?, 'page', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'online', 0, ?, ?, ?)`, [ publishId, userId, @@ -13275,6 +20071,7 @@ ${publishContent}`, { tokenHash, privateToken?.slice(0, 8) ?? null, result.expiresAt, + null, now, scanId, now, @@ -13316,6 +20113,20 @@ ${publishContent}`, { WHERE id = ? AND user_id = ?`, [htmlBytes, now, page.space_id, userId] ); + const assetUrlPattern = /\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)/gi; + const assetIds = /* @__PURE__ */ new Set(); + let match; + while ((match = assetUrlPattern.exec(html)) !== null) { + assetIds.add(match[1]); + } + if (assetIds.size > 0) { + const refValues = Array.from(assetIds).map((assetId) => [publishId, assetId, now]); + await conn.query( + `INSERT IGNORE INTO h5_publication_asset_refs + (publication_id, asset_id, created_at) VALUES ` + refValues.map(() => "(?, ?, ?)").join(","), + refValues.flat() + ); + } await conn.commit(); return { ...publicationResponse({ @@ -13335,7 +20146,7 @@ ${publishContent}`, { }; } catch (error) { await conn.rollback(); - if (writtenPath) await fs15.rm(writtenPath, { force: true }).catch(() => { + if (writtenPath) await fs16.rm(writtenPath, { force: true }).catch(() => { }); throw error; } finally { @@ -13343,7 +20154,7 @@ ${publishContent}`, { } }; const getCurrent = async (userId, pageId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pr.* FROM h5_publish_records pr JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id WHERE pr.page_id = ? AND pr.user_id = ? AND pr.status = 'online' @@ -13352,8 +20163,44 @@ ${publishContent}`, { ); return publicationResponse(rows[0]); }; + const updatePublicationStatus = async (userId, publicationId, { accessMode, expiresAt }) => { + const conn = await pool2.getConnection(); + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT * FROM h5_publish_records + WHERE id = ? AND user_id = ? AND status = 'online' LIMIT 1 FOR UPDATE`, + [publicationId, userId] + ); + const publication = rows[0]; + if (!publication) throw publicationError("\u53D1\u5E03\u8BB0\u5F55\u4E0D\u5B58\u5728", "publication_not_found"); + const normalizedMode = normalizeAccessMode(accessMode); + const normalizedExpiresAt = normalizeExpiresAt(expiresAt, normalizedMode === "time_limited"); + const now = Date.now(); + await conn.query( + `UPDATE h5_publish_records + SET access_mode = ?, expires_at = ?, user_confirmed_at = ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [normalizedMode, normalizedExpiresAt, now, now, publicationId, userId] + ); + await conn.commit(); + const updated = { + ...publication, + access_mode: normalizedMode, + expires_at: normalizedExpiresAt, + user_confirmed_at: now, + updated_at: now + }; + return publicationResponse(updated); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + }; const offline = async (userId, publicationId) => { - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( @@ -13406,7 +20253,7 @@ ${publishContent}`, { const resolveRow = async (row, viewerId, password, requestMeta = {}) => { if (!row) throw publicationError("\u516C\u5F00\u9875\u9762\u4E0D\u5B58\u5728", "publication_not_found"); if (row.expires_at && Number(row.expires_at) <= Date.now()) { - await pool.query( + await pool2.query( `UPDATE h5_publish_records SET status = 'expired', updated_at = ? WHERE id = ?`, [Date.now(), row.id] ); @@ -13422,11 +20269,11 @@ ${publishContent}`, { throw publicationError("\u9700\u8981\u8BBF\u95EE\u5BC6\u7801", "publication_password_required"); } const now = Date.now(); - await pool.query( + await pool2.query( `UPDATE h5_publish_records SET view_count = view_count + 1, updated_at = ? WHERE id = ?`, [now, row.id] ); - await pool.query( + await pool2.query( `INSERT INTO h5_publication_views (id, publish_id, viewer_user_id, referrer_host, device_type, viewed_at) VALUES (?, ?, ?, ?, ?, ?)`, @@ -13440,12 +20287,12 @@ ${publishContent}`, { ] ); return { - html: await fs15.readFile(await resolveReadableStoragePath(row.storage_key), "utf8"), + html: await fs16.readFile(await resolveReadableStoragePath(row.storage_key), "utf8"), publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }) }; }; const resolvePublic = async (ownerSlug, urlSlug, viewerId, password, requestMeta) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pr.*, u.id AS owner_id, av.storage_key FROM h5_publish_records pr JOIN h5_users u ON u.id = pr.user_id @@ -13459,8 +20306,8 @@ ${publishContent}`, { return resolveRow(rows[0], viewerId, password, requestMeta); }; const resolvePrivateLink = async (token, viewerId, requestMeta) => { - const tokenHash = crypto12.createHash("sha256").update(String(token)).digest("hex"); - const [rows] = await pool.query( + const tokenHash = crypto13.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 JOIN h5_users u ON u.id = pr.user_id @@ -13473,7 +20320,7 @@ ${publishContent}`, { return resolveRow(rows[0], viewerId, null, requestMeta); }; const getStats = async (userId, publicationId) => { - const [publications] = await pool.query( + const [publications] = await pool2.query( `SELECT id, view_count, published_at, status FROM h5_publish_records WHERE id = ? AND user_id = ? LIMIT 1`, [publicationId, userId] @@ -13481,7 +20328,7 @@ ${publishContent}`, { const publication = publications[0]; if (!publication) throw publicationError("\u53D1\u5E03\u8BB0\u5F55\u4E0D\u5B58\u5728", "publication_not_found"); const since = Date.now() - 30 * 24 * 60 * 60 * 1e3; - const [daily] = await pool.query( + const [daily] = await pool2.query( `SELECT DATE_FORMAT(FROM_UNIXTIME(viewed_at / 1000), '%Y-%m-%d') AS day, COUNT(*) AS views FROM h5_publication_views @@ -13489,13 +20336,13 @@ ${publishContent}`, { GROUP BY day ORDER BY day`, [publicationId, since] ); - const [devices] = await pool.query( + const [devices] = await pool2.query( `SELECT device_type, COUNT(*) AS views FROM h5_publication_views WHERE publish_id = ? GROUP BY device_type ORDER BY views DESC`, [publicationId] ); - const [sources] = await pool.query( + const [sources] = await pool2.query( `SELECT COALESCE(referrer_host, 'direct') AS source, COUNT(*) AS views FROM h5_publication_views WHERE publish_id = ? GROUP BY source ORDER BY views DESC LIMIT 10`, @@ -13515,7 +20362,7 @@ ${publishContent}`, { }; }; const getPublicHomepage = async (ownerSlug) => { - const [owners] = await pool.query( + const [owners] = await pool2.query( `SELECT id, username, COALESCE(slug, username) AS slug, display_name FROM h5_users WHERE COALESCE(slug, username) = ? @@ -13524,7 +20371,7 @@ ${publishContent}`, { ); const owner = owners[0]; if (!owner) throw publicationError("\u516C\u5F00\u4E3B\u9875\u4E0D\u5B58\u5728", "publication_not_found"); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pr.id, pr.page_id, pr.url_slug, pr.public_url, pr.view_count, pr.published_at, p.title, p.summary, p.template_id FROM h5_publish_records pr @@ -13536,6 +20383,19 @@ ${publishContent}`, { ); return publicHomepageResponse(owner, rows); }; + const cleanupExpiredUnconfirmedPublications = async (now = Date.now()) => { + const [result] = await pool2.query( + `UPDATE h5_publish_records + SET access_mode = 'private', expires_at = NULL, updated_at = ? + WHERE access_mode = 'public' + AND expires_at IS NOT NULL + AND expires_at <= ? + AND user_confirmed_at IS NULL + AND status = 'online'`, + [now, now] + ); + return { cleaned: result.affectedRows }; + }; return { check, publish, @@ -13543,13 +20403,15 @@ ${publishContent}`, { getPublicHomepage, getStats, offline, + updatePublicationStatus, resolvePublic, - resolvePrivateLink + resolvePrivateLink, + cleanupExpiredUnconfirmedPublications }; } // plaza-posts.mjs -import crypto13 from "node:crypto"; +import crypto14 from "node:crypto"; // plaza-algorithm.mjs var DEFAULT_CONFIG = { @@ -13568,16 +20430,16 @@ function computeHotScore(stats, config = DEFAULT_CONFIG, nowMs2 = Date.now()) { if (denominator <= 0) return 0; return Math.max(0, numerator / denominator); } -async function loadAlgorithmConfig(pool) { - const [rows] = await pool.query(`SELECT \`key\`, value FROM plaza_algorithm_config`); +async function loadAlgorithmConfig(pool2) { + const [rows] = await pool2.query(`SELECT \`key\`, value FROM plaza_algorithm_config`); const config = { ...DEFAULT_CONFIG }; for (const row of rows) { if (row.key in config) config[row.key] = Number(row.value); } return config; } -async function ensureAlgorithmConfig(pool) { - const [rows] = await pool.query(`SELECT COUNT(*) AS count FROM plaza_algorithm_config`); +async function ensureAlgorithmConfig(pool2) { + const [rows] = await pool2.query(`SELECT COUNT(*) AS count FROM plaza_algorithm_config`); if (Number(rows[0]?.count ?? 0) > 0) return; const now = Date.now(); const seeds = [ @@ -13589,26 +20451,26 @@ async function ensureAlgorithmConfig(pool) { ["decay_exp", 1.5, "\u65F6\u95F4\u8870\u51CF\u6307\u6570"] ]; for (const [key, value, description] of seeds) { - await pool.query( + await pool2.query( `INSERT INTO plaza_algorithm_config (\`key\`, value, description, updated_at) VALUES (?, ?, ?, ?)`, [key, value, description, now] ); } } -async function recalculateHotScores(pool, { windowHours = 48 } = {}) { - await ensureAlgorithmConfig(pool); - const config = await loadAlgorithmConfig(pool); +async function recalculateHotScores(pool2, { windowHours = 48 } = {}) { + await ensureAlgorithmConfig(pool2); + const config = await loadAlgorithmConfig(pool2); const now = Date.now(); const windowStart = now - windowHours * 36e5; - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id, view_count, like_count, comment_count, collect_count, published_at FROM plaza_posts WHERE status = 'published' AND published_at >= ?`, [windowStart] ); if (rows.length === 0) return { updated: 0 }; - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); for (const row of rows) { @@ -13732,8 +20594,8 @@ function formatPostRow(row, { category, viewerReacted = null, publicationUrl = n ...row.status ? { status: row.status } : {} }; } -function createPlazaPostService(pool, { - idFactory = () => crypto13.randomUUID(), +function createPlazaPostService(pool2, { + idFactory = () => crypto14.randomUUID(), loadViewerReactions = null, plazaRedis: plazaRedis2 = null, algorithmConfig = null, @@ -13763,11 +20625,11 @@ function createPlazaPostService(pool, { ); }; const ensureCategories = async () => { - const [rows] = await pool.query(`SELECT COUNT(*) AS count FROM plaza_categories`); + const [rows] = await pool2.query(`SELECT COUNT(*) AS count FROM plaza_categories`); if (Number(rows[0]?.count ?? 0) > 0) return; const now = Date.now(); for (const category of DEFAULT_CATEGORIES) { - await pool.query( + await pool2.query( `INSERT INTO plaza_categories (id, name, slug, icon, description, sort_order, is_active, created_at) VALUES (?, ?, ?, ?, '', ?, 1, ?)`, @@ -13777,7 +20639,7 @@ function createPlazaPostService(pool, { }; const listCategories = async () => { await ensureCategories(); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT c.id, c.name, c.slug, c.icon, COUNT(p.id) AS post_count FROM plaza_categories c @@ -13798,7 +20660,7 @@ function createPlazaPostService(pool, { const resolveCategoryId = async (categoryId, categorySlug) => { await ensureCategories(); if (categoryId) { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id FROM plaza_categories WHERE id = ? AND is_active = 1 LIMIT 1`, [categoryId] ); @@ -13806,7 +20668,7 @@ function createPlazaPostService(pool, { return rows[0].id; } if (categorySlug) { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id FROM plaza_categories WHERE slug = ? AND is_active = 1 LIMIT 1`, [categorySlug] ); @@ -13816,7 +20678,7 @@ function createPlazaPostService(pool, { throw plazaError("\u5FC5\u987B\u9009\u62E9\u5206\u7C7B", "category_required"); }; const loadPublicationContext = async (userId, publicationId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pr.id, pr.user_id, pr.status, pr.public_url, p.title, p.summary, p.cover_image_asset_id FROM h5_publish_records pr @@ -13833,7 +20695,7 @@ function createPlazaPostService(pool, { return row; }; const loadUserSnapshot = async (userId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id, slug, display_name, username, plaza_post_banned FROM h5_users WHERE id = ? LIMIT 1`, [userId] @@ -13850,7 +20712,7 @@ function createPlazaPostService(pool, { const createPost = async (userId, input) => { const publicationId = String(input?.publication_id ?? "").trim(); if (!publicationId) throw plazaError("publication_id \u4E0D\u80FD\u4E3A\u7A7A", "invalid_input"); - const [existing] = await pool.query( + const [existing] = await pool2.query( `SELECT id FROM plaza_posts WHERE publication_id = ? LIMIT 1`, [publicationId] ); @@ -13865,7 +20727,7 @@ function createPlazaPostService(pool, { const postId = idFactory(); const status = autoApprove ? "published" : "pending_review"; const hotScore = status === "published" ? await resolveHotScore(now) : 0; - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); await conn.query( @@ -13914,7 +20776,7 @@ function createPlazaPostService(pool, { return { id: postId, status }; }; const updatePost = async (userId, postId, input) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.*, pr.public_url FROM plaza_posts pp JOIN h5_publish_records pr ON pr.id = pp.publication_id @@ -13948,7 +20810,7 @@ function createPlazaPostService(pool, { updates.push("status = ?"); updates.push("updated_at = ?"); params.push(status, Date.now(), postId, userId); - await pool.query( + await pool2.query( `UPDATE plaza_posts SET ${updates.join(", ")} WHERE id = ? AND user_id = ?`, params ); @@ -13956,7 +20818,7 @@ function createPlazaPostService(pool, { }; const hidePost = async (userId, postId) => { const now = Date.now(); - const [result] = await pool.query( + const [result] = await pool2.query( `UPDATE plaza_posts SET status = 'hidden', updated_at = ? WHERE id = ? AND user_id = ? AND status != 'hidden'`, [now, postId, userId] @@ -13973,7 +20835,7 @@ function createPlazaPostService(pool, { }; const getPostById = async (postId, { viewerId = null, includeHidden = false } = {}) => { const statusClause = includeHidden ? "" : `AND pp.status = 'published'`; - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id @@ -14035,7 +20897,7 @@ function createPlazaPostService(pool, { } let cursorClause = ""; if (cursor) { - const [cursorRows] = await pool.query( + const [cursorRows] = await pool2.query( `SELECT id, hot_score, published_at FROM plaza_posts WHERE id = ? LIMIT 1`, [cursor] ); @@ -14051,7 +20913,7 @@ function createPlazaPostService(pool, { } } const orderBy = normalizedSort === "new" ? "pp.published_at DESC, pp.id DESC" : "pp.hot_score DESC, pp.id DESC"; - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id @@ -14128,7 +20990,7 @@ function createPlazaPostService(pool, { const now = Date.now(); if (action === "approve") { const hotScore = await resolveHotScore(now); - await pool.query( + await pool2.query( `UPDATE plaza_posts SET status = 'published', hot_score = ?, hot_updated_at = ?, updated_at = ? WHERE id = ?`, @@ -14140,14 +21002,14 @@ function createPlazaPostService(pool, { } if (action === "reject") { if (!reason) throw plazaError("\u62D2\u7EDD\u65F6\u5FC5\u987B\u586B\u5199\u539F\u56E0", "invalid_input"); - await pool.query( + await pool2.query( `UPDATE plaza_posts SET status = 'rejected', updated_at = ? WHERE id = ?`, [now, postId] ); return { id: postId, status: "rejected", reason }; } if (action === "hide") { - await pool.query( + await pool2.query( `UPDATE plaza_posts SET status = 'hidden', updated_at = ? WHERE id = ?`, [now, postId] ); @@ -14156,7 +21018,7 @@ function createPlazaPostService(pool, { throw plazaError("\u4E0D\u652F\u6301\u7684\u5BA1\u6838\u64CD\u4F5C", "invalid_input"); }; const listPendingPosts = async (limit = 50) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id, title, status, user_display_name, user_slug, published_at FROM plaza_posts WHERE status = 'pending_review' @@ -14212,7 +21074,7 @@ function mapPlazaError(error) { } // plaza-events.mjs -import crypto14 from "node:crypto"; +import crypto15 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([ @@ -14301,7 +21163,7 @@ function eventSignal(event) { } return weight; } -function createPlazaEventService(pool, { idFactory = () => crypto14.randomUUID() } = {}) { +function createPlazaEventService(pool2, { idFactory = () => crypto15.randomUUID() } = {}) { const profileCache = /* @__PURE__ */ new Map(); const cacheKey = ({ userId, sessionId }) => userId ? `u:${userId}` : sessionId ? `s:${sessionId}` : "anon"; const invalidateProfileCache = ({ userId, sessionId }) => { @@ -14350,7 +21212,7 @@ function createPlazaEventService(pool, { idFactory = () => crypto14.randomUUID() event.position, event.createdAt ]); - await pool.query( + await pool2.query( `INSERT INTO plaza_user_events (id, user_id, session_id, post_id, event_type, dwell_ms, feed_sort, feed_category, position, created_at) VALUES ?`, @@ -14371,7 +21233,7 @@ function createPlazaEventService(pool, { idFactory = () => crypto14.randomUUID() identityClause = "AND e.session_id = ?"; params.push(sessionId); } - const [eventRows] = await pool.query( + const [eventRows] = await pool2.query( `SELECT e.event_type, e.dwell_ms, e.post_id, e.created_at, e.feed_category, c.slug AS category_slug, pp.tags, pp.user_id AS author_id FROM plaza_user_events e @@ -14408,7 +21270,7 @@ function createPlazaEventService(pool, { idFactory = () => crypto14.randomUUID() if (row.event_type === "collect") profile.likedPostIds.add(row.post_id); } if (userId) { - const [reactions] = await pool.query( + const [reactions] = await pool2.query( `SELECT r.type, r.post_id, c.slug AS category_slug, pp.tags, pp.user_id AS author_id, r.created_at FROM plaza_reactions r JOIN plaza_posts pp ON pp.id = r.post_id AND pp.status = 'published' @@ -14427,7 +21289,7 @@ function createPlazaEventService(pool, { idFactory = () => crypto14.randomUUID() profile.likedPostIds.add(row.post_id); profile.seenPostIds.add(row.post_id); } - const [follows] = await pool.query( + const [follows] = await pool2.query( `SELECT followee_id FROM plaza_follows WHERE follower_id = ?`, [userId] ); @@ -14435,7 +21297,7 @@ function createPlazaEventService(pool, { idFactory = () => crypto14.randomUUID() profile.followedAuthorIds.add(row.followee_id); bumpWeight(profile.authorWeights, row.followee_id, 4); } - const [comments] = await pool.query( + const [comments] = await pool2.query( `SELECT c.post_id, cat.slug AS category_slug, pp.tags, pp.user_id AS author_id, c.created_at FROM plaza_comments c JOIN plaza_posts pp ON pp.id = c.post_id AND pp.status = 'published' @@ -14677,7 +21539,7 @@ function buildTagMatchClause(tags, params) { }); return { clause: ` AND (${parts.join(" OR ")})`, params }; } -function createPlazaRecommendService(pool, { +function createPlazaRecommendService(pool2, { eventService, formatPostRow: formatPostRow2, loadViewerReactions = null, @@ -14712,7 +21574,7 @@ function createPlazaRecommendService(pool, { params.push(categorySlug); } params.push(limit); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id @@ -14797,7 +21659,7 @@ function createPlazaRecommendService(pool, { params.push(categorySlug); } params.push(RECALL_LIMIT); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id @@ -14819,7 +21681,7 @@ function createPlazaRecommendService(pool, { params.push(categorySlug); } params.push(RECALL_LIMIT); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id @@ -14845,7 +21707,7 @@ function createPlazaRecommendService(pool, { params.push(categorySlug); } params.push(RECALL_LIMIT); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url, COUNT(*) AS overlap_score @@ -14877,7 +21739,7 @@ function createPlazaRecommendService(pool, { params.push(...categories); } params.push(RECALL_LIMIT); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id @@ -14901,7 +21763,7 @@ function createPlazaRecommendService(pool, { params.push(...dominant); } params.push(Math.max(12, Math.floor(RECALL_LIMIT * config.explore_ratio))); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id @@ -14925,7 +21787,7 @@ function createPlazaRecommendService(pool, { const tagMatch = buildTagMatchClause(tags, params); filter += tagMatch.clause; params.push(RECALL_LIMIT); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id @@ -14940,7 +21802,7 @@ function createPlazaRecommendService(pool, { const recallContentSimilar = async (profile, categorySlug) => { const seeds = [...profile.likedPostIds].slice(0, 8); if (seeds.length === 0) return []; - const [seedRows] = await pool.query( + const [seedRows] = await pool2.query( `SELECT tags FROM plaza_posts WHERE id IN (${seeds.map(() => "?").join(",")}) AND status = 'published'`, seeds ); @@ -14963,7 +21825,7 @@ function createPlazaRecommendService(pool, { const tagMatch = buildTagMatchClause(tags, params); filter += tagMatch.clause; params.push(RECALL_LIMIT); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon, pr.public_url FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id @@ -15121,7 +21983,7 @@ function createPlazaRecommendService(pool, { } // plaza-interactions.mjs -import crypto15 from "node:crypto"; +import crypto16 from "node:crypto"; var REACTION_TYPES = /* @__PURE__ */ new Set(["like", "collect", "share"]); var COUNTER_BY_TYPE = { like: "like_count", @@ -15149,8 +22011,8 @@ function formatCommentAuthor(row) { avatar_url: "" }; } -function createPlazaInteractionService(pool, { idFactory = () => crypto15.randomUUID(), formatPostRow: formatPostRow2, plazaRedis: plazaRedis2 = null } = {}) { - const requirePublishedPost = async (postId, conn = pool) => { +function createPlazaInteractionService(pool2, { idFactory = () => crypto16.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`, [postId] @@ -15162,7 +22024,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random const map = /* @__PURE__ */ new Map(); if (!viewerId || postIds.length === 0) return map; const placeholders = postIds.map(() => "?").join(", "); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT post_id, type FROM plaza_reactions WHERE user_id = ? AND post_id IN (${placeholders})`, [viewerId, ...postIds] @@ -15196,7 +22058,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random const type = normalizeReactionType(typeInput); const field = COUNTER_BY_TYPE[type]; await requirePublishedPost(postId); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [existing] = await conn.query( @@ -15222,7 +22084,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random const removeReaction = async (userId, postId, typeInput) => { const type = normalizeReactionType(typeInput); const field = COUNTER_BY_TYPE[type]; - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [result] = await conn.query( @@ -15256,7 +22118,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random params.push(cursor); } params.push(pageLimit + 1); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT c.*, u.slug AS author_slug, u.username, u.display_name AS author_display_name FROM plaza_comments c JOIN h5_users u ON u.id = c.user_id @@ -15270,7 +22132,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random let likedSet = /* @__PURE__ */ new Set(); if (viewerId && pageRows.length > 0) { const ids = pageRows.map((row) => row.id); - const [likedRows] = await pool.query( + const [likedRows] = await pool2.query( `SELECT comment_id FROM plaza_comment_reactions WHERE user_id = ? AND comment_id IN (${ids.map(() => "?").join(", ")})`, [viewerId, ...ids] @@ -15300,7 +22162,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random if (text.length > 500) throw plazaError3("\u8BC4\u8BBA\u8D85\u51FA 500 \u5B57\u7B26", "COMMENT_TOO_LONG"); const post = await requirePublishedPost(postId); if (!post.allow_comment) throw plazaError3("\u8BE5\u5E16\u5B50\u5DF2\u5173\u95ED\u8BC4\u8BBA", "COMMENT_DISABLED"); - const [users] = await pool.query( + const [users] = await pool2.query( `SELECT plaza_comment_banned FROM h5_users WHERE id = ? LIMIT 1`, [userId] ); @@ -15314,7 +22176,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random } } else { const hourAgo = Date.now() - 36e5; - const [recent] = await pool.query( + const [recent] = await pool2.query( `SELECT COUNT(*) AS count FROM plaza_comments WHERE user_id = ? AND post_id = ? AND created_at >= ? AND status = 'visible'`, [userId, postId, hourAgo] @@ -15325,7 +22187,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random } let parentComment = null; if (parentId) { - const [parents] = await pool.query( + const [parents] = await pool2.query( `SELECT * FROM plaza_comments WHERE id = ? AND post_id = ? AND status = 'visible' LIMIT 1`, [parentId, postId] ); @@ -15337,7 +22199,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random } const now = Date.now(); const commentId = idFactory(); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); await conn.query( @@ -15368,13 +22230,13 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random return { id: commentId }; }; const deleteComment = async (userId, commentId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM plaza_comments WHERE id = ? AND user_id = ? LIMIT 1`, [commentId, userId] ); if (!rows[0]) throw plazaError3("\u8BC4\u8BBA\u4E0D\u5B58\u5728", "COMMENT_NOT_FOUND"); const now = Date.now(); - await pool.query( + await pool2.query( `UPDATE plaza_comments SET content = '', status = 'deleted', deleted_by = 'user', updated_at = ? WHERE id = ?`, @@ -15383,7 +22245,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random return { id: commentId, status: "deleted" }; }; const toggleCommentLike = async (userId, commentId, liked) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT c.* FROM plaza_comments c JOIN plaza_posts p ON p.id = c.post_id WHERE c.id = ? AND c.status = 'visible' AND p.status = 'published' @@ -15391,7 +22253,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random [commentId] ); if (!rows[0]) throw plazaError3("\u8BC4\u8BBA\u4E0D\u5B58\u5728", "COMMENT_NOT_FOUND"); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); if (liked) { @@ -15431,7 +22293,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random return { id: commentId, liked }; }; const resolveUserBySlug = async (slug) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id, slug, username, display_name, plaza_post_count, plaza_follower_count, plaza_following_count FROM h5_users WHERE slug = ? OR username = ? LIMIT 1`, @@ -15445,7 +22307,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random const followee = await resolveUserBySlug(followeeSlug); if (followee.id === followerId) throw plazaError3("\u4E0D\u80FD\u5173\u6CE8\u81EA\u5DF1", "SELF_FOLLOW"); const now = Date.now(); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [result] = await conn.query( @@ -15474,7 +22336,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random const unfollowUser = async (followerId, followeeSlug) => { const followee = await resolveUserBySlug(followeeSlug); const now = Date.now(); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [result] = await conn.query( @@ -15502,12 +22364,12 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random }; const getUserProfile = async (slug, viewerId = null) => { const user = await resolveUserBySlug(slug); - const [likeRows] = await pool.query( + const [likeRows] = await pool2.query( `SELECT COALESCE(SUM(like_count), 0) AS total_likes FROM plaza_posts WHERE user_id = ? AND status = 'published'`, [user.id] ); - const [recentRows] = await pool.query( + const [recentRows] = await pool2.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id @@ -15518,7 +22380,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random ); let viewerFollowing = false; if (viewerId && viewerId !== user.id) { - const [followRows] = await pool.query( + const [followRows] = await pool2.query( `SELECT 1 FROM plaza_follows WHERE follower_id = ? AND followee_id = ? LIMIT 1`, [viewerId, user.id] ); @@ -15555,7 +22417,7 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random params.push(cursor, cursor, cursor); } params.push(pageLimit + 1); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon FROM plaza_posts pp JOIN plaza_categories c ON c.id = pp.category_id @@ -15595,11 +22457,11 @@ function createPlazaInteractionService(pool, { idFactory = () => crypto15.random } // plaza-redis.mjs -import crypto16 from "node:crypto"; +import crypto17 from "node:crypto"; var SYNC_SET = "plaza:sync:post_ids"; var FEED_GEN_KEY = "plaza:feed:gen"; function hashIp(ip) { - return crypto16.createHash("sha256").update(String(ip ?? "unknown")).digest("hex").slice(0, 16); + return crypto17.createHash("sha256").update(String(ip ?? "unknown")).digest("hex").slice(0, 16); } function counterKey(postId, field) { return `plaza:post:${postId}:${field}`; @@ -15612,7 +22474,7 @@ function feedCacheKey(gen, sort, categorySlug, cursor) { const page = cursor || "root"; return `plaza:feed:${sort}:${category}:cursor:${page}:g${gen}`; } -function createNoopPlazaRedis(pool = null) { +function createNoopPlazaRedis(pool2 = null) { const viewDedup = /* @__PURE__ */ new Map(); return { enabled: false, @@ -15621,14 +22483,14 @@ function createNoopPlazaRedis(pool = null) { async disconnect() { }, async recordView(postId, ip) { - if (!pool) return { counted: false }; + if (!pool2) return { counted: false }; const ipHash = hashIp(ip); const dedupKey = `${ipHash}:${postId}`; if (viewDedup.has(dedupKey)) return { counted: false }; viewDedup.set(dedupKey, Date.now()); setTimeout(() => viewDedup.delete(dedupKey), 864e5).unref?.(); const now = Date.now(); - const [result] = await pool.query( + const [result] = await pool2.query( `UPDATE plaza_posts SET view_count = view_count + 1, updated_at = ? WHERE id = ? AND status = 'published'`, [now, postId] @@ -15652,8 +22514,8 @@ function createNoopPlazaRedis(pool = null) { } }; } -async function createPlazaRedis(redisUrl, pool) { - if (!redisUrl) return createNoopPlazaRedis(pool); +async function createPlazaRedis(redisUrl, pool2) { + if (!redisUrl) return createNoopPlazaRedis(pool2); const { createClient } = await import("redis"); const client = createClient({ url: redisUrl }); client.on("error", (error) => { @@ -15706,7 +22568,7 @@ async function createPlazaRedis(redisUrl, pool) { await client.sRem(SYNC_SET, postId); continue; } - await pool.query( + await pool2.query( `UPDATE plaza_posts SET view_count = view_count + ?, updated_at = ? WHERE id = ?`, [delta, now, postId] ); @@ -15736,7 +22598,7 @@ async function createPlazaRedis(redisUrl, pool) { // plaza-tasks.mjs function startPlazaTasks({ - pool, + pool: pool2, plazaRedis: plazaRedis2, recalculateHotScores: recalculateHotScores2, writebackPublications: writebackPublications2 @@ -15756,14 +22618,14 @@ function startPlazaTasks({ timers.push(timer); }; schedule(async () => { - const result = await recalculateHotScores2(pool); + const result = await recalculateHotScores2(pool2); if (result.updated > 0) await plazaRedis2.invalidateFeedCaches(); }, 10 * 60 * 1e3, "hot_score"); schedule(async () => { await plazaRedis2.syncCountersToMySQL(); }, 5 * 60 * 1e3, "redis_sync"); schedule(async () => { - await writebackPublications2(pool); + await writebackPublications2(pool2); }, 60 * 60 * 1e3, "publication_writeback"); return { stop() { @@ -15771,9 +22633,9 @@ function startPlazaTasks({ } }; } -async function writebackPublications(pool) { +async function writebackPublications(pool2) { const now = Date.now(); - const [result] = await pool.query( + const [result] = await pool2.query( `UPDATE h5_publish_records p JOIN plaza_posts pp ON pp.publication_id = p.id SET p.plaza_view_count = pp.view_count, @@ -15784,9 +22646,9 @@ async function writebackPublications(pool) { } // plaza-seo.mjs -import crypto17 from "node:crypto"; +import crypto18 from "node:crypto"; function hashIp2(ip) { - return crypto17.createHash("sha256").update(String(ip ?? "unknown")).digest("hex").slice(0, 32); + return crypto18.createHash("sha256").update(String(ip ?? "unknown")).digest("hex").slice(0, 32); } function normalizeUtm(value, maxLen = 100) { return String(value ?? "").trim().slice(0, maxLen); @@ -15800,17 +22662,17 @@ function buildPostPublicUrl(postId, siteBase = process.env.PLAZA_PUBLIC_BASE ?? const base = String(siteBase).replace(/\/$/, ""); return `${base}/plaza/p/${postId}`; } -function createPlazaSeoService(pool, { - idFactory = () => crypto17.randomUUID(), +function createPlazaSeoService(pool2, { + idFactory = () => crypto18.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" } = {}) { const listSitemapData = async ({ postLimit = 1e3, userLimit = 500 } = {}) => { - const [categoryRows] = await pool.query( + const [categoryRows] = await pool2.query( `SELECT slug, name FROM plaza_categories WHERE is_active = 1 ORDER BY sort_order ASC` ); - const [postRows] = await pool.query( + const [postRows] = await pool2.query( `SELECT id, updated_at, published_at FROM plaza_posts WHERE status = 'published' @@ -15818,7 +22680,7 @@ function createPlazaSeoService(pool, { LIMIT ?`, [Math.min(Math.max(Number(postLimit) || 1e3, 1), 5e3)] ); - const [userRows] = await pool.query( + const [userRows] = await pool2.query( `SELECT u.slug, u.username, MAX(pp.published_at) AS last_post_at FROM h5_users u JOIN plaza_posts pp ON pp.user_id = u.id AND pp.status = 'published' @@ -15856,7 +22718,7 @@ function createPlazaSeoService(pool, { if (!source) throw Object.assign(new Error("utm_source \u4E0D\u80FD\u4E3A\u7A7A"), { code: "invalid_input" }); const id = idFactory(); const now = Date.now(); - await pool.query( + await pool2.query( `INSERT INTO plaza_attribution_events (id, event_type, utm_source, utm_medium, utm_campaign, ref_id, user_id, ip_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, @@ -15909,7 +22771,7 @@ function createPlazaSeoService(pool, { } // plaza-ops.mjs -import crypto18 from "node:crypto"; +import crypto19 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 }; @@ -15924,18 +22786,18 @@ function clampLimit3(value, fallback = 20, max = 100) { function hasOpsRole(role, minimum) { return (OPS_ROLE_RANK[role] ?? 0) >= (OPS_ROLE_RANK[minimum] ?? 0); } -function createPlazaOpsService(pool, { - idFactory = () => crypto18.randomUUID(), +function createPlazaOpsService(pool2, { + idFactory = () => crypto19.randomUUID(), formatPostRow: formatPostRow2, reviewPost, invalidateFeedCaches = null } = {}) { const loadOperatorRole = async (userId) => { - const [rows] = await pool.query(`SELECT ops_role FROM h5_users WHERE id = ? LIMIT 1`, [userId]); + const [rows] = await pool2.query(`SELECT ops_role FROM h5_users WHERE id = ? LIMIT 1`, [userId]); return rows[0]?.ops_role ?? "none"; }; const writeAuditLog = async (operatorId, action, targetType, targetId, detail) => { - await pool.query( + await pool2.query( `INSERT INTO ops_audit_log (id, operator_id, action, target_type, target_id, detail, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, [idFactory(), operatorId, action, targetType, targetId, JSON.stringify(detail ?? {}), Date.now()] @@ -15961,7 +22823,7 @@ function createPlazaOpsService(pool, { params.push(pattern, pattern, pattern); } params.push(pageLimit + 1); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT pp.id, pp.title, pp.summary, pp.cover_url, pp.status, pp.user_display_name, pp.user_slug, pp.published_at, pp.created_at, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon FROM plaza_posts pp @@ -15992,7 +22854,7 @@ function createPlazaOpsService(pool, { }; }; const reviewPostAsOps = async (operatorId, postId, action, { reason = null } = {}) => { - const [beforeRows] = await pool.query(`SELECT status FROM plaza_posts WHERE id = ? LIMIT 1`, [postId]); + const [beforeRows] = await pool2.query(`SELECT status FROM plaza_posts WHERE id = ? LIMIT 1`, [postId]); const before = beforeRows[0]; if (!before) throw opsError("\u5E16\u5B50\u4E0D\u5B58\u5728", "POST_NOT_FOUND"); const result = await reviewPost(postId, action, { reason }); @@ -16021,13 +22883,13 @@ function createPlazaOpsService(pool, { const reasonValue = String(reason ?? "").trim(); if (!REPORT_REASONS.has(reasonValue)) throw opsError("\u4E0D\u652F\u6301\u7684\u4E3E\u62A5\u539F\u56E0", "invalid_input"); if (type === "post") { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id FROM plaza_posts WHERE id = ? AND status = 'published' LIMIT 1`, [targetId] ); if (!rows[0]) throw opsError("\u5E16\u5B50\u4E0D\u5B58\u5728", "POST_NOT_FOUND"); } else { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT id FROM plaza_comments WHERE id = ? AND status = 'visible' LIMIT 1`, [targetId] ); @@ -16035,7 +22897,7 @@ function createPlazaOpsService(pool, { } const id = idFactory(); const now = Date.now(); - await pool.query( + await pool2.query( `INSERT INTO plaza_reports (id, target_type, target_id, reporter_id, reason, detail, status, created_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`, @@ -16045,7 +22907,7 @@ function createPlazaOpsService(pool, { }; const listReports = async ({ status = "pending", limit = 50 } = {}) => { const pageLimit = clampLimit3(limit, 50, 100); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT r.*, (SELECT COUNT(*) FROM plaza_reports r2 WHERE r2.target_type = r.target_type AND r2.target_id = r.target_id AND r2.status = 'pending') AS target_report_count @@ -16069,19 +22931,19 @@ function createPlazaOpsService(pool, { }; }; const processReport = async (operatorId, reportId, { action, action_taken: actionTaken = "" } = {}) => { - const [rows] = await pool.query(`SELECT * FROM plaza_reports WHERE id = ? LIMIT 1`, [reportId]); + const [rows] = await pool2.query(`SELECT * FROM plaza_reports WHERE id = ? LIMIT 1`, [reportId]); const report = rows[0]; if (!report) throw opsError("\u4E3E\u62A5\u4E0D\u5B58\u5728", "report_not_found"); const now = Date.now(); const status = action === "dismiss" ? "dismissed" : "processed"; - await pool.query( + await pool2.query( `UPDATE plaza_reports SET status = ?, processed_by = ?, processed_at = ?, action_taken = ? WHERE id = ?`, [status, operatorId, now, String(actionTaken).slice(0, 200), reportId] ); if (action === "hide_post" && report.target_type === "post") { - await pool.query( + await pool2.query( `UPDATE plaza_posts SET status = 'hidden', updated_at = ? WHERE id = ?`, [now, report.target_id] ); @@ -16097,7 +22959,7 @@ function createPlazaOpsService(pool, { const setFeatured = async (operatorId, { post_id: postId, position, sort_order: sortOrder = 0, expires_at: expiresAt = null }) => { const pos = String(position ?? "").trim(); if (!FEATURED_POSITIONS.has(pos)) throw opsError("\u4E0D\u652F\u6301\u7684\u7CBE\u9009\u4F4D", "invalid_input"); - const [postRows] = await pool.query( + const [postRows] = await pool2.query( `SELECT id FROM plaza_posts WHERE id = ? AND status = 'published' LIMIT 1`, [postId] ); @@ -16105,7 +22967,7 @@ function createPlazaOpsService(pool, { const now = Date.now(); const id = idFactory(); const expiresMs = expiresAt ? new Date(expiresAt).getTime() : null; - await pool.query( + await pool2.query( `INSERT INTO plaza_featured (id, post_id, position, sort_order, starts_at, expires_at, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [id, postId, pos, Number(sortOrder) || 0, now, expiresMs, operatorId, now] @@ -16116,7 +22978,7 @@ function createPlazaOpsService(pool, { }; const listFeatured = async () => { const now = Date.now(); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT f.*, pp.title, pp.cover_url, pp.user_display_name FROM plaza_featured f JOIN plaza_posts pp ON pp.id = f.post_id @@ -16138,7 +23000,7 @@ function createPlazaOpsService(pool, { }; }; const removeFeatured = async (operatorId, featuredId) => { - const [result] = await pool.query(`DELETE FROM plaza_featured WHERE id = ?`, [featuredId]); + const [result] = await pool2.query(`DELETE FROM plaza_featured WHERE id = ?`, [featuredId]); if ((result.affectedRows ?? 0) === 0) throw opsError("\u7CBE\u9009\u4E0D\u5B58\u5728", "featured_not_found"); await invalidateFeedCaches?.(); await writeAuditLog(operatorId, "remove_featured", "featured", featuredId, {}); @@ -16146,7 +23008,7 @@ function createPlazaOpsService(pool, { }; const loadActiveFeaturedPosts = async (viewerId = null) => { const now = Date.now(); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT f.position, f.sort_order, pp.*, c.name AS category_name, c.slug AS category_slug, c.icon AS category_icon FROM plaza_featured f JOIN plaza_posts pp ON pp.id = f.post_id AND pp.status = 'published' @@ -16173,30 +23035,30 @@ function createPlazaOpsService(pool, { const dayMs = 864e5; const todayStart = now - now % dayMs; const yesterdayStart = todayStart - dayMs; - const [[todayPosts]] = await pool.query( + const [[todayPosts]] = await pool2.query( `SELECT COUNT(*) AS count FROM plaza_posts WHERE status = 'published' AND published_at >= ?`, [todayStart] ); - const [[yesterdayPosts]] = await pool.query( + const [[yesterdayPosts]] = await pool2.query( `SELECT COUNT(*) AS count FROM plaza_posts WHERE status = 'published' AND published_at >= ? AND published_at < ?`, [yesterdayStart, todayStart] ); - const [[pendingReview]] = await pool.query( + const [[pendingReview]] = await pool2.query( `SELECT COUNT(*) AS count FROM plaza_posts WHERE status = 'pending_review'` ); - const [[signupFromPlaza]] = await pool.query( + const [[signupFromPlaza]] = await pool2.query( `SELECT COUNT(*) AS count FROM plaza_attribution_events WHERE event_type = 'signup' AND utm_source = 'plaza' AND created_at >= ?`, [todayStart] ); - const [categoryRows] = await pool.query( + const [categoryRows] = await pool2.query( `SELECT c.name, COUNT(p.id) AS count FROM plaza_categories c LEFT JOIN plaza_posts p ON p.category_id = c.id AND p.status = 'published' GROUP BY c.id, c.name ORDER BY count DESC` ); - const [topCreators] = await pool.query( + const [topCreators] = await pool2.query( `SELECT user_slug AS slug, user_display_name AS display_name, COUNT(*) AS post_count, SUM(like_count) AS total_likes FROM plaza_posts @@ -16205,7 +23067,7 @@ function createPlazaOpsService(pool, { ORDER BY total_likes DESC LIMIT 10` ); - const [dailyPosts] = await pool.query( + const [dailyPosts] = await pool2.query( `SELECT DATE(FROM_UNIXTIME(published_at / 1000)) AS day, COUNT(*) AS count FROM plaza_posts WHERE status = 'published' AND published_at >= ? @@ -16248,7 +23110,7 @@ function createPlazaOpsService(pool, { params.push(pattern, pattern, pattern); } params.push(pageLimit); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT u.id, u.slug, u.username, u.display_name, u.plaza_post_count, u.plaza_follower_count, u.plaza_verified, u.plaza_post_banned, u.plaza_comment_banned, u.ops_role FROM h5_users u @@ -16288,7 +23150,7 @@ function createPlazaOpsService(pool, { if (updates.length === 0) throw opsError("\u6CA1\u6709\u53EF\u66F4\u65B0\u7684\u5B57\u6BB5", "invalid_input"); updates.push("updated_at = ?"); params.push(Date.now(), userId); - const [result] = await pool.query( + const [result] = await pool2.query( `UPDATE h5_users SET ${updates.join(", ")} WHERE id = ?`, params ); @@ -16316,10 +23178,10 @@ function createPlazaOpsService(pool, { } // word-filter.mjs -import crypto19 from "node:crypto"; -function createWordFilterService(pool) { +import crypto20 from "node:crypto"; +function createWordFilterService(pool2) { async function listBlockedWords() { - const [rows] = await pool.query( + const [rows] = await pool2.query( "SELECT id, word, replacement, note, status, created_at, updated_at FROM h5_blocked_words ORDER BY created_at DESC" ); return rows; @@ -16327,14 +23189,14 @@ function createWordFilterService(pool) { 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 = crypto19.randomUUID(); + const id = crypto20.randomUUID(); const now = Date.now(); try { - await pool.query( + await pool2.query( "INSERT INTO h5_blocked_words (id, word, replacement, note, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [id, trimmed, replacement ?? "***", note ?? "", "active", now, now] ); - const [rows] = await pool.query("SELECT * FROM h5_blocked_words WHERE id = ?", [id]); + const [rows] = await pool2.query("SELECT * FROM h5_blocked_words WHERE id = ?", [id]); return { ok: true, blockedWord: rows[0] }; } catch (err) { if (err?.code === "ER_DUP_ENTRY") return { ok: false, message: "\u8BE5\u8BCD\u8BED\u5DF2\u5B58\u5728" }; @@ -16365,21 +23227,21 @@ function createWordFilterService(pool) { if (!updates.length) return { ok: false, message: "\u6CA1\u6709\u53EF\u66F4\u65B0\u7684\u5B57\u6BB5" }; updates.push("updated_at = ?"); values.push(Date.now(), id); - const [result] = await pool.query( + const [result] = await pool2.query( `UPDATE h5_blocked_words SET ${updates.join(", ")} WHERE id = ?`, values ); if (!result.affectedRows) return { ok: false, message: "\u8BB0\u5F55\u4E0D\u5B58\u5728" }; - const [rows] = await pool.query("SELECT * FROM h5_blocked_words WHERE id = ?", [id]); + const [rows] = await pool2.query("SELECT * FROM h5_blocked_words WHERE id = ?", [id]); return { ok: true, blockedWord: rows[0] }; } async function deleteBlockedWord(id) { - const [result] = await pool.query("DELETE FROM h5_blocked_words WHERE id = ?", [id]); + const [result] = await pool2.query("DELETE FROM h5_blocked_words WHERE id = ?", [id]); if (!result.affectedRows) return { ok: false, message: "\u8BB0\u5F55\u4E0D\u5B58\u5728" }; return { ok: true }; } async function listAllForFrontend() { - const [rows] = await pool.query( + const [rows] = await pool2.query( "SELECT word, replacement FROM h5_blocked_words WHERE status = ? ORDER BY word", ["active"] ); @@ -16565,9 +23427,9 @@ function injectPlazaEmbedBootstrap(html) { } // mindspace-cleanup.mjs -import crypto20 from "node:crypto"; -import fs16 from "node:fs/promises"; -import path17 from "node:path"; +import crypto21 from "node:crypto"; +import fs17 from "node:fs/promises"; +import path21 from "node:path"; var WORKSPACE_TEMP_SKIP = /* @__PURE__ */ new Set([ ".tkmindhints", ".goosehints", @@ -16576,11 +23438,11 @@ var WORKSPACE_TEMP_SKIP = /* @__PURE__ */ new Set([ ".goose" ]); function candidateId(kind, key) { - return `${kind}:${crypto20.createHash("sha256").update(key).digest("hex").slice(0, 24)}`; + return `${kind}:${crypto21.createHash("sha256").update(key).digest("hex").slice(0, 24)}`; } async function fileSize(targetPath) { try { - const stat = await fs16.stat(targetPath); + const stat = await fs17.stat(targetPath); return stat.isFile() ? stat.size : 0; } catch { return 0; @@ -16589,12 +23451,12 @@ async function fileSize(targetPath) { async function walkFiles(rootDir, onFile) { let entries; try { - entries = await fs16.readdir(rootDir, { withFileTypes: true }); + entries = await fs17.readdir(rootDir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { - const fullPath = path17.join(rootDir, entry.name); + const fullPath = path21.join(rootDir, entry.name); if (entry.isDirectory()) { if (WORKSPACE_TEMP_SKIP.has(entry.name)) continue; await walkFiles(fullPath, onFile); @@ -16604,19 +23466,19 @@ async function walkFiles(rootDir, onFile) { await onFile(fullPath); } } -function createCleanupService(pool, options = {}) { - const storageRoot = path17.resolve(options.storageRoot ?? path17.join(process.cwd(), "data", "mindspace")); - const h5Root = path17.resolve(options.h5Root ?? process.cwd()); - const absoluteStoragePath = (storageKey) => { - const resolved = path17.resolve(storageRoot, storageKey); - if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path17.sep}`)) { +function createCleanupService(pool2, options = {}) { + const storageRoot = path21.resolve(options.storageRoot ?? path21.join(process.cwd(), "data", "mindspace")); + const h5Root = path21.resolve(options.h5Root ?? process.cwd()); + const absoluteStoragePath2 = (storageKey) => { + const resolved = path21.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path21.sep}`)) { throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); } return resolved; }; const listCandidates = async (userId, username) => { const candidates = []; - const [uploads] = await pool.query( + const [uploads] = await pool2.query( `SELECT id, filename, reserved_bytes, temporary_storage_key, status, expires_at, created_at FROM h5_upload_sessions WHERE user_id = ? AND status IN ('reserved', 'uploaded', 'expired', 'failed') @@ -16625,7 +23487,7 @@ function createCleanupService(pool, options = {}) { [userId] ); for (const upload of uploads) { - const storagePath = upload.temporary_storage_key ? absoluteStoragePath(upload.temporary_storage_key) : null; + const storagePath = upload.temporary_storage_key ? absoluteStoragePath2(upload.temporary_storage_key) : null; const exists = storagePath ? await fileSize(storagePath) : 0; if (!exists && upload.status === "expired") continue; candidates.push({ @@ -16639,9 +23501,9 @@ function createCleanupService(pool, options = {}) { refId: upload.id }); } - const tmpDir = path17.join(storageRoot, "tmp", userId); + const tmpDir = path21.join(storageRoot, "tmp", userId); await walkFiles(tmpDir, async (fullPath) => { - const key = path17.relative(storageRoot, fullPath).split(path17.sep).join("/"); + const key = path21.relative(storageRoot, fullPath).split(path21.sep).join("/"); const active = uploads.some( (upload) => upload.temporary_storage_key === key && upload.status === "reserved" ); @@ -16651,7 +23513,7 @@ function createCleanupService(pool, options = {}) { candidates.push({ id: candidateId("tmp", key), kind: "orphan_tmp", - label: path17.basename(fullPath), + label: path21.basename(fullPath), path: key, sizeBytes, createdAt: null, @@ -16659,9 +23521,9 @@ function createCleanupService(pool, options = {}) { refId: key }); }); - const workspaceTempDir = path17.join(h5Root, "temp", username); + const workspaceTempDir = path21.join(h5Root, "temp", username); await walkFiles(workspaceTempDir, async (fullPath) => { - const rel = path17.relative(workspaceTempDir, fullPath).split(path17.sep).join("/"); + const rel = path21.relative(workspaceTempDir, fullPath).split(path21.sep).join("/"); const sizeBytes = await fileSize(fullPath); if (!sizeBytes) return; candidates.push({ @@ -16675,7 +23537,7 @@ function createCleanupService(pool, options = {}) { refId: rel }); }); - const [staleVersions] = await pool.query( + const [staleVersions] = await pool2.query( `SELECT pv.id AS version_id, pv.page_id, pv.version_no, pv.content_asset_id, pv.bundle_asset_id, p.title, a.size_bytes, a.display_name, pv.created_at, COALESCE(bundle.size_bytes, 0) AS bundle_size_bytes @@ -16717,7 +23579,7 @@ function createCleanupService(pool, options = {}) { let removedCount = 0; for (const item of targets) { if (item.kind === "stale_upload") { - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( @@ -16741,9 +23603,9 @@ function createCleanupService(pool, options = {}) { ); await conn.commit(); if (upload.temporary_storage_key) { - const target = absoluteStoragePath(upload.temporary_storage_key); + const target = absoluteStoragePath2(upload.temporary_storage_key); const sizeBytes = await fileSize(target); - await fs16.rm(target, { force: true }); + await fs17.rm(target, { force: true }); freedBytes += sizeBytes; } removedCount += 1; @@ -16758,26 +23620,26 @@ function createCleanupService(pool, options = {}) { continue; } if (item.kind === "orphan_tmp") { - const target = absoluteStoragePath(item.refId); + const target = absoluteStoragePath2(item.refId); const sizeBytes = await fileSize(target); - await fs16.rm(target, { force: true }); + await fs17.rm(target, { force: true }); freedBytes += sizeBytes; removedCount += 1; continue; } if (item.kind === "workspace_temp") { - const target = path17.join(h5Root, "temp", username, item.refId); - const resolvedRoot = path17.resolve(path17.join(h5Root, "temp", username)); - const resolved = path17.resolve(target); - if (!resolved.startsWith(`${resolvedRoot}${path17.sep}`)) continue; + const target = path21.join(h5Root, "temp", username, item.refId); + const resolvedRoot = path21.resolve(path21.join(h5Root, "temp", username)); + const resolved = path21.resolve(target); + if (!resolved.startsWith(`${resolvedRoot}${path21.sep}`)) continue; const sizeBytes = await fileSize(resolved); - await fs16.rm(resolved, { force: true }); + await fs17.rm(resolved, { force: true }); freedBytes += sizeBytes; removedCount += 1; continue; } if (item.kind === "stale_page_version") { - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( @@ -16856,8 +23718,8 @@ function createCleanupService(pool, options = {}) { } // mindspace-agent-jobs.mjs -import crypto21 from "node:crypto"; -import path18 from "node:path"; +import crypto22 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"]); var ACTIVE_JOB_STATUSES = /* @__PURE__ */ new Set(["queued", "running"]); @@ -16917,7 +23779,7 @@ function normalizeJobInput(input) { }; } function hashJobToken(token) { - return crypto21.createHash("sha256").update(String(token)).digest("hex"); + return crypto22.createHash("sha256").update(String(token)).digest("hex"); } function jsonValue(value, fallback) { if (value == null) return fallback; @@ -16976,18 +23838,18 @@ 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 && crypto21.timingSafeEqual(expected, actual); + return expected.length === actual.length && crypto22.timingSafeEqual(expected, actual); } -function createAgentJobService(pool, options = {}) { - const idFactory = options.idFactory ?? (() => crypto21.randomUUID()); +function createAgentJobService(pool2, options = {}) { + const idFactory = options.idFactory ?? (() => crypto22.randomUUID()); const nowFactory = options.nowFactory ?? (() => Date.now()); const tokenTtlMs = Number(options.tokenTtlMs ?? 30 * 60 * 1e3); const maxOutputBytes = Number(options.maxOutputBytes ?? 2 * 1024 * 1024); const pageService = options.pageService; - const storageRoot = path18.resolve(options.storageRoot ?? path18.join(process.cwd(), "data", "mindspace")); - const absoluteStoragePath = (storageKey) => { - const resolved = path18.resolve(storageRoot, storageKey); - if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path18.sep}`)) { + const storageRoot = path22.resolve(options.storageRoot ?? path22.join(process.cwd(), "data", "mindspace")); + const absoluteStoragePath2 = (storageKey) => { + const resolved = path22.resolve(storageRoot, storageKey); + if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path22.sep}`)) { throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C"); } return resolved; @@ -17007,7 +23869,7 @@ function createAgentJobService(pool, options = {}) { const resolveReadableStoragePath = async (storageKey) => { let lastError = null; for (const candidate of resolveStoragePathCandidates(storageKey)) { - const absolutePath = absoluteStoragePath(candidate); + const absolutePath = absoluteStoragePath2(candidate); try { await fs.stat(absolutePath); return absolutePath; @@ -17022,7 +23884,7 @@ function createAgentJobService(pool, options = {}) { throw lastError ?? Object.assign(new Error("\u5B58\u50A8\u6587\u4EF6\u4E0D\u5B58\u5728"), { code: "storage_not_found" }); }; const fetchJobAssets = async (jobId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT ja.asset_id, ja.asset_version_id, ja.permission, a.display_name, a.mime_type, a.status, v.scan_status FROM h5_agent_job_assets ja @@ -17053,7 +23915,7 @@ function createAgentJobService(pool, options = {}) { params.push(userId); } sql += " LIMIT 1"; - const [rows] = await pool.query(sql, params); + const [rows] = await pool2.query(sql, params); return rows[0] ?? null; }; const createJob = async (userId, input) => { @@ -17062,7 +23924,7 @@ function createAgentJobService(pool, options = {}) { throw agentJobError("\u9875\u9762\u670D\u52A1\u672A\u521D\u59CB\u5316", "internal_error"); } if (normalized.idempotencyKey) { - const [existingRows] = await pool.query( + const [existingRows] = await pool2.query( `SELECT j.*, c.category_code AS output_category_code FROM h5_agent_jobs j JOIN h5_space_categories c ON c.id = j.output_category_id @@ -17075,7 +23937,7 @@ function createAgentJobService(pool, options = {}) { return jobResponse(existingRows[0], assets); } } - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const categoryParams = normalized.outputCategoryId ? [normalized.outputCategoryId, userId] : [userId]; @@ -17093,7 +23955,7 @@ function createAgentJobService(pool, options = {}) { ); const category = categoryRows[0]; if (!category) throw agentJobError("\u8F93\u51FA\u5206\u7C7B\u4E0D\u5B58\u5728", "category_not_found"); - if (!["draft", "private", "oa"].includes(category.category_code)) { + if (!["draft", "oa"].includes(category.category_code)) { throw agentJobError("\u8BE5\u5206\u7C7B\u4E0D\u5141\u8BB8\u5199\u5165 Agent \u8F93\u51FA", "invalid_agent_job_input"); } const placeholders = normalized.allowedAssetIds.map(() => "?").join(", "); @@ -17172,12 +24034,12 @@ function createAgentJobService(pool, options = {}) { const listJobs = async (userId, { limit = 20, offset = 0 } = {}) => { const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100); const safeOffset = Math.max(Number(offset) || 0, 0); - const [countRows] = await pool.query( + const [countRows] = await pool2.query( `SELECT COUNT(*) AS total FROM h5_agent_jobs WHERE user_id = ?`, [userId] ); const total = Number(countRows[0]?.total ?? 0); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT j.*, c.category_code AS output_category_code FROM h5_agent_jobs j JOIN h5_space_categories c ON c.id = j.output_category_id @@ -17198,7 +24060,7 @@ function createAgentJobService(pool, options = {}) { }; }; const cancelJob = async (userId, jobId) => { - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( @@ -17231,7 +24093,7 @@ function createAgentJobService(pool, options = {}) { } }; const retryJob = async (userId, jobId) => { - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( @@ -17267,8 +24129,90 @@ function createAgentJobService(pool, options = {}) { conn.release(); } }; + const finalizeClaim = async (conn, job) => { + const jobId = job.id; + const token = crypto22.randomBytes(24).toString("base64url"); + const now = nowFactory(); + await conn.query( + `UPDATE h5_agent_jobs + SET status = 'running', started_at = COALESCE(started_at, ?), heartbeat_at = ?, + updated_at = ?, expires_at = ?, progress_json = ?, job_token_hash = ? + WHERE id = ?`, + [ + now, + now, + now, + now + tokenTtlMs, + JSON.stringify({ stage: "preparing_files" }), + hashJobToken(token), + jobId + ] + ); + await conn.commit(); + const assets = await fetchJobAssets(jobId); + const permissionScope = jsonValue(job.permission_scope, {}); + const userContext = jsonValue(job.user_context_json, { + locale: "zh-CN", + timezone: "Asia/Shanghai" + }); + return { + jobId, + userId: job.user_id, + jobToken: token, + instruction: job.instruction, + userContext, + allowedAssets: assets.map((asset) => ({ + assetId: asset.assetId, + versionId: asset.assetVersionId, + permission: asset.permission, + displayName: asset.displayName, + mimeType: asset.mimeType, + downloadEndpoint: `/api/internal/agent/jobs/${jobId}/assets/${asset.assetId}` + })), + output: { + categoryId: job.output_category_id, + categoryCode: job.output_category_code, + allowedTypes: [job.output_type], + maxBytes: asNumber5(job.max_output_bytes) + }, + capabilities: permissionScope.capabilities ?? { + network: false, + shell: false, + createPage: true + }, + expiresAt: now + tokenTtlMs + }; + }; + const claimNextJob = async () => { + const conn = await pool2.getConnection(); + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT j.*, c.category_code AS output_category_code + FROM h5_agent_jobs j + JOIN h5_space_categories c ON c.id = j.output_category_id + WHERE j.status = 'queued' + AND (j.expires_at IS NULL OR j.expires_at >= ?) + ORDER BY j.queued_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED`, + [nowFactory()] + ); + const job = rows[0]; + if (!job) { + await conn.commit(); + return null; + } + return await finalizeClaim(conn, job); + } catch (error) { + await conn.rollback(); + throw error; + } finally { + conn.release(); + } + }; const claimJob = async (jobId) => { - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( @@ -17288,57 +24232,7 @@ function createAgentJobService(pool, options = {}) { if (job.expires_at != null && asNumber5(job.expires_at) < nowFactory()) { throw agentJobError("\u4EFB\u52A1\u5DF2\u8FC7\u671F", "agent_job_expired"); } - const token = crypto21.randomBytes(24).toString("base64url"); - const now = nowFactory(); - await conn.query( - `UPDATE h5_agent_jobs - SET status = 'running', started_at = COALESCE(started_at, ?), heartbeat_at = ?, - updated_at = ?, expires_at = ?, progress_json = ?, job_token_hash = ? - WHERE id = ?`, - [ - now, - now, - now, - now + tokenTtlMs, - JSON.stringify({ stage: "preparing_files" }), - hashJobToken(token), - jobId - ] - ); - await conn.commit(); - const assets = await fetchJobAssets(jobId); - const permissionScope = jsonValue(job.permission_scope, {}); - const userContext = jsonValue(job.user_context_json, { - locale: "zh-CN", - timezone: "Asia/Shanghai" - }); - return { - jobId, - userId: job.user_id, - jobToken: token, - instruction: job.instruction, - userContext, - allowedAssets: assets.map((asset) => ({ - assetId: asset.assetId, - versionId: asset.assetVersionId, - permission: asset.permission, - displayName: asset.displayName, - mimeType: asset.mimeType, - downloadEndpoint: `/api/internal/agent/jobs/${jobId}/assets/${asset.assetId}` - })), - output: { - categoryId: job.output_category_id, - categoryCode: job.output_category_code, - allowedTypes: [job.output_type], - maxBytes: asNumber5(job.max_output_bytes) - }, - capabilities: permissionScope.capabilities ?? { - network: false, - shell: false, - createPage: true - }, - expiresAt: now + tokenTtlMs - }; + return await finalizeClaim(conn, job); } catch (error) { await conn.rollback(); throw error; @@ -17347,7 +24241,7 @@ function createAgentJobService(pool, options = {}) { } }; const requireJobToken = async (jobId, token) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT j.*, c.category_code AS output_category_code FROM h5_agent_jobs j JOIN h5_space_categories c ON c.id = j.output_category_id @@ -17370,7 +24264,7 @@ function createAgentJobService(pool, options = {}) { }; const getAssetForJob = async (jobId, token, assetId) => { await requireJobToken(jobId, token); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT a.id, a.display_name, a.mime_type, a.status, v.id AS asset_version_id, v.storage_key, v.scan_status FROM h5_agent_job_assets ja @@ -17397,7 +24291,7 @@ function createAgentJobService(pool, options = {}) { const job = await requireJobToken(jobId, token); const now = nowFactory(); const payload = progressPayload(input, "running"); - await pool.query( + await pool2.query( `UPDATE h5_agent_jobs SET heartbeat_at = ?, updated_at = ?, expires_at = ?, progress_json = ? WHERE id = ?`, @@ -17411,7 +24305,7 @@ function createAgentJobService(pool, options = {}) { if (input?.status === "failed") { const errorCode = String(input.errorCode ?? "worker_crashed").trim() || "worker_crashed"; const errorMessage = String(input.errorMessage ?? "\u4EFB\u52A1\u6267\u884C\u5931\u8D25").trim() || "\u4EFB\u52A1\u6267\u884C\u5931\u8D25"; - await pool.query( + await pool2.query( `UPDATE h5_agent_jobs SET status = 'failed', error_code = ?, error_message = ?, completed_at = ?, updated_at = ?, heartbeat_at = NULL, job_token_hash = NULL, progress_json = ? @@ -17457,7 +24351,7 @@ function createAgentJobService(pool, options = {}) { assetIds: sourceAssetIds } ); - await pool.query( + await pool2.query( `UPDATE h5_agent_jobs SET status = 'completed', result_page_id = ?, completed_at = ?, updated_at = ?, heartbeat_at = NULL, job_token_hash = NULL, progress_json = ? @@ -17466,6 +24360,22 @@ function createAgentJobService(pool, options = {}) { ); return getJob(job.user_id, jobId); }; + const reapStaleJobs = async (maxStaleMs = 5 * 60 * 1e3) => { + const now = nowFactory(); + const cutoff = now - Math.max(1, maxStaleMs); + const [result] = await pool2.query( + `UPDATE h5_agent_jobs + SET status = 'failed', error_code = 'worker_crashed', + error_message = 'worker heartbeat timed out', + completed_at = ?, updated_at = ?, heartbeat_at = NULL, job_token_hash = NULL, + progress_json = ? + WHERE status = 'running' + AND heartbeat_at IS NOT NULL + AND heartbeat_at < ?`, + [now, now, JSON.stringify({ stage: "failed" }), cutoff] + ); + return result?.affectedRows ?? 0; + }; return { createJob, getJob, @@ -17473,6 +24383,8 @@ function createAgentJobService(pool, options = {}) { cancelJob, retryJob, claimJob, + claimNextJob, + reapStaleJobs, getAssetForJob, heartbeat, completeJob @@ -17480,8 +24392,8 @@ function createAgentJobService(pool, options = {}) { } // mindspace-agent-runner.mjs -import crypto22 from "node:crypto"; -import fs17 from "node:fs/promises"; +import crypto23 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"; import { jsonrepair as jsonrepair2 } from "jsonrepair"; @@ -17554,7 +24466,7 @@ function runnerError(message, code, details) { } function createUserMessage(text) { return { - id: crypto22.randomUUID(), + id: crypto23.randomUUID(), role: "user", created: Math.floor(Date.now() / 1e3), content: [{ type: "text", text }], @@ -17692,7 +24604,7 @@ async function readAssetContext(asset, maxBytes = DEFAULT_TEXT_BYTES) { note: "\u8BE5\u6587\u4EF6\u4E0D\u662F\u7EAF\u6587\u672C\uFF0CRunner \u5F53\u524D\u4E0D\u4F1A\u76F4\u63A5\u5185\u5D4C\u4E8C\u8FDB\u5236\u5185\u5BB9\u3002" }; } - const buffer = await fs17.readFile(asset.path); + const buffer = await fs18.readFile(asset.path); return { assetId: asset.assetId, displayName: asset.displayName, @@ -17804,6 +24716,7 @@ function createMindSpaceAgentRunner({ apiSecret, userAuth: userAuth2, agentJobService, + experienceService = null, executeSessionReply: executeSessionReply2 = defaultExecuteSessionReply, apiFetchImpl = null }) { @@ -17822,11 +24735,11 @@ function createMindSpaceAgentRunner({ dispatcher: isHttpsTarget3(apiTarget) ? insecureDispatcher5 : void 0 }); }); - const runJob = async (jobId) => { + const runJob = async (jobId, preClaimed = null) => { let claim = null; let sessionId = null; try { - claim = await agentJobService.claimJob(jobId); + claim = preClaimed ?? await agentJobService.claimJob(jobId); const gate = await userAuth2.canUseChat(claim.userId); if (!gate.ok) { throw runnerError(gate.message || "\u5F53\u524D\u7528\u6237\u65E0\u6CD5\u6267\u884C Agent \u4EFB\u52A1", "worker_unavailable"); @@ -17869,14 +24782,18 @@ function createMindSpaceAgentRunner({ const localAsset = await agentJobService.getAssetForJob(jobId, claim.jobToken, asset.assetId); assetContexts.push(await readAssetContext(localAsset)); } - const prompt = buildAgentJobPrompt(claim, assetContexts); - const requestId = crypto22.randomUUID(); + let prompt = buildAgentJobPrompt(claim, assetContexts); + const relevant = await retrieveExperience(claim.instruction); + if (relevant) prompt = `${relevant} + +${prompt}`; + const requestId = crypto23.randomUUID(); const reply = await executeSessionReply2(apiFetch2, sessionId, requestId, prompt); const parsed = normalizeStructuredResult(extractJsonObject2(reply.text)); if (reply.tokenState) { await userAuth2.billSessionUsage(claim.userId, sessionId, reply.tokenState, requestId); } - return agentJobService.completeJob(jobId, claim.jobToken, { + const completed = await agentJobService.completeJob(jobId, claim.jobToken, { title: parsed.title, summary: parsed.summary, content: parsed.content, @@ -17885,6 +24802,8 @@ function createMindSpaceAgentRunner({ templateId: parsed.templateId, sourceAssetIds: claim.allowedAssets.map((asset) => asset.assetId) }); + await recordExperience(claim, sessionId, parsed); + return completed; } catch (error) { if (claim?.jobToken) { await agentJobService.completeJob(jobId, claim.jobToken, { @@ -17897,36 +24816,217 @@ function createMindSpaceAgentRunner({ throw error; } }; + async function retrieveExperience(instruction) { + if (!experienceService || !instruction) return ""; + try { + const hits = await experienceService.search(instruction, { limit: 3 }); + if (!hits.length) return ""; + const lines = hits.map((h) => `- ${h.title}: ${h.body}`).join("\n"); + return `# \u76F8\u5173\u7ECF\u9A8C\uFF08\u4F9B\u53C2\u8003\uFF0C\u6765\u81EA\u5386\u53F2\u4EFB\u52A1\uFF09 +${lines}`; + } catch { + return ""; + } + } + async function recordExperience(claim, sessionId, parsed) { + if (!experienceService) return; + try { + const title = String(parsed?.title ?? claim.instruction ?? "").slice(0, 200); + const summary = String(parsed?.summary ?? "").trim(); + if (!title || !summary) return; + await experienceService.record({ + kind: "task_outcome", + title, + body: summary, + sourceSessionId: sessionId, + sourceUserId: claim.userId + }); + } catch { + } + } return { runJob }; } // mindspace-chat-save.mjs -import fs18 from "node:fs/promises"; -import path19 from "node:path"; +import fs19 from "node:fs/promises"; +import path23 from "node:path"; +var PRIVATE_ASSET_URL_PATTERN2 = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; +var PUBLIC_TEMP_IMAGE_DIR2 = ".tmp-images"; +function extensionForMime3(mimeType, filename = "") { + const ext = path23.extname(String(filename ?? "")).replace(/^\./, "").toLowerCase(); + if (ext && /^[a-z0-9]{1,8}$/.test(ext)) return ext === "jpeg" ? "jpg" : ext; + if (mimeType === "image/jpeg") return "jpg"; + if (mimeType === "image/png") return "png"; + if (mimeType === "image/webp") return "webp"; + if (mimeType === "image/gif") return "gif"; + return "bin"; +} +function publicTempImageFilename2(assetId, mimeType, fallbackFilename = "") { + return `${assetId}.${extensionForMime3(mimeType, fallbackFilename)}`; +} +function absoluteStoragePath(storageRoot, storageKey) { + const resolvedRoot = path23.resolve(storageRoot); + const resolved = path23.resolve(resolvedRoot, storageKey); + if (resolved !== resolvedRoot && !resolved.startsWith(`${resolvedRoot}${path23.sep}`)) { + throw Object.assign(new Error("\u8DEF\u5F84\u8D8A\u754C"), { code: "invalid_storage_path" }); + } + return resolved; +} +function workspaceTempImageRelativeUrl(htmlRelativePath, filename) { + const htmlDir = path23.posix.dirname(String(htmlRelativePath ?? "").replace(/^\/+/, "")); + const tmpDir = path23.posix.join(PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR2); + const relDir = htmlDir === "." || !htmlDir ? tmpDir : path23.posix.relative(htmlDir, tmpDir).replace(/\\/g, "/"); + return relDir ? `${relDir}/${filename}` : filename; +} +async function materializePrivateAssetsInWorkspaceHtml({ + pool: pool2, + storageRoot, + h5Root, + userId, + html, + htmlRelativePath, + writeBack = false +}) { + const matches = [...String(html).matchAll(PRIVATE_ASSET_URL_PATTERN2)]; + if (matches.length === 0) { + return { html, count: 0, changed: false }; + } + const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))]; + const [assets] = await pool2.query( + `SELECT a.id, a.mime_type, a.original_filename, v.storage_key + FROM h5_assets a + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%' + AND a.status <> 'deleted'`, + [userId, assetIds] + ); + const publishDir = resolvePublishDir(h5Root, { id: userId }); + const tmpDir = path23.join(publishDir, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR2); + const urlMap = /* @__PURE__ */ new Map(); + for (const asset of assets) { + const filename = publicTempImageFilename2(asset.id, asset.mime_type, asset.original_filename); + const target = path23.join(tmpDir, filename); + try { + const sourcePath = absoluteStoragePath(storageRoot, asset.storage_key); + await fs19.mkdir(tmpDir, { recursive: true }); + await fs19.copyFile(sourcePath, target); + urlMap.set(asset.id, workspaceTempImageRelativeUrl(htmlRelativePath, filename)); + } catch { + } + } + if (urlMap.size === 0) { + return { html, count: 0, changed: false }; + } + let count = 0; + const result = String(html).replace(PRIVATE_ASSET_URL_PATTERN2, (value, assetId) => { + const nextUrl = urlMap.get(assetId); + if (!nextUrl) return value; + count += 1; + return nextUrl; + }); + if (count === 0 || result === html) { + return { html, count: 0, changed: false }; + } + if (writeBack) { + const htmlPath = path23.join(publishDir, htmlRelativePath); + await fs19.writeFile(htmlPath, result, "utf8"); + } + return { html: result, count, changed: true }; +} +async function repairMissingHtmlAssetReferences({ pool: pool2, userId, sourceContent, html }) { + const collectIds = (text) => [ + ...new Set([...String(text).matchAll(PRIVATE_ASSET_URL_PATTERN2)].map((match) => match[1])) + ]; + const htmlIds = collectIds(html); + const messageIds = collectIds(sourceContent); + if (htmlIds.length === 0 || messageIds.length === 0) { + return { html, changed: false }; + } + const [rows] = await pool2.query( + `SELECT id FROM h5_assets + WHERE user_id = ? AND id IN (?) AND mime_type LIKE 'image/%' AND status <> 'deleted'`, + [userId, [.../* @__PURE__ */ new Set([...htmlIds, ...messageIds])]] + ); + const existing = new Set(rows.map((row) => row.id)); + const invalidHtmlIds = htmlIds.filter((id) => !existing.has(id)); + const validMessageIds = messageIds.filter((id) => existing.has(id)); + if (invalidHtmlIds.length === 0 || validMessageIds.length === 0) { + return { html, changed: false }; + } + const idMap = /* @__PURE__ */ new Map(); + for (let index = 0; index < invalidHtmlIds.length; index += 1) { + const replacement = validMessageIds[index]; + if (!replacement) break; + idMap.set(invalidHtmlIds[index], replacement); + } + if (idMap.size === 0) { + return { html, changed: false }; + } + let changed = false; + const nextHtml = String(html).replace(PRIVATE_ASSET_URL_PATTERN2, (value, assetId) => { + const replacement = idMap.get(assetId); + if (!replacement) return value; + changed = true; + return value.replace(assetId, replacement); + }); + return { html: nextHtml, changed }; +} +function createAssetDataUriResolver(pool2, storageRoot, userId) { + const cache = /* @__PURE__ */ new Map(); + return async (assetId) => { + const key = String(assetId ?? "").trim(); + if (!key) return null; + if (cache.has(key)) return cache.get(key); + const [rows] = await pool2.query( + `SELECT a.mime_type, v.storage_key + FROM h5_assets a + JOIN h5_asset_versions v ON v.id = a.current_version_id + WHERE a.user_id = ? AND a.id = ? AND a.mime_type LIKE 'image/%' + AND a.status <> 'deleted' + LIMIT 1`, + [userId, key] + ); + const asset = rows[0]; + if (!asset) { + cache.set(key, null); + return null; + } + try { + const buffer = await fs19.readFile(absoluteStoragePath(storageRoot, asset.storage_key)); + const mimeType = String(asset.mime_type || "image/jpeg"); + const dataUri = `data:${mimeType};base64,${buffer.toString("base64")}`; + cache.set(key, dataUri); + return dataUri; + } catch { + cache.set(key, null); + return null; + } + }; +} var URL_PATTERN = /https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi; -function decodePathSegment(segment) { +function decodePathSegment2(segment) { try { return decodeURIComponent(segment); } catch { return segment; } } -function encodeUrlPath(relativePath) { +function encodeUrlPath2(relativePath) { return String(relativePath ?? "").split("/").filter(Boolean).map((part) => encodeURIComponent(part)).join("/"); } -function normalizeStaticHtmlRelativePath(relativePath) { +function normalizeStaticHtmlRelativePath2(relativePath) { const parts = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== ".."); if (parts.length === 0) return ""; if (parts[0].toLowerCase() === "public") return ["public", ...parts.slice(1)].join("/"); if (parts.length === 1 && parts[0].toLowerCase().endsWith(".html")) return `public/${parts[0]}`; return parts.join("/"); } -function canonicalizeStaticPageUrl(publicUrl2, originalRelativePath, canonicalRelativePath) { +function canonicalizeStaticPageUrl2(publicUrl2, originalRelativePath, canonicalRelativePath) { if (!canonicalRelativePath || canonicalRelativePath === String(originalRelativePath ?? "").replace(/^\/+/, "")) { return publicUrl2; } - const suffix = encodeUrlPath(originalRelativePath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return String(publicUrl2).replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath)); + const suffix = encodeUrlPath2(originalRelativePath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return String(publicUrl2).replace(new RegExp(`${suffix}$`), encodeUrlPath2(canonicalRelativePath)); } function extractStaticPageLinks(content, { userId, username } = {}) { const text = String(content ?? ""); @@ -17935,9 +25035,9 @@ function extractStaticPageLinks(content, { userId, username } = {}) { const normalizedUserId = userId ? String(userId).trim().toLowerCase() : null; const normalizedUsername = username ? String(username).trim().toLowerCase() : null; for (const match of text.matchAll(URL_PATTERN)) { - const owner = decodePathSegment(match[1]).toLowerCase(); - const originalRelativePath = decodePathSegment(match[2]); - const relativePath = normalizeStaticHtmlRelativePath(originalRelativePath); + const owner = decodePathSegment2(match[1]).toLowerCase(); + const originalRelativePath = decodePathSegment2(match[2]); + const relativePath = normalizeStaticHtmlRelativePath2(originalRelativePath); if (normalizedUserId) { if (owner !== normalizedUserId && owner !== normalizedUsername) continue; } else if (normalizedUsername && owner !== normalizedUsername) { @@ -17947,10 +25047,10 @@ function extractStaticPageLinks(content, { userId, username } = {}) { if (seen.has(key)) continue; seen.add(key); links.push({ - publicUrl: canonicalizeStaticPageUrl(match[0], originalRelativePath, relativePath), + publicUrl: canonicalizeStaticPageUrl2(match[0], originalRelativePath, relativePath), owner, relativePath, - filename: path19.basename(relativePath) + filename: path23.basename(relativePath) }); } return links; @@ -17974,38 +25074,38 @@ function resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath) { if (!key || !clean || !clean.toLowerCase().endsWith(".html")) { throw Object.assign(new Error("\u65E0\u6548\u7684\u9875\u9762\u8DEF\u5F84"), { code: "invalid_page_path" }); } - const publishRoot = path19.resolve(h5Root, PUBLISH_ROOT_DIR, key); - const absolute = path19.resolve(publishRoot, clean); - if (absolute !== publishRoot && !absolute.startsWith(`${publishRoot}${path19.sep}`)) { + const publishRoot = path23.resolve(h5Root, PUBLISH_ROOT_DIR, key); + const absolute = path23.resolve(publishRoot, clean); + if (absolute !== publishRoot && !absolute.startsWith(`${publishRoot}${path23.sep}`)) { throw Object.assign(new Error("\u9875\u9762\u8DEF\u5F84\u8D8A\u754C"), { code: "invalid_page_path" }); } return absolute; } async function readPublishHtml(h5Root, userId, relativePath) { const absolute = resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath); - const content = await fs18.readFile(absolute, "utf8"); + const content = await fs19.readFile(absolute, "utf8"); if (!content.trim()) { throw Object.assign(new Error("\u9875\u9762\u5185\u5BB9\u4E3A\u7A7A"), { code: "empty_page_content" }); } - return { absolute, content, relativePath, filename: path19.basename(relativePath) }; + return { absolute, content, relativePath, filename: path23.basename(relativePath) }; } async function walkPublishHtmlByBasename(publishRoot, basename, maxDepth = 6, depth = 0) { if (depth > maxDepth || !basename.toLowerCase().endsWith(".html")) return null; let entries; try { - entries = await fs18.readdir(publishRoot, { withFileTypes: true }); + entries = await fs19.readdir(publishRoot, { withFileTypes: true }); } catch { return null; } for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules") continue; - const full = path19.join(publishRoot, entry.name); + const full = path23.join(publishRoot, entry.name); if (entry.isFile() && entry.name === basename) return full; } for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue; const found = await walkPublishHtmlByBasename( - path19.join(publishRoot, entry.name), + path23.join(publishRoot, entry.name), basename, maxDepth, depth + 1 @@ -18018,13 +25118,13 @@ async function collectPublishHtmlPaths(publishRoot, results, maxDepth = 6, depth if (depth > maxDepth || results.length >= 200) return; let entries; try { - entries = await fs18.readdir(publishRoot, { withFileTypes: true }); + entries = await fs19.readdir(publishRoot, { withFileTypes: true }); } catch { return; } for (const entry of entries) { if (entry.name.startsWith(".") || entry.name === "node_modules") continue; - const full = path19.join(publishRoot, entry.name); + const full = path23.join(publishRoot, entry.name); if (entry.isFile() && entry.name.toLowerCase().endsWith(".html")) { results.push(full); if (results.length >= 200) return; @@ -18032,7 +25132,7 @@ async function collectPublishHtmlPaths(publishRoot, results, maxDepth = 6, depth } for (const entry of entries) { if (!entry.isDirectory() || entry.name.startsWith(".") || entry.name === "node_modules") continue; - await collectPublishHtmlPaths(path19.join(publishRoot, entry.name), results, maxDepth, depth + 1); + await collectPublishHtmlPaths(path23.join(publishRoot, entry.name), results, maxDepth, depth + 1); if (results.length >= 200) return; } } @@ -18059,7 +25159,7 @@ function levenshteinDistance(left, right) { return prev[right.length]; } function htmlNameForSimilarity(relativePath) { - return path19.basename(String(relativePath ?? ""), ".html").toLowerCase(); + return path23.basename(String(relativePath ?? ""), ".html").toLowerCase(); } function isAcceptableSimilarHtmlMatch(requested, candidate, score, nextScore = Infinity) { if (!requested || !candidate || requested === candidate) return false; @@ -18069,13 +25169,13 @@ function isAcceptableSimilarHtmlMatch(requested, candidate, score, nextScore = I return nextScore > score; } async function resolveClosestHtmlRelativePath(rootDir, relativePath) { - const normalized = normalizeStaticHtmlRelativePath(relativePath); + const normalized = normalizeStaticHtmlRelativePath2(relativePath); if (!normalized.toLowerCase().endsWith(".html")) return null; const requestedName = htmlNameForSimilarity(normalized); const candidates = []; await collectPublishHtmlPaths(rootDir, candidates); const ranked = candidates.map((absolute) => { - const candidateRelative = path19.relative(rootDir, absolute).split(path19.sep).join("/"); + const candidateRelative = path23.relative(rootDir, absolute).split(path23.sep).join("/"); const candidateName = htmlNameForSimilarity(candidateRelative); return { relativePath: candidateRelative, @@ -18091,11 +25191,11 @@ async function resolveClosestHtmlRelativePath(rootDir, relativePath) { return best.relativePath; } async function findPublishHtml(h5Root, userId, relativePath) { - const normalized = normalizeStaticHtmlRelativePath(relativePath); - const basename = path19.basename(normalized); + const normalized = normalizeStaticHtmlRelativePath2(relativePath); + const basename = path23.basename(normalized); const candidates = [ normalized, - path19.posix.join("public", basename), + path23.posix.join("public", basename), basename ].filter((value, index, list) => value && list.indexOf(value) === index); for (const candidate of candidates) { @@ -18104,14 +25204,14 @@ async function findPublishHtml(h5Root, userId, relativePath) { } catch { } } - const publishRoot = path19.resolve( + const publishRoot = path23.resolve( h5Root, PUBLISH_ROOT_DIR, String(userId ?? "").trim().toLowerCase() ); const absolute = await walkPublishHtmlByBasename(publishRoot, basename); if (absolute) { - const resolvedRelativePath = path19.relative(publishRoot, absolute).split(path19.sep).join("/"); + const resolvedRelativePath = path23.relative(publishRoot, absolute).split(path23.sep).join("/"); return readPublishHtml(h5Root, userId, resolvedRelativePath); } const similarRelativePath = await resolveClosestHtmlRelativePath(publishRoot, normalized); @@ -18122,7 +25222,7 @@ async function findPublishHtml(h5Root, userId, relativePath) { } function buildWorkspaceBaseHref(userId, htmlRelativePath) { const key = String(userId ?? "").trim(); - const dir = path19.posix.dirname(String(htmlRelativePath ?? "").replace(/^\/+/, "")); + const dir = path23.posix.dirname(String(htmlRelativePath ?? "").replace(/^\/+/, "")); const segments = dir === "." ? [] : dir.split("/").filter(Boolean); const encoded = segments.map((part) => encodeURIComponent(part)).join("/"); return `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(key)}/${encoded ? `${encoded}/` : ""}`; @@ -18316,6 +25416,432 @@ async function resolveChatSaveAnalysis({ return { analysis, resolvedHtml: null }; } +// mindspace-public-finish-sync.mjs +import fs20 from "node:fs"; +import path24 from "node:path"; +var PUBLIC_HTML_PATH_PATTERN = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/i; +var PUBLIC_DOCX_HREF_PATTERN = /href=["']([^"'#?\s]+\.docx)["']/gi; +function normalizePublicDocxHref(href) { + const clean = String(href ?? "").split("?")[0].split("#")[0].replace(/^\.\//, "").trim(); + if (!clean || clean.includes("://") || clean.startsWith("data:")) return null; + if (clean.startsWith("../oa/")) return clean.slice(3); + if (clean.startsWith("oa/")) return clean; + if (!clean.includes("/")) return `public/${clean}`; + return clean; +} +function extractPublicDocxReferencesFromHtml(publishDir) { + const root = path24.resolve(String(publishDir ?? "")); + const publicDir = path24.join(root, "public"); + if (!fs20.existsSync(publicDir) || !fs20.statSync(publicDir).isDirectory()) return []; + const refs = /* @__PURE__ */ new Set(); + for (const file of fs20.readdirSync(publicDir)) { + if (!file.toLowerCase().endsWith(".html")) continue; + const content = fs20.readFileSync(path24.join(publicDir, file), "utf8"); + for (const match of content.matchAll(PUBLIC_DOCX_HREF_PATTERN)) { + const relativePath = normalizePublicDocxHref(match[1]); + if (relativePath) refs.add(relativePath); + } + } + return [...refs]; +} +function findLatestCompleteOaDocx(publishDir, { minSize = 8e3 } = {}) { + const oaDir = path24.join(path24.resolve(String(publishDir ?? "")), "oa"); + if (!fs20.existsSync(oaDir) || !fs20.statSync(oaDir).isDirectory()) return null; + const candidates = fs20.readdirSync(oaDir).filter((name) => name.toLowerCase().endsWith(".docx")).map((name) => { + const absolutePath = path24.join(oaDir, name); + const stat = fs20.statSync(absolutePath); + return { name, absolutePath, size: stat.size, mtimeMs: stat.mtimeMs }; + }).filter((item) => item.size >= minSize).sort((a, b) => b.mtimeMs - a.mtimeMs || b.size - a.size); + return candidates[0] ?? null; +} +function syncPublicDocxDownloads({ publishDir, minCompleteSize = 8e3 } = {}) { + const root = path24.resolve(String(publishDir ?? "")); + if (!root) return { synced: [], skipped: [] }; + const source = findLatestCompleteOaDocx(root, { minSize: minCompleteSize }); + if (!source) return { synced: [], skipped: [] }; + const synced = []; + const skipped = []; + for (const relativePath of extractPublicDocxReferencesFromHtml(root)) { + const destination = path24.resolve(root, relativePath); + if (destination !== root && !destination.startsWith(`${root}${path24.sep}`)) { + skipped.push(relativePath); + continue; + } + let shouldCopy = !fs20.existsSync(destination) || !fs20.statSync(destination).isFile(); + if (!shouldCopy) { + try { + shouldCopy = fs20.statSync(destination).size < source.size; + } catch { + shouldCopy = true; + } + } + if (!shouldCopy) { + skipped.push(relativePath); + continue; + } + try { + fs20.mkdirSync(path24.dirname(destination), { recursive: true }); + fs20.copyFileSync(source.absolutePath, destination); + synced.push(relativePath); + } catch { + skipped.push(relativePath); + } + } + return { synced, skipped, source: source.name }; +} +function messageText(message) { + const textParts = Array.isArray(message?.content) ? message.content.filter((item) => item?.type === "text" && typeof item.text === "string").map((item) => item.text.trim()).filter(Boolean) : []; + const displayText = typeof message?.metadata?.displayText === "string" ? message.metadata.displayText.trim() : ""; + return [...textParts, displayText].filter(Boolean).join("\n"); +} +function normalizePublicHtmlRelativePath(relativePath) { + const parts = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== ".."); + if (parts.length === 0) return ""; + if (parts[0].toLowerCase() === "public") return parts.join("/"); + if (parts.length === 1 && parts[0].toLowerCase().endsWith(".html")) return `public/${parts[0]}`; + return parts.join("/"); +} +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 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 = []) { + const artifacts = /* @__PURE__ */ new Map(); + for (const message of messages) { + 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; + 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; + if (content == null) continue; + artifacts.set(relativePath, { relativePath, content }); + } + } + return [...artifacts.values()]; +} +var PUBLIC_HTML_STUB_MARKERS = ["\u4E34\u65F6\u8865\u51FA", "\u670D\u52A1\u53F7\u515C\u5E95", "\u670D\u52A1\u53F7\u81EA\u52A8\u8865\u51FA\u7B80\u7248\u9875\u9762"]; +function isStubPublicHtmlContent(content) { + const value = String(content ?? ""); + return PUBLIC_HTML_STUB_MARKERS.some((marker) => value.includes(marker)); +} +function shouldReplaceExistingPublicHtml(destination, nextContent) { + if (!fs20.existsSync(destination) || !fs20.statSync(destination).isFile()) return true; + try { + const existing = fs20.readFileSync(destination, "utf8"); + if (isStubPublicHtmlContent(existing)) return true; + return typeof nextContent === "string" && nextContent.length > 0 && existing !== nextContent; + } catch { + return Boolean(nextContent); + } +} +function materializeMissingPublicHtmlWrites({ messages, publishDir }) { + const root = path24.resolve(String(publishDir ?? "")); + if (!root) return { materialized: [], skipped: [] }; + const materialized = []; + const skipped = []; + for (const artifact of extractPublicHtmlWriteArtifacts(messages)) { + const destination = path24.resolve(root, artifact.relativePath); + if (destination !== root && !destination.startsWith(`${root}${path24.sep}`)) { + skipped.push(artifact.relativePath); + continue; + } + if (fs20.existsSync(destination) && fs20.statSync(destination).isFile()) { + if (!shouldReplaceExistingPublicHtml(destination, artifact.content)) { + skipped.push(artifact.relativePath); + continue; + } + } + try { + fs20.mkdirSync(path24.dirname(destination), { recursive: true }); + fs20.writeFileSync(destination, artifact.content, "utf8"); + materialized.push(artifact.relativePath); + } catch { + skipped.push(artifact.relativePath); + } + } + return { materialized, skipped }; +} +function hasRecentOwnPublicHtmlReference(messages, currentUser, { recentCount = 80 } = {}) { + 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) { + return true; + } + const text = messageText(message); + if (!text) continue; + if (extractStaticPageLinks(text, { + userId: currentUser.id, + username: currentUser.username ?? null + }).length > 0) { + return true; + } + if (PUBLIC_HTML_PATH_PATTERN.test(text)) { + return true; + } + } + return false; +} +async function syncPublicHtmlAfterFinish({ + messages, + currentUser, + publishDir, + syncWorkspaceAssets +} = {}) { + if (!hasRecentOwnPublicHtmlReference(messages, currentUser)) { + return { materialized: [], skipped: [], synced: false }; + } + const { materialized, skipped } = materializeMissingPublicHtmlWrites({ messages, publishDir }); + const docxSync = syncPublicDocxDownloads({ publishDir }); + let synced = false; + if (typeof syncWorkspaceAssets === "function" && currentUser?.id) { + await syncWorkspaceAssets(currentUser.id, { categoryCode: "public" }); + synced = true; + } + return { materialized, skipped, synced, docxSync }; +} + +// mindspace-page-sync.mjs +import fs21 from "node:fs/promises"; +import path25 from "node:path"; +var PUBLIC_HTML_SKIP_DIRS = /* @__PURE__ */ new Set([ + ".tmp-images", + "assets", + "images", + "shared", + "node_modules" +]); +function normalizePublicHtmlPath(relativePath) { + const parts = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== ".."); + if (parts.length === 0) return ""; + if (parts[0].toLowerCase() === "public") return parts.join("/"); + if (parts.length === 1 && parts[0].toLowerCase().endsWith(".html")) return `public/${parts[0]}`; + return parts.join("/"); +} +function titleFromRelativePath(relativePath) { + const basename = path25.basename(String(relativePath ?? ""), ".html"); + return basename.replace(/[-_]+/g, " ").trim() || "\u751F\u6210\u7684\u9875\u9762"; +} +function extractHtmlTitle(content) { + const match = String(content).match(/]*>([^<]+)<\/title>/i); + return match?.[1]?.trim() ?? ""; +} +function extractHtmlSummary(content) { + const match = String(content).match(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i); + return match?.[1]?.trim().slice(0, 1e3) ?? ""; +} +async function loadIndexedWorkspacePages(pool2, userId) { + const [rows] = await pool2.query( + `SELECT p.id, p.updated_at, p.source_asset_id, pv.source_snapshot_json + 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'`, + [userId] + ); + const byPath = /* @__PURE__ */ new Map(); + for (const row of rows) { + let snapshot = {}; + try { + snapshot = JSON.parse(row.source_snapshot_json ?? "{}"); + } catch { + snapshot = {}; + } + const relativePath = normalizePublicHtmlPath(snapshot.relative_path); + if (relativePath) { + byPath.set(relativePath, { + id: row.id, + updatedAt: Number(row.updated_at ?? 0) + }); + } + if (row.source_asset_id && relativePath) { + byPath.set(`asset:${row.source_asset_id}`, { id: row.id, updatedAt: Number(row.updated_at ?? 0) }); + } + } + return byPath; +} +async function listPublishPublicHtmlFiles(publishDir) { + const publicDir = path25.join(publishDir, "public"); + const files = []; + async function walk(absDir, relWithinPublic) { + let entries; + try { + entries = await fs21.readdir(absDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name.startsWith(".") || PUBLIC_HTML_SKIP_DIRS.has(entry.name)) continue; + const absPath = path25.join(absDir, entry.name); + const relPath = relWithinPublic ? `${relWithinPublic}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await walk(absPath, relPath); + continue; + } + if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".html")) continue; + const stat = await fs21.stat(absPath); + files.push({ + relativePath: normalizePublicHtmlPath(`public/${relPath}`), + absolutePath: absPath, + mtimeMs: stat.mtimeMs + }); + } + } + await walk(publicDir, ""); + return files; +} +async function upsertWorkspaceHtmlPage({ + pageService, + userId, + indexedPages, + relativePath, + content, + assetId = null, + sourceMtimeMs = null +}) { + const title = extractHtmlTitle(content) || titleFromRelativePath(relativePath); + const summary = extractHtmlSummary(content); + const existing = indexedPages.get(relativePath) ?? (assetId ? indexedPages.get(`asset:${assetId}`) : null); + const pageInput = { + title, + summary, + content, + contentFormat: "html", + pageType: "html", + templateId: "editorial", + categoryCode: "draft" + }; + const snapshot = { + ...assetId ? { source_asset_id: assetId } : {}, + source_category: "public", + content_mode: "static_html", + relative_path: relativePath, + auto_synced: true + }; + if (existing) { + if (sourceMtimeMs != null && existing.updatedAt >= sourceMtimeMs) { + return "skipped"; + } + await pageService.updatePage(userId, existing.id, { + ...pageInput, + changeNote: "\u540C\u6B65\u5DE5\u4F5C\u533A\u9875\u9762\u66F4\u65B0" + }); + indexedPages.set(relativePath, { id: existing.id, updatedAt: Date.now() }); + return "updated"; + } + const page = await pageService.createFromChat(userId, pageInput, { + ...assetId ? { assetId } : {}, + snapshot, + createdAt: sourceMtimeMs != null && Number.isFinite(Number(sourceMtimeMs)) ? Math.floor(Number(sourceMtimeMs)) : void 0 + }); + indexedPages.set(relativePath, { id: page.id, updatedAt: Date.now() }); + if (assetId) indexedPages.set(`asset:${assetId}`, { id: page.id, updatedAt: Date.now() }); + return "created"; +} +async function syncGeneratedPagesFromPublicAssets({ + pool: pool2, + pageService, + assetService, + userId, + publishDir = null, + syncWorkspaceAssets = null +} = {}) { + if (!pool2 || !pageService || !userId) { + return { created: 0, skipped: 0, updated: 0 }; + } + if (typeof syncWorkspaceAssets === "function") { + await syncWorkspaceAssets(userId, { categoryCode: "public" }).catch(() => { + }); + } + const indexedPages = await loadIndexedWorkspacePages(pool2, userId); + let created = 0; + let updated = 0; + let skipped = 0; + if (publishDir) { + const htmlFiles = await listPublishPublicHtmlFiles(publishDir); + for (const file of htmlFiles) { + try { + const content = await fs21.readFile(file.absolutePath, "utf8"); + if (!content.trim()) { + skipped += 1; + continue; + } + const result = await upsertWorkspaceHtmlPage({ + pageService, + userId, + indexedPages, + relativePath: file.relativePath, + content, + sourceMtimeMs: file.mtimeMs + }); + if (result === "created") created += 1; + else if (result === "updated") updated += 1; + else skipped += 1; + } catch (error) { + console.warn( + `[MindSpace] page sync failed for ${file.relativePath}:`, + error?.message ?? error + ); + skipped += 1; + } + } + } + if (!assetService) { + return { created, updated, skipped }; + } + const [rows] = await pool2.query( + `SELECT a.id, a.display_name, a.original_filename, a.updated_at + FROM h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + WHERE a.user_id = ? + AND c.category_code = 'public' + AND a.mime_type = 'text/html' + AND a.status = 'ready' + ORDER BY a.updated_at DESC + LIMIT 200`, + [userId] + ); + for (const asset of rows) { + const relativePath = normalizePublicHtmlPath( + String(asset.original_filename ?? "").includes("/") ? asset.original_filename : `public/${asset.original_filename}` + ); + if (indexedPages.has(relativePath)) { + skipped += 1; + continue; + } + try { + const { path: assetPath } = await assetService.readAsset(userId, asset.id); + const content = await fs21.readFile(assetPath, "utf8"); + const result = await upsertWorkspaceHtmlPage({ + pageService, + userId, + indexedPages, + relativePath, + content, + assetId: asset.id, + sourceMtimeMs: Number(asset.updated_at ?? 0) + }); + if (result === "created") created += 1; + else if (result === "updated") updated += 1; + else skipped += 1; + } catch (error) { + console.warn( + `[MindSpace] asset page sync failed for ${asset.original_filename}:`, + error?.message ?? error + ); + skipped += 1; + } + } + return { created, updated, skipped }; +} + // mindspace-og-tags.mjs function rawTitleFromHtml(html) { return String(html).match(/]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? ""; @@ -18333,6 +25859,17 @@ function resolveImageUrl(image, { origin, pageDirUrl }) { if (trimmed.startsWith("/")) return `${origin}${trimmed}`; return `${pageDirUrl}${trimmed}`; } +function rawMetaContent(html, attr, name) { + const pattern = new RegExp( + `]+${attr}=["']${name}["'][^>]+content=["']([^"']*)["']|]+content=["']([^"']*)["'][^>]+${attr}=["']${name}["']`, + "i" + ); + const match = String(html).match(pattern); + return match?.[1] || match?.[2] || ""; +} +function escapeScriptString(value) { + return JSON.stringify(String(value ?? "")).replace(/]+property=["']og:image["']/i.test(source)) return source; @@ -18362,9 +25899,88 @@ ${tags.map((t) => ` ${t}`).join("\n")} } return source; } +function injectWechatShareBridge(html, { + pageUrl, + signatureEndpoint = "/auth/wechat/public-js-sdk-signature" +} = {}) { + const source = String(html ?? ""); + if (!pageUrl) return source; + if (source.includes('data-tkmind-wechat-share="1"')) return source; + const title = rawMetaContent(source, "property", "og:title") || rawTitleFromHtml(source); + const description = rawMetaContent(source, "property", "og:description") || rawMetaContent(source, "name", "description") || ""; + const imageUrl = rawMetaContent(source, "property", "og:image") || ""; + const script = ` + `; + if (/<\/head>/i.test(source)) { + return source.replace(/<\/head>/i, `${script} +`); + } + if (/]*>/i.test(source)) { + return source.replace(/(]*>)/i, `$1${script}`); + } + return `${script} +${source}`; +} // mindspace-thumbnail-png.mjs -import fs19 from "node:fs"; +import fs22 from "node:fs"; import { Resvg } from "@resvg/resvg-js"; var RENDER_WIDTH = 540; function rasterizeThumbnailSvgToPng(svg) { @@ -18380,28 +25996,28 @@ function thumbnailPngPathForSvg(svgAbsPath) { return svgAbsPath.replace(/\.svg$/i, ".png"); } function ensureThumbnailPng(svgAbsPath) { - if (!fs19.existsSync(svgAbsPath)) return null; + if (!fs22.existsSync(svgAbsPath)) return null; const pngPath = thumbnailPngPathForSvg(svgAbsPath); try { - const svgStat = fs19.statSync(svgAbsPath); - if (fs19.existsSync(pngPath) && fs19.statSync(pngPath).mtimeMs >= svgStat.mtimeMs) { + const svgStat = fs22.statSync(svgAbsPath); + if (fs22.existsSync(pngPath) && fs22.statSync(pngPath).mtimeMs >= svgStat.mtimeMs) { return pngPath; } - const svg = fs19.readFileSync(svgAbsPath, "utf8"); - fs19.writeFileSync(pngPath, rasterizeThumbnailSvgToPng(svg)); + const svg = fs22.readFileSync(svgAbsPath, "utf8"); + fs22.writeFileSync(pngPath, rasterizeThumbnailSvgToPng(svg)); return pngPath; } catch { - return fs19.existsSync(pngPath) ? pngPath : null; + return fs22.existsSync(pngPath) ? pngPath : null; } } // billing-subscription.mjs -import crypto23 from "node:crypto"; +import crypto24 from "node:crypto"; var PLAN_CATALOG = { free: { name: "\u514D\u8D39\u7248", priceCents: 0, - periodTokens: 15e4, + periodTokens: 45e4, periodImages: 10, modelTier: "basic", overageRate: 1, @@ -18410,7 +26026,7 @@ var PLAN_CATALOG = { lite: { name: "\u8F7B\u91CF\u7248", priceCents: 990, - periodTokens: 12e5, + periodTokens: 36e5, periodImages: 50, modelTier: "basic", overageRate: 0.5, @@ -18419,7 +26035,7 @@ var PLAN_CATALOG = { standard: { name: "\u6807\u51C6\u7248", priceCents: 2900, - periodTokens: 45e5, + periodTokens: 135e5, periodImages: 200, modelTier: "standard", overageRate: 0.7, @@ -18435,6 +26051,11 @@ var PLAN_CATALOG = { periodDays: 30 } }; +var PLAN_TOKEN_ALLOWANCE_MIGRATIONS = [ + ["free", 15e4, PLAN_CATALOG.free.periodTokens], + ["lite", 12e5, PLAN_CATALOG.lite.periodTokens], + ["standard", 45e5, PLAN_CATALOG.standard.periodTokens] +]; function getPlanDef(planType) { return PLAN_CATALOG[planType] ?? null; } @@ -18463,7 +26084,7 @@ function mapSubRow(row) { updatedAt: Number(row.updated_at) }; } -function createSubscriptionService(pool, { getPlanAsync = null } = {}) { +function createSubscriptionService(pool2, { getPlanAsync = null } = {}) { const resolvePlan = async (planType) => { if (getPlanAsync) { const p2 = await getPlanAsync(planType); @@ -18474,7 +26095,7 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { }; const getActiveSubscription = async (userId) => { const now = Date.now(); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_subscriptions WHERE user_id = ? AND status = 'active' AND expires_at > ? ORDER BY expires_at DESC LIMIT 1`, @@ -18488,7 +26109,7 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { const now = Date.now(); const periodDays = durationDays ?? plan.periodDays; const periodEnd = now + periodDays * 24 * 60 * 60 * 1e3; - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); await conn.query( @@ -18496,7 +26117,7 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { WHERE user_id = ? AND status = 'active'`, [now, userId] ); - const id = crypto23.randomUUID(); + const id = crypto24.randomUUID(); await conn.query( `INSERT INTO h5_subscriptions (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, @@ -18525,7 +26146,7 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { [planType, now, userId] ); await conn.commit(); - const [rows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [id]); + const [rows] = await pool2.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [id]); return { ok: true, subscription: mapSubRow(rows[0]) }; } catch (err) { await conn.rollback(); @@ -18570,17 +26191,17 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { return { fullyCovers: false, overageRate: sub.overageRate }; }; const renewSubscription = async (subId, operatorId = null) => { - const [rows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [subId]); + const [rows] = await pool2.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [subId]); const sub = mapSubRow(rows[0]); if (!sub) return { ok: false, message: "\u8BA2\u9605\u4E0D\u5B58\u5728" }; const plan = await resolvePlan(sub.planType); if (!plan) return { ok: false, message: "\u5957\u9910\u5B9A\u4E49\u5DF2\u5931\u6548" }; const now = Date.now(); const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1e3; - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); - const newId = crypto23.randomUUID(); + const newId = crypto24.randomUUID(); await conn.query( `UPDATE h5_subscriptions SET status = 'expired', updated_at = ? WHERE id = ?`, [now, subId] @@ -18610,7 +26231,7 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { ] ); await conn.commit(); - const [newRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [newId]); + const [newRows] = await pool2.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [newId]); return { ok: true, subscription: mapSubRow(newRows[0]) }; } catch (err) { await conn.rollback(); @@ -18621,13 +26242,13 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { }; const expireStaleSubscriptions = async () => { const now = Date.now(); - const [result] = await pool.query( + const [result] = await pool2.query( `UPDATE h5_subscriptions SET status = 'expired', updated_at = ? WHERE status = 'active' AND expires_at <= ?`, [now, now] ); if (result.affectedRows > 0) { - await pool.query( + await pool2.query( `UPDATE h5_users u LEFT JOIN h5_subscriptions s ON s.user_id = u.id AND s.status = 'active' AND s.expires_at > ? @@ -18640,13 +26261,13 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { }; const cancelSubscription = async (userId, operatorId = null) => { const now = Date.now(); - const [result] = await pool.query( + const [result] = await pool2.query( `UPDATE h5_subscriptions SET status = 'cancelled', updated_at = ? WHERE user_id = ? AND status = 'active'`, [now, userId] ); if (result.affectedRows > 0) { - await pool.query( + await pool2.query( `UPDATE h5_users SET plan_type = 'free', updated_at = ? WHERE id = ?`, [now, userId] ); @@ -18668,11 +26289,11 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { params.push(status); } const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; - const [[{ total }]] = await pool.query( + const [[{ total }]] = await pool2.query( `SELECT COUNT(*) AS total FROM h5_subscriptions s ${where}`, params ); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT s.*, u.username, u.display_name FROM h5_subscriptions s JOIN h5_users u ON u.id = s.user_id ${where} ORDER BY s.created_at DESC LIMIT ${safePageSize} OFFSET ${offset}`, @@ -18695,7 +26316,7 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { return { ok: false, message: "\u65E0\u6548\u5957\u9910\u6216\u514D\u8D39\u5957\u9910\u4E0D\u53EF\u8D2D\u4E70" }; } const now = Date.now(); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [currentSubRows] = await conn.query( @@ -18748,7 +26369,7 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { WHERE user_id = ? AND status = 'active'`, [now, userId] ); - const id = crypto23.randomUUID(); + const id = crypto24.randomUUID(); const periodEnd = now + plan.periodDays * 24 * 60 * 60 * 1e3; await conn.query( `INSERT INTO h5_subscriptions @@ -18777,7 +26398,7 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { [planType, now, userId] ); await conn.commit(); - const [subRows] = await pool.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [id]); + const [subRows] = await pool2.query(`SELECT * FROM h5_subscriptions WHERE id = ?`, [id]); return { ok: true, subscription: mapSubRow(subRows[0]), @@ -18792,7 +26413,7 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { }; const setAutoRenew = async (userId, enabled) => { const now = Date.now(); - const [result] = await pool.query( + const [result] = await pool2.query( `UPDATE h5_subscriptions SET auto_renew = ?, updated_at = ? WHERE user_id = ? AND status = 'active' AND expires_at > ?`, [enabled ? 1 : 0, now, userId, now] @@ -18801,7 +26422,7 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { }; const processAutoRenewals = async () => { const now = Date.now(); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT s.*, w.balance_cents FROM h5_subscriptions s JOIN h5_user_wallets w ON w.user_id = s.user_id @@ -18812,10 +26433,10 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { let failed = 0; for (const row of rows) { const sub = mapSubRow(row); - const plan = getPlanDef(sub.planType); + const plan = await resolvePlan(sub.planType); if (!plan || plan.priceCents === 0) continue; const balance = Number(row.balance_cents ?? 0); - const conn = await pool.getConnection(); + const conn = await pool2.getConnection(); try { await conn.beginTransaction(); const [walletRows] = await conn.query( @@ -18842,7 +26463,7 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { `UPDATE h5_subscriptions SET status = 'expired', auto_renew = 0, updated_at = ? WHERE id = ?`, [now, sub.id] ); - const newId = crypto23.randomUUID(); + const newId = crypto24.randomUUID(); const newPeriodEnd = now + plan.periodDays * 24 * 60 * 60 * 1e3; await conn.query( `INSERT INTO h5_subscriptions @@ -18908,7 +26529,7 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { }; const consumeImageQuota = async (userId, count, conn = null) => { if (conn) return consumeImageQuotaTx(userId, count, conn); - const ownConn = await pool.getConnection(); + const ownConn = await pool2.getConnection(); try { await ownConn.beginTransaction(); const result = await consumeImageQuotaTx(userId, count, ownConn); @@ -18935,8 +26556,8 @@ function createSubscriptionService(pool, { getPlanAsync = null } = {}) { listSubscriptions }; } -async function ensurePlanCatalogSchema(pool) { - await pool.query(` +async function ensurePlanCatalogSchema(pool2) { + await pool2.query(` CREATE TABLE IF NOT EXISTS h5_plan_catalog ( plan_type VARCHAR(32) PRIMARY KEY, name VARCHAR(64) NOT NULL, @@ -18958,16 +26579,16 @@ async function ensurePlanCatalogSchema(pool) { "ALTER TABLE h5_subscriptions ADD COLUMN period_images_used INT NOT NULL DEFAULT 0" ]) { try { - await pool.query(col); + await pool2.query(col); } catch (_) { } } - const [[{ cnt }]] = await pool.query(`SELECT COUNT(*) AS cnt FROM h5_plan_catalog`); + const [[{ cnt }]] = await pool2.query(`SELECT COUNT(*) AS cnt FROM h5_plan_catalog`); if (Number(cnt) === 0) { - const now = Date.now(); + const now2 = Date.now(); const entries = Object.entries(PLAN_CATALOG); for (const [i, [planType, plan]] of entries.entries()) { - await pool.query( + await pool2.query( `INSERT IGNORE INTO h5_plan_catalog (plan_type, name, price_cents, period_days, period_tokens, period_images, model_tier, overage_rate, sort_order, is_active, created_at, updated_at) @@ -18982,14 +26603,30 @@ async function ensurePlanCatalogSchema(pool) { plan.modelTier, plan.overageRate, i, - now, - now + now2, + now2 ] ); } } + const now = Date.now(); + for (const [planType, previousTokens, nextTokens] of PLAN_TOKEN_ALLOWANCE_MIGRATIONS) { + if (nextTokens <= previousTokens) continue; + await pool2.query( + `UPDATE h5_plan_catalog + SET period_tokens = ?, updated_at = ? + WHERE plan_type = ? AND period_tokens = ?`, + [nextTokens, now, planType, previousTokens] + ); + await pool2.query( + `UPDATE h5_subscriptions + SET period_tokens_limit = ?, updated_at = ? + WHERE status = 'active' AND plan_type = ? AND period_tokens_limit = ?`, + [nextTokens, now, planType, previousTokens] + ); + } } -function createPlanCatalogService(pool) { +function createPlanCatalogService(pool2) { function mapPlanRow(row) { return { planType: row.plan_type, @@ -19007,13 +26644,13 @@ function createPlanCatalogService(pool) { } const listPlans = async ({ includeInactive = true } = {}) => { const where = includeInactive ? "" : "WHERE is_active = 1"; - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_plan_catalog ${where} ORDER BY sort_order ASC, price_cents ASC` ); return rows.map(mapPlanRow); }; const getPlan = async (planType) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_plan_catalog WHERE plan_type = ?`, [planType] ); @@ -19024,7 +26661,7 @@ function createPlanCatalogService(pool) { return { ok: false, message: "\u5957\u9910\u6807\u8BC6\u53EA\u80FD\u4F7F\u7528\u5C0F\u5199\u5B57\u6BCD\u3001\u6570\u5B57\u548C\u4E0B\u5212\u7EBF\uFF0C\u6700\u957F32\u4F4D" }; } const now = Date.now(); - await pool.query( + await pool2.query( `INSERT INTO h5_plan_catalog (plan_type, name, price_cents, period_days, period_tokens, period_images, model_tier, overage_rate, sort_order, is_active, description, created_at, updated_at) @@ -19056,7 +26693,7 @@ function createPlanCatalogService(pool) { }; const deletePlan = async (planType) => { if (planType === "free") return { ok: false, message: "\u514D\u8D39\u5957\u9910\u4E0D\u53EF\u5220\u9664" }; - const [result] = await pool.query( + const [result] = await pool2.query( `DELETE FROM h5_plan_catalog WHERE plan_type = ?`, [planType] ); @@ -19066,21 +26703,21 @@ function createPlanCatalogService(pool) { } // wechat-pay.mjs -import crypto24 from "node:crypto"; -import fs20 from "node:fs"; +import crypto25 from "node:crypto"; +import fs23 from "node:fs"; import { fetch as fetch2 } from "undici"; var API_BASE = "https://api.mch.weixin.qq.com"; function readPrivateKey(config) { if (config.privateKeyPem) return config.privateKeyPem; - if (config.privateKeyPath && fs20.existsSync(config.privateKeyPath)) { - return fs20.readFileSync(config.privateKeyPath, "utf8"); + if (config.privateKeyPath && fs23.existsSync(config.privateKeyPath)) { + return fs23.readFileSync(config.privateKeyPath, "utf8"); } return null; } function readPlatformCert(config) { if (config.platformCertPem) return config.platformCertPem; - if (config.platformCertPath && fs20.existsSync(config.platformCertPath)) { - return fs20.readFileSync(config.platformCertPath, "utf8"); + if (config.platformCertPath && fs23.existsSync(config.platformCertPath)) { + return fs23.readFileSync(config.platformCertPath, "utf8"); } return null; } @@ -19145,12 +26782,12 @@ function loadWechatPayConfig() { }; } function randomNonce(length = 32) { - return crypto24.randomBytes(length).toString("hex").slice(0, length); + return crypto25.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 crypto24.createHash("md5").update(stringSignTemp, "utf8").digest("hex").toUpperCase(); + return crypto25.createHash("md5").update(stringSignTemp, "utf8").digest("hex").toUpperCase(); } function buildXml(fields) { const parts = [""]; @@ -19182,7 +26819,7 @@ function normalizeV2Transaction(fields) { amount: fields.total_fee ? { total: Number(fields.total_fee) } : void 0 }; } -async function wechatV2Request(config, path23, fields) { +async function wechatV2Request(config, path29, fields) { const payload = { appid: config.appId, mch_id: config.mchId, @@ -19191,7 +26828,7 @@ async function wechatV2Request(config, path23, fields) { }; payload.sign = signV2Params(payload, config.apiKey); const body = buildXml(payload); - const res = await fetch2(`${API_BASE}${path23}`, { + const res = await fetch2(`${API_BASE}${path29}`, { method: "POST", headers: { "Content-Type": "text/xml" }, body @@ -19210,7 +26847,7 @@ async function wechatV2Request(config, path23, fields) { return data; } function signMessage(message, privateKeyPem) { - return crypto24.createSign("RSA-SHA256").update(message).sign(privateKeyPem, "base64"); + return crypto25.createSign("RSA-SHA256").update(message).sign(privateKeyPem, "base64"); } function buildAuthorization({ mchId, serialNo, privateKey }, method, pathWithQuery, body) { const timestamp = String(Math.floor(Date.now() / 1e3)); @@ -19233,10 +26870,10 @@ ${payload} ].join(","); return { authorization, timestamp, nonce, signature }; } -async function wechatV3Request(config, method, path23, bodyObj) { +async function wechatV3Request(config, method, path29, bodyObj) { const body = bodyObj ? JSON.stringify(bodyObj) : ""; - const { authorization } = buildAuthorization(config, method, path23, body); - const res = await fetch2(`${API_BASE}${path23}`, { + const { authorization } = buildAuthorization(config, method, path29, body); + const res = await fetch2(`${API_BASE}${path29}`, { method, headers: { Authorization: authorization, @@ -19441,7 +27078,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 = crypto24.createVerify("RSA-SHA256").update(message).verify(config.platformCert, signature, "base64"); + const verified = crypto25.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"); } @@ -19454,7 +27091,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 = crypto24.createDecipheriv( + const decipher = crypto25.createDecipheriv( "aes-256-gcm", Buffer.from(config.apiV3Key, "utf8"), Buffer.from(resource.nonce, "utf8") @@ -19505,7 +27142,7 @@ function createWechatPayClient(config) { var WECHAT_NOTIFY_SUCCESS_V2 = ""; // wechat-oauth.mjs -import crypto25 from "node:crypto"; +import crypto26 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"; @@ -19624,7 +27261,7 @@ async function fetchUserInfo(accessToken, openid) { unionid: data.unionid ?? null }; } -function createWechatOAuthService(pool, config, { userAuth: userAuth2 } = {}) { +function createWechatOAuthService(pool2, config, { userAuth: userAuth2 } = {}) { if (!config?.enabled) { return { enabled: false, @@ -19634,8 +27271,8 @@ function createWechatOAuthService(pool, config, { userAuth: userAuth2 } = {}) { }; } const pruneExpiredStates = async (now = Date.now()) => { - if (!pool) return; - await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE expires_at <= ?`, [now]); + if (!pool2) return; + await pool2.query(`DELETE FROM h5_wechat_oauth_states WHERE expires_at <= ?`, [now]); }; const createState = async ({ returnTo = "/", @@ -19647,10 +27284,10 @@ function createWechatOAuthService(pool, config, { userAuth: userAuth2 } = {}) { authMode = "mp", now = Date.now() } = {}) => { - const state = crypto25.randomBytes(24).toString("base64url"); - if (pool) { + const state = crypto26.randomBytes(24).toString("base64url"); + if (pool2) { await pruneExpiredStates(now); - await pool.query( + await pool2.query( `INSERT INTO h5_wechat_oauth_states (state, return_to, utm_source, utm_medium, utm_campaign, intent, bind_user_id, auth_mode, status, expires_at, created_at) @@ -19672,8 +27309,8 @@ function createWechatOAuthService(pool, config, { userAuth: userAuth2 } = {}) { return state; }; const loadState = async (state, now = Date.now()) => { - if (!state || !pool) return null; - const [rows] = await pool.query( + if (!state || !pool2) return null; + const [rows] = await pool2.query( `SELECT state, return_to, utm_source, utm_medium, utm_campaign, intent, bind_user_id, auth_mode, status, result_kind, result_token, result_message, expires_at FROM h5_wechat_oauth_states @@ -19688,7 +27325,7 @@ function createWechatOAuthService(pool, config, { userAuth: userAuth2 } = {}) { const consumeState = async (state, now = Date.now()) => { const row = await loadState(state, now); if (!row) return null; - await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); + await pool2.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); return { returnTo: row.return_to || "/", utmSource: row.utm_source, @@ -19700,7 +27337,7 @@ function createWechatOAuthService(pool, config, { userAuth: userAuth2 } = {}) { }; }; const markStateComplete = async (state, { resultKind, resultToken = null, resultMessage = null }, now = Date.now()) => { - await pool.query( + await pool2.query( `UPDATE h5_wechat_oauth_states SET status = 'done', result_kind = ?, result_token = ?, result_message = ?, expires_at = ? WHERE state = ?`, @@ -19759,7 +27396,7 @@ function createWechatOAuthService(pool, config, { userAuth: userAuth2 } = {}) { const row = await loadState(state, now); if (!row) return { status: "expired" }; if (row.status !== "done") return { status: "pending" }; - await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); + await pool2.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); if (row.result_kind === "error") { return { status: "error", message: row.result_message || "\u5FAE\u4FE1\u767B\u5F55\u5931\u8D25" }; } @@ -19872,7 +27509,7 @@ function createWechatOAuthService(pool, config, { userAuth: userAuth2 } = {}) { utmCampaign: stateRow.utm_campaign }; } - await pool.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); + await pool2.query(`DELETE FROM h5_wechat_oauth_states WHERE state = ?`, [state]); return { authMode, action: "login", @@ -19908,9 +27545,9 @@ function createWechatOAuthService(pool, config, { userAuth: userAuth2 } = {}) { } // wechat-mp.mjs -import crypto27 from "node:crypto"; -import fs22 from "node:fs"; -import path21 from "node:path"; +import crypto28 from "node:crypto"; +import fs25 from "node:fs"; +import path27 from "node:path"; import { fetch as undiciFetch7 } from "undici"; // schedule-intent.mjs @@ -20037,12 +27674,12 @@ function shouldUseScheduleAssistant(text) { } // wechat-media.mjs -import crypto26 from "node:crypto"; -import fs21 from "node:fs"; -import path20 from "node:path"; +import crypto27 from "node:crypto"; +import fs24 from "node:fs"; +import path26 from "node:path"; import { fileURLToPath as fileURLToPath5 } from "node:url"; import { fetch as undiciFetch6 } from "undici"; -var __dirname4 = path20.dirname(fileURLToPath5(import.meta.url)); +var __dirname4 = path26.dirname(fileURLToPath5(import.meta.url)); var DEFAULT_WECHAT_MEDIA_URL = "https://api.weixin.qq.com/cgi-bin/media/get"; var DEFAULT_MAX_IMAGE_BYTES = 10 * 1024 * 1024; var ALLOWED_IMAGE_MIME_TYPES = /* @__PURE__ */ new Map([ @@ -20059,7 +27696,7 @@ function resolveImageExtension(contentType = "", fallbackUrl = "") { extension: ALLOWED_IMAGE_MIME_TYPES.get(normalized) }; } - const ext = path20.extname(String(fallbackUrl ?? "").split("?")[0]).replace(/^\./, "").toLowerCase(); + const ext = path26.extname(String(fallbackUrl ?? "").split("?")[0]).replace(/^\./, "").toLowerCase(); if (ext === "jpg" || ext === "jpeg") return { mimeType: "image/jpeg", extension: "jpg" }; if (ext === "png") return { mimeType: "image/png", extension: "png" }; if (ext === "webp") return { mimeType: "image/webp", extension: "webp" }; @@ -20118,8 +27755,8 @@ async function persistWechatImage({ h5Root = __dirname4 } = {}) { if (!userId) throw new Error("\u7F3A\u5C11 userId"); - const publishDir = path20.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR, "wechat-mp"); - fs21.mkdirSync(publishDir, { recursive: true }); + const publishDir = path26.join(h5Root, PUBLISH_ROOT_DIR, String(userId), PUBLIC_ZONE_DIR, "wechat-mp"); + fs24.mkdirSync(publishDir, { recursive: true }); let source = "wechat_media"; let buffer; let contentType = ""; @@ -20151,11 +27788,11 @@ async function persistWechatImage({ throw new Error(`\u6682\u4E0D\u652F\u6301\u7684\u56FE\u7247\u7C7B\u578B\uFF1A${contentType || "unknown"}`); } const timestamp = Date.now(); - const hash = crypto26.createHash("sha1").update(buffer).digest("hex").slice(0, 12); + const hash = crypto27.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 = path20.join(publishDir, filename); - fs21.writeFileSync(absolutePath, buffer); + const absolutePath = path26.join(publishDir, filename); + fs24.writeFileSync(absolutePath, buffer); return { absolutePath, bytes: buffer.length, @@ -20239,8 +27876,8 @@ function withNickname(text, nickname, nicknameEnabled) { if (!nicknameEnabled || !nickname) return text; return `${nickname}\uFF0C${text}`; } -function buildText(pool, ctx) { - const text = pickRandom(pool, ctx.randomEnabled); +function buildText(pool2, ctx) { + const text = pickRandom(pool2, ctx.randomEnabled); return withNickname(text, ctx.nickname, ctx.nicknameEnabled); } var ImageBuilder = { @@ -20315,7 +27952,20 @@ var DEFAULT_STATUS_TEXT = "\u6211\u5728\u8FD9\u8FB9\u3002\u4E0A\u4E00\u6761\u598 var DEFAULT_UNSUPPORTED_TEXT = "\u5F53\u524D\u5148\u652F\u6301\u6587\u5B57\u6D88\u606F\uFF0C\u4F60\u53EF\u4EE5\u76F4\u63A5\u53D1\u6587\u5B57\u7ED9\u6211\u3002"; var DEFAULT_UNBOUND_TEXT = "\u5148\u70B9\u8FD9\u91CC\u5B8C\u6210\u7ED1\u5B9A\uFF0C\u518D\u7EE7\u7EED\u548C\u4E13\u5C5E Agent \u5BF9\u8BDD\uFF1A"; var DEFAULT_PROGRESS_DELAY_MS = 8e3; -var PUBLIC_HTML_LINK_PATTERN = /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi; +var PUBLIC_HTML_LINK_PATTERN2 = /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/public\/([^\s<>"')\]]+\.html)/gi; +var SESSION_WORKSPACE_TOOL_NAMES = /* @__PURE__ */ new Set([ + "write", + "edit", + "tree", + "read_file", + "write_file", + "edit_file", + "create_dir", + "list_dir" +]); +function escapeRegExp(value) { + return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} function parseXmlField(xml, field) { const cdataMatch = xml.match(new RegExp(`<${field}><\\/${field}>`)); if (cdataMatch) return cdataMatch[1]; @@ -20403,7 +28053,7 @@ function deriveWechatEndpointFromUrl(sourceUrl, pathname) { } function createUserMessage2(text, metadata = {}) { return { - id: crypto27.randomUUID(), + id: crypto28.randomUUID(), role: "user", created: Math.floor(Date.now() / 1e3), content: [{ type: "text", text }], @@ -20457,6 +28107,7 @@ async function executeSessionReply(apiFetch2, sessionId, requestId, prompt, meta const decoder = new TextDecoder(); let buffer = ""; let messages = []; + let hasScopedAssistantUpdate = false; while (true) { const { value, done } = await reader.read(); if (done) break; @@ -20482,16 +28133,23 @@ async function executeSessionReply(apiFetch2, sessionId, requestId, prompt, meta if (hasActionRequired) { throw new Error("\u5F53\u524D\u56DE\u590D\u9700\u8981\u4EBA\u5DE5\u786E\u8BA4\uFF0C\u516C\u4F17\u53F7\u901A\u9053\u6682\u4E0D\u652F\u6301"); } + if (event.message.role === "assistant") hasScopedAssistantUpdate = true; messages = pushMessage2(messages, event.message); } else if (event.type === "UpdateConversation") { - messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible); + if (hasScopedAssistantUpdate) { + messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible); + } } else if (event.type === "Error") { throw new Error(event.error || "\u4EFB\u52A1\u6267\u884C\u5931\u8D25"); } else if (event.type === "Finish") { const assistant = [...messages].reverse().find((item) => item.role === "assistant"); + if (!hasScopedAssistantUpdate || !assistant) { + throw new Error("\u672C\u8F6E\u672A\u6536\u5230\u53EF\u53D1\u9001\u7684\u65B0\u56DE\u590D\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5"); + } return { text: messageVisibleText2(assistant), - tokenState: event.token_state ?? null + tokenState: event.token_state ?? null, + messages }; } } @@ -20519,6 +28177,275 @@ function splitWechatText(text, maxBytes = WECHAT_CUSTOMER_TEXT_MAX_BYTES) { } return chunks.length > 0 ? chunks : ["\u6211\u5148\u6536\u5230\u4E86\uFF0C\u4F46\u8FD9\u6B21\u6CA1\u6709\u751F\u6210\u53EF\u53D1\u9001\u7684\u6587\u672C\u7ED3\u679C\u3002"]; } +function flattenSessionTools(extensions = []) { + return new Set( + extensions.flatMap( + (extension) => Array.isArray(extension?.available_tools) ? extension.available_tools : [] + ) + ); +} +function needsWorkspaceTools(sessionPolicy) { + return developerToolsFromPolicy(sessionPolicy).some((tool) => SESSION_WORKSPACE_TOOL_NAMES.has(tool)); +} +async function readSessionExtensions(fetchForSession, sessionId) { + const payload = await readJsonResponse2(await fetchForSession(sessionId, `/sessions/${sessionId}/extensions`)); + return payload?.extensions ?? []; +} +async function sessionHasRequiredTools(fetchForSession, sessionId, sessionPolicy) { + if (!needsWorkspaceTools(sessionPolicy)) return true; + const desired = developerToolsFromPolicy(sessionPolicy).filter((tool) => SESSION_WORKSPACE_TOOL_NAMES.has(tool)); + if (desired.length === 0) return true; + const available = flattenSessionTools(await readSessionExtensions(fetchForSession, sessionId)); + return desired.some((tool) => available.has(tool)); +} +function extractHtmlWriteTargets(messages = []) { + const targets = /* @__PURE__ */ new Set(); + for (const message of messages) { + for (const item of message?.content ?? []) { + if (item?.type !== "toolRequest") continue; + const toolCall = item.toolCall?.value; + const name = toolCall?.name; + const args = toolCall?.arguments ?? {}; + const action = String(args.action ?? "").toLowerCase(); + const writeLikeDeveloper = name === "developer" && action === "write"; + const normalizedName = String(name ?? "").trim(); + const writeLikeSandbox = (normalizedName === "write_file" || normalizedName === "edit_file" || normalizedName.endsWith("__write_file") || normalizedName.endsWith("__edit_file")) && typeof args.path === "string"; + if (!writeLikeDeveloper && !writeLikeSandbox) continue; + const candidate = String(args.path ?? "").trim(); + if (candidate.toLowerCase().endsWith(".html")) targets.add(candidate); + } + } + return [...targets]; +} +function looksLikeHtmlGenerationIntent(text) { + const normalized = String(text ?? "").trim().toLowerCase(); + if (!normalized) return false; + return /(?:生成|创建|做|写|帮我.*(?:生成|创建|做|写)).*(?:html|页面|网页|page|文件)/i.test(normalized); +} +function usedStaticPagePublishSkill(messages = []) { + return messages.some( + (message) => message?.content?.some((item) => { + if (item?.type !== "toolRequest") return false; + const toolCall = item.toolCall?.value; + const name = String(toolCall?.name ?? "").trim(); + const args = toolCall?.arguments ?? {}; + const skillName = String(args.name ?? "").trim(); + return name === "load_skill" && skillName === "static-page-publish"; + }) + ); +} +function isBareCompletionText(text) { + const normalized = String(text ?? "").trim(); + return /^(?:已完成|完成了|完成)$/u.test(normalized); +} +function hasAnyToolRequest(messages = []) { + return messages.some( + (message) => message?.content?.some((item) => item?.type === "toolRequest") + ); +} +function isSuspiciousBareCompletionReply(reply, intent) { + if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; + if (!isBareCompletionText(reply?.text)) return false; + if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false; + return !hasAnyToolRequest(reply?.messages ?? []); +} +function looksLikePublishSuccessClaim(text) { + const normalized = String(text ?? "").trim(); + if (!normalized) return false; + return /(?:页面|网页|html).*(?:已创建|已生成|已发布|创建完毕|生成完成|发布成功)|(?:成功发布|已经发布|已创建完毕).*(?:页面|网页|html)/iu.test(normalized); +} +async function hasAnyValidPublishedHtmlLink(text, linkExists, { confirmedArtifacts = [] } = {}) { + const value = String(text ?? ""); + for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN2)) { + try { + if (await linkExists(match[0])) return true; + } catch { + } + const filename = String(match[2] ?? "").split("/").pop()?.trim(); + if (filename && confirmedArtifacts.some((artifact) => { + const artifactName = path27.posix.basename(String(artifact.relativePath ?? "").replace(/\\/g, "/")); + return artifactName === filename && artifactFileExists(artifact); + })) { + return true; + } + } + return false; +} +async function isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists = defaultPublicHtmlLinkExists } = {}) { + if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; + const text = String(reply?.text ?? "").trim(); + if (!looksLikePublishSuccessClaim(text)) return false; + if (extractHtmlWriteTargets(reply?.messages ?? []).length > 0) return false; + return !await hasAnyValidPublishedHtmlLink(text, linkExists); +} +function isMissingRequiredPublishSkill(reply, intent) { + if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; + const wroteHtml = extractHtmlWriteTargets(reply?.messages ?? []).length > 0; + if (!wroteHtml) return false; + return !usedStaticPagePublishSkill(reply?.messages ?? []); +} +function buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl) { + const owner = path27.basename(path27.resolve(workingDir)); + const normalized = relativePath.split(path27.sep).map(encodeURIComponent).join("/"); + return `${String(publicBaseUrl ?? "").replace(/\/+$/, "")}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(owner)}/${normalized}`; +} +function ensurePublicHtmlArtifact(htmlPath, workingDir) { + const workspaceRoot = path27.resolve(workingDir); + const source = path27.isAbsolute(String(htmlPath ?? "")) ? path27.resolve(String(htmlPath)) : path27.resolve(workspaceRoot, String(htmlPath ?? "")); + if (source !== workspaceRoot && !source.startsWith(`${workspaceRoot}${path27.sep}`)) return null; + if (!fs25.existsSync(source) || !fs25.statSync(source).isFile()) return null; + const publicRoot = path27.join(workspaceRoot, "public"); + let publishedPath = source; + if (source !== publicRoot && !source.startsWith(`${publicRoot}${path27.sep}`)) { + fs25.mkdirSync(publicRoot, { recursive: true }); + publishedPath = path27.join(publicRoot, path27.basename(source)); + if (publishedPath !== source) fs25.copyFileSync(source, publishedPath); + } + const relativePath = path27.relative(workspaceRoot, publishedPath); + if (!relativePath || relativePath.startsWith("..")) return null; + return { + localPath: publishedPath, + relativePath + }; +} +function collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }) { + const artifacts = []; + const htmlTargets = extractHtmlWriteTargets(reply?.messages ?? []); + for (const target of htmlTargets) { + const artifact = ensurePublicHtmlArtifact(target, workingDir); + if (!artifact) continue; + artifacts.push({ + ...artifact, + url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl) + }); + } + return artifacts; +} +function extractRequestedHtmlTarget(text) { + const value = String(text ?? ""); + if (!value) return ""; + const explicitPublicMatch = value.match(/(?:^|[\s`'"])(public\/[a-z0-9._-]+\.html)\b/i); + if (explicitPublicMatch?.[1]) return explicitPublicMatch[1]; + const bareMatch = value.match(/(?:^|[\s`'"])([a-z0-9._-]+\.html)\b/i); + if (bareMatch?.[1]) return `public/${bareMatch[1]}`; + return ""; +} +function collectExpectedHtmlArtifacts(intent, { workingDir, publicBaseUrl }) { + const target = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? ""); + if (!target) return []; + const artifact = ensurePublicHtmlArtifact(target, workingDir); + if (!artifact) return []; + return [{ + ...artifact, + url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl) + }]; +} +function hasAnyUrl(text) { + return /https?:\/\/\S+/i.test(String(text ?? "")); +} +function hasAnyPublicHtmlLink(text) { + return [...String(text ?? "").matchAll(PUBLIC_HTML_LINK_PATTERN2)].length > 0; +} +function collectPublicHtmlLinkFilenames(text) { + const filenames = /* @__PURE__ */ new Set(); + for (const match of String(text ?? "").matchAll(PUBLIC_HTML_LINK_PATTERN2)) { + const relativePath = String(match[2] ?? "").trim(); + const filename = path27.posix.basename(relativePath); + if (filename) filenames.add(filename); + } + return filenames; +} +function collectRecentPublishedHtmlArtifacts(intent, { workingDir, publicBaseUrl, replyText = "", sinceMs = 0, limit = 20 } = {}) { + const publicRoot = path27.join(path27.resolve(workingDir), "public"); + if (!fs25.existsSync(publicRoot) || !fs25.statSync(publicRoot).isDirectory()) return []; + const expectedTarget = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? ""); + const expectedRelativePath = expectedTarget ? expectedTarget.replace(/^\/+/, "").replace(/\\/g, "/") : ""; + const linkedFilenames = collectPublicHtmlLinkFilenames(replyText); + const artifacts = []; + const stack = [publicRoot]; + while (stack.length > 0 && artifacts.length < limit) { + const current = stack.pop(); + let entries = []; + try { + entries = fs25.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + const absolutePath = path27.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(absolutePath); + continue; + } + if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".html")) continue; + let stat; + try { + stat = fs25.statSync(absolutePath); + } catch { + continue; + } + if (sinceMs > 0 && Number(stat.mtimeMs ?? 0) + 1 < sinceMs) continue; + const relativePath = path27.relative(path27.resolve(workingDir), absolutePath); + if (!relativePath || relativePath.startsWith("..")) continue; + const normalizedRelativePath = relativePath.replace(/\\/g, "/"); + const filename = path27.posix.basename(normalizedRelativePath); + if (linkedFilenames.size > 0 && filename && !linkedFilenames.has(filename)) continue; + artifacts.push({ + localPath: absolutePath, + relativePath: normalizedRelativePath, + url: buildPublicHtmlUrl(workingDir, normalizedRelativePath, publicBaseUrl), + mtimeMs: Number(stat.mtimeMs ?? 0), + matchesExpected: expectedRelativePath ? normalizedRelativePath === expectedRelativePath : false + }); + if (artifacts.length >= limit) break; + } + } + return artifacts.sort((left, right) => { + if (left.matchesExpected !== right.matchesExpected) return left.matchesExpected ? -1 : 1; + return right.mtimeMs - left.mtimeMs; + }).map(({ mtimeMs, matchesExpected, ...artifact }) => artifact); +} +function rewritePublishedHtmlLinks(text, artifacts = []) { + const value = String(text ?? ""); + if (!value || artifacts.length === 0) return value; + let next = value; + for (const artifact of artifacts) { + const canonicalUrl = String(artifact?.url ?? "").trim(); + const filename = String(artifact?.relativePath ?? "").replace(/\\/g, "/").split("/").pop(); + if (!canonicalUrl || !filename) continue; + const wrongPublicLinkPattern = new RegExp( + `https?:\\/\\/[^\\s)]+\\/(?:MindSpace\\/[^\\s/]+\\/)?public\\/${escapeRegExp(filename)}\\b`, + "g" + ); + const wrongTkmindHtmlLinkPattern = new RegExp( + `https?:\\/\\/(?:[^\\s./]+\\.)*tkmind\\.cn\\/[^ +\\s)]*${escapeRegExp(filename)}\\b`, + "gi" + ); + next = next.replace(wrongPublicLinkPattern, canonicalUrl); + next = next.replace(wrongTkmindHtmlLinkPattern, canonicalUrl); + next = next.replace(PUBLIC_HTML_LINK_PATTERN2, (match) => { + if (match === canonicalUrl) return match; + const matchedFilename = String(match).split("/").pop(); + return matchedFilename === filename ? canonicalUrl : match; + }); + } + return next; +} +async function maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl, artifacts: providedArtifacts = null }) { + const artifacts = Array.isArray(providedArtifacts) ? providedArtifacts : collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl }); + const baseText = rewritePublishedHtmlLinks(String(reply?.text ?? "").trim(), artifacts).trim(); + for (const artifact of artifacts) { + const url = artifact.url; + if (baseText.includes(url)) return baseText; + return baseText ? `${baseText} + +\u67E5\u770B\u9875\u9762\uFF1A +${url}` : `\u67E5\u770B\u9875\u9762\uFF1A +${url}`; + } + return baseText; +} function stripInternalWechatUsername(text) { return String(text ?? "").replace(/^\s*wx_[a-z0-9_]{4,64}\s*[,,、::]\s*/i, ""); } @@ -20600,15 +28527,49 @@ function defaultPublicHtmlLinkExists(urlText) { const owner = parts[1]; const rest = parts.slice(3); if (!owner || rest.some((part) => !part || part === "." || part === "..")) return false; - const root = path21.resolve(process.cwd(), PUBLISH_ROOT_DIR, owner, "public"); - const target = path21.resolve(root, ...rest); - if (target !== root && !target.startsWith(`${root}${path21.sep}`)) return false; - return fs22.existsSync(target) && fs22.statSync(target).isFile(); + const root = path27.resolve(process.cwd(), PUBLISH_ROOT_DIR, owner, "public"); + const target = path27.resolve(root, ...rest); + if (target !== root && !target.startsWith(`${root}${path27.sep}`)) return false; + return fs25.existsSync(target) && fs25.statSync(target).isFile(); +} +function createPublicHtmlLinkExists(workingDir) { + const workspaceRoot = path27.resolve(String(workingDir ?? "")); + const workspaceKey = path27.basename(workspaceRoot); + return (urlText) => { + let url; + try { + url = new URL(urlText); + } catch { + return true; + } + const parts = url.pathname.split("/").filter(Boolean).map((part) => { + try { + return decodeURIComponent(part); + } catch { + return part; + } + }); + if (parts.length < 4 || parts[0] !== PUBLISH_ROOT_DIR || parts[2] !== "public") return true; + const owner = parts[1]; + const rest = parts.slice(3); + if (!owner || rest.some((part) => !part || part === "." || part === "..")) return false; + if (owner === workspaceKey) { + const root = path27.join(workspaceRoot, "public"); + const target = path27.resolve(root, ...rest); + if (target !== root && !target.startsWith(`${root}${path27.sep}`)) return false; + return fs25.existsSync(target) && fs25.statSync(target).isFile(); + } + return defaultPublicHtmlLinkExists(urlText); + }; +} +function resolveLinkExistsForWorkingDir(workingDir, linkExists = defaultPublicHtmlLinkExists) { + if (linkExists !== defaultPublicHtmlLinkExists) return linkExists; + return createPublicHtmlLinkExists(workingDir); } async function guardMissingPublicHtmlLinks(text, { linkExists = defaultPublicHtmlLinkExists } = {}) { const value = String(text ?? ""); const replacements = []; - for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN)) { + for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN2)) { const url = match[0]; let exists = true; try { @@ -20626,6 +28587,120 @@ async function guardMissingPublicHtmlLinks(text, { linkExists = defaultPublicHtm } return replacements.reduce((next, [from, to]) => next.replaceAll(from, to), value); } +function downgradePrematurePublishClaims(text) { + const value = String(text ?? ""); + if (!value.includes("\u9875\u9762\u751F\u6210\u672A\u5B8C\u6210")) return value; + return value.replace(/(^|\n)([^\n]*页面都已经成功发布了[^\n]*)(?=\n|$)/g, "$1\u9875\u9762\u6682\u672A\u751F\u6210\u5B8C\u6210\uFF0C\u8FD9\u6B21\u6211\u5148\u4E0D\u53D1\u9001\u5931\u6548\u94FE\u63A5\u3002").replace(/(^|\n)([^\n]*主题页面已发布[^\n]*)(?=\n|$)/g, "$1\u{1F334} \u590F\u65E5\u4E3B\u9898\u9875\u9762\u751F\u6210\u672A\u5B8C\u6210").replace(/页面已创建完毕/g, "\u9875\u9762\u751F\u6210\u672A\u5B8C\u6210").replace(/页面已创建/g, "\u9875\u9762\u751F\u6210\u672A\u5B8C\u6210").replace(/发布成功/g, "\u751F\u6210\u672A\u5B8C\u6210").replace(/已发布成功/g, "\u751F\u6210\u672A\u5B8C\u6210"); +} +function buildHtmlPublishFailureText() { + return [ + "\u8FD9\u6B21\u9875\u9762\u6CA1\u6709\u6309 H5 \u91CC\u7684\u9875\u9762\u6280\u80FD\u771F\u6B63\u751F\u6210\u6210\u529F\uFF0C\u6240\u4EE5\u6211\u5148\u4E0D\u53D1\u4E0D\u4E00\u81F4\u7684\u7B80\u7248\u9875\u3002", + "\u8BF7\u76F4\u63A5\u91CD\u53D1\u4E00\u6B21\u4F60\u7684\u9875\u9762\u9700\u6C42\uFF0C\u6211\u4F1A\u6309\u548C H5 \u76F8\u540C\u7684 `static-page-publish` \u6280\u80FD\u94FE\u8DEF\u91CD\u505A\u3002" + ].join("\n"); +} +function artifactFileExists(artifact) { + const localPath = String(artifact?.localPath ?? "").trim(); + if (!localPath) return false; + try { + return fs25.existsSync(localPath) && fs25.statSync(localPath).isFile(); + } catch { + return false; + } +} +function uniqueArtifactsByUrl(artifacts = []) { + const byUrl = /* @__PURE__ */ new Map(); + for (const artifact of artifacts) { + const url = String(artifact?.url ?? "").trim(); + if (url) byUrl.set(url, artifact); + } + return [...byUrl.values()]; +} +function allExistingHtmlArtifacts({ publishedArtifacts = [], expectedArtifacts = [], recentArtifacts = [] } = {}) { + return uniqueArtifactsByUrl([ + ...publishedArtifacts, + ...expectedArtifacts, + ...recentArtifacts + ]).filter((artifact) => artifactFileExists(artifact)); +} +function buildVerifiedHtmlArtifacts({ + publishedArtifacts = [], + expectedArtifacts = [], + recentArtifacts = [], + replyText = "" +} = {}) { + const existing = allExistingHtmlArtifacts({ publishedArtifacts, expectedArtifacts, recentArtifacts }); + const linkedFilenames = collectPublicHtmlLinkFilenames(replyText); + if (linkedFilenames.size === 0) return existing; + const matched = existing.filter((artifact) => { + const filename = path27.posix.basename(String(artifact.relativePath ?? "").replace(/\\/g, "/")); + return filename && linkedFilenames.has(filename); + }); + return matched; +} +function resolveHtmlPublishArtifacts({ + reply, + intent, + workingDir, + publicBaseUrl, + requestStartedAt +}) { + materializeMissingPublicHtmlWrites({ + messages: reply?.messages ?? [], + publishDir: workingDir + }); + const publishedArtifacts = collectPublishedHtmlArtifacts(reply, { + workingDir, + publicBaseUrl + }); + const expectedArtifacts = collectExpectedHtmlArtifacts(intent, { + workingDir, + publicBaseUrl + }); + const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, { + workingDir, + publicBaseUrl, + replyText: reply?.text, + sinceMs: requestStartedAt + }); + const confirmedArtifacts = allExistingHtmlArtifacts({ + publishedArtifacts, + expectedArtifacts, + recentArtifacts + }); + const verifiedArtifacts = buildVerifiedHtmlArtifacts({ + publishedArtifacts, + expectedArtifacts, + recentArtifacts, + replyText: reply?.text + }); + return { + publishedArtifacts, + expectedArtifacts, + recentArtifacts, + confirmedArtifacts, + verifiedArtifacts + }; +} +function shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts = [], + hasValidLinkInReply = false +}) { + if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; + const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); + if (replyHasPublicLinks) { + if (!hasValidLinkInReply) return true; + if (isMissingRequiredPublishSkill(reply, intent)) return true; + return false; + } + if (confirmedArtifacts.length > 0) return false; + if (isMissingRequiredPublishSkill(reply, intent) || isSuspiciousBareCompletionReply(reply, intent)) { + return true; + } + if (!usedStaticPagePublishSkill(reply?.messages ?? [])) return true; + return !hasAnyUrl(reply?.text); +} function isQuestionStatusProbe(text) { return /^[??]+$/.test(String(text ?? "").trim()); } @@ -20755,6 +28830,15 @@ function normalizeWechatInboundIntent(inbound) { } function buildWechatAgentPrompt(intent) { const msgType = String(intent?.msgType ?? "text"); + const pagePublishHint = looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content) ? [ + "\u3010\u9875\u9762\u53D1\u5E03\u6280\u80FD\u8981\u6C42\u3011\u8FD9\u6761\u6D88\u606F\u662F\u5728\u751F\u6210\u53EF\u8BBF\u95EE HTML \u9875\u9762\u3002", + "\u5F00\u59CB\u524D\u5FC5\u987B\u5148\u8C03\u7528 `load_skill` \u2192 `static-page-publish`\uFF0C\u4E0D\u80FD\u7701\u7565\uFF0C\u4E5F\u4E0D\u80FD\u5199\u5B8C\u9875\u9762\u540E\u518D\u8865\u8C03\u3002", + "\u968F\u540E\u5FC5\u987B\u7528 sandbox-fs \u7684 `write_file` / `edit_file` \u5199\u5165 `public/*.html`\u3002", + "\u7981\u6B62\u7528 shell / cat / heredoc / echo / cp \u5199\u5165 HTML\uFF1Ashell \u5728\u5BB9\u5668\u5185\u6267\u884C\uFF0C\u6587\u4EF6\u4E0D\u4F1A\u51FA\u73B0\u5728\u516C\u7F51 MindSpace \u8DEF\u5F84\uFF0C\u7528\u6237\u4F1A\u6536\u5230\u300C\u6587\u4EF6\u4E0D\u5B58\u5728\u300D\u3002", + "\u5728\u56DE\u590D\u7528\u6237\u524D\uFF0C\u786E\u8BA4 `public/` \u4E0B\u76EE\u6807 HTML \u5DF2\u901A\u8FC7 write_file \u843D\u76D8\uFF1B\u6CA1\u6709\u843D\u76D8\u5C31\u4E0D\u8981\u53D1\u9001\u94FE\u63A5\u6216\u8BF4\u300C\u5DF2\u53D1\u5E03\u300D\u3002", + "\u6700\u7EC8\u53EA\u7ED9\u7528\u6237\u4E00\u4E2A\u6B63\u5F0F\u57DF\u540D\u7684\u552F\u4E00\u6B63\u786E\u94FE\u63A5\uFF1B\u4E0D\u8981\u8F93\u51FA\u9519\u8BEF\u57DF\u540D\u3001\u5907\u7528\u94FE\u63A5\u6216\u8BA9\u7528\u6237\u624B\u52A8\u4FDD\u5B58\u6587\u4EF6\u3002", + "" + ].join("\n") : ""; const scheduleAssistantHint = shouldUseScheduleAssistant(intent?.agentText ?? intent?.content) ? [ "\u3010\u65E5\u7A0B\u6280\u80FD\u8981\u6C42\u3011\u8FD9\u6761\u6D88\u606F\u6D89\u53CA\u5F85\u529E\u3001\u63D0\u9192\u6216\u65E5\u7A0B\u3002", "\u5F00\u59CB\u524D\u5148\u52A0\u8F7D `schedule-assistant` skill\uFF0C\u5E76\u4E25\u683C\u6309 skill \u91CC\u7684\u8FB9\u754C\u6267\u884C\u3002", @@ -20802,6 +28886,7 @@ function buildWechatAgentPrompt(intent) { } const content = String(intent?.agentText ?? intent?.content ?? "").trim(); const lines = []; + if (pagePublishHint) lines.push(pagePublishHint); if (scheduleAssistantHint) lines.push(scheduleAssistantHint); lines.push( "\u3010\u5FAE\u4FE1\u670D\u52A1\u53F7\u65B0\u6D88\u606F\u3011\u8BF7\u53EA\u56DE\u7B54\u4E0B\u9762\u8FD9\u6761\u7528\u6237\u6D88\u606F\uFF0C\u4E0D\u8981\u4E3B\u52A8\u5EF6\u7EED\u65E0\u5173\u7684\u5386\u53F2\u8BDD\u9898\u3002", @@ -20887,7 +28972,7 @@ function loadWechatMpConfig(env = process.env) { }; } function sha1Hex(parts) { - return crypto27.createHash("sha1").update([...parts].sort().join("")).digest("hex"); + return crypto28.createHash("sha1").update([...parts].sort().join("")).digest("hex"); } function verifyWechatMpSignature({ token, timestamp, nonce, signature }) { return sha1Hex([token, timestamp, nonce]) === String(signature ?? ""); @@ -20905,7 +28990,7 @@ function decodeWechatMpAesKey(encodingAesKey) { function decryptWechatMpPayload({ encodingAesKey, appId, encrypted }) { const key = decodeWechatMpAesKey(encodingAesKey); const iv = key.subarray(0, 16); - const decipher = crypto27.createDecipheriv("aes-256-cbc", key, iv); + const decipher = crypto28.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()]); @@ -20983,9 +29068,11 @@ function createWechatMpService({ config, userAuth: userAuth2, apiFetch: apiFetch2, + startAgentSession = null, sessionApiFetch = null, scheduleService: scheduleService2 = null, applySessionLlmProvider = null, + refreshSessionSnapshot = null, wechatFetch = undiciFetch7, linkExists = defaultPublicHtmlLinkExists, logger = console @@ -21022,8 +29109,8 @@ function createWechatMpService({ const fetchForSession = (sessionId, pathname, init) => sessionApiFetch ? sessionApiFetch(sessionId, pathname, init) : apiFetch2(pathname, init); const buildBindUrl = () => { if (/^https?:\/\//i.test(config.bindPath)) return config.bindPath; - const path23 = config.bindPath.startsWith("/") ? config.bindPath : `/${config.bindPath}`; - return `${config.publicBaseUrl}${path23}`; + const path29 = config.bindPath.startsWith("/") ? config.bindPath : `/${config.bindPath}`; + return `${config.publicBaseUrl}${path29}`; }; const verifyRequest = (query = {}) => verifyWechatMpSignature({ token: config.token, @@ -21088,9 +29175,9 @@ function createWechatMpService({ throw new Error("\u7B7E\u540D URL \u5FC5\u987B\u662F\u5B8C\u6574 http(s) \u5730\u5740"); } const ticket = await getJsapiTicket(); - const nonceStr = crypto27.randomBytes(12).toString("hex"); + const nonceStr = crypto28.randomBytes(12).toString("hex"); const timestamp = Math.floor(Date.now() / 1e3); - const signature = crypto27.createHash("sha1").update( + const signature = crypto28.createHash("sha1").update( [ `jsapi_ticket=${ticket}`, `noncestr=${nonceStr}`, @@ -21104,12 +29191,34 @@ function createWechatMpService({ nonceStr, signature, url: normalizedUrl, - jsApiList: ["startRecord", "stopRecord", "onVoiceRecordEnd", "translateVoice"] + jsApiList: [ + "startRecord", + "stopRecord", + "onVoiceRecordEnd", + "translateVoice", + "updateAppMessageShareData", + "updateTimelineShareData", + "onMenuShareAppMessage", + "onMenuShareTimeline" + ] }; }; - const sendCustomerServiceText = async (openid, content, user = null) => { + const sendCustomerServiceText = async (openid, content, user = null, { verifiedHtmlUrls = [], linkExistsForRequest = linkExists } = {}) => { const formatted = formatWechatOutboundText(content, user); - const guarded = await guardMissingPublicHtmlLinks(formatted, { linkExists }); + const verifiedUrlSet = new Set( + verifiedHtmlUrls.map((url) => String(url ?? "").trim()).filter(Boolean) + ); + const guarded = downgradePrematurePublishClaims( + await guardMissingPublicHtmlLinks(formatted, { + linkExists: async (url) => { + const normalized = String(url ?? "").trim(); + if (verifiedUrlSet.has(normalized) && !linkExistsForRequest(normalized)) { + verifiedUrlSet.delete(normalized); + } + return linkExistsForRequest(url); + } + }) + ); const chunks = splitWechatText(guarded); const accessToken = await getStableAccessToken(); for (const chunk of chunks) { @@ -21186,18 +29295,45 @@ function createWechatMpService({ if (forceNew) { await userAuth2.clearWechatAgentRoute(config.appId, openid); } + const workingDir = await userAuth2.resolveWorkingDir(userId); + const sessionPolicy = await userAuth2.getAgentSessionPolicy(userId); + const publishLayout = await userAuth2.getUserPublishLayout(userId); + const addressName = resolveWechatAddressName(userContext); const existingRoute = await userAuth2.getWechatAgentRoute(config.appId, openid); if (existingRoute?.agentSessionId) { - return existingRoute.agentSessionId; + await reconcileAgentSession( + (pathname, init) => fetchForSession(existingRoute.agentSessionId, pathname, init), + existingRoute.agentSessionId, + { + workingDir, + sessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + userContext: publishLayout ? { + userId, + displayName: addressName || publishLayout.displayName, + username: addressName || null, + slug: null + } : null, + tolerateInvalidWorkingDir: true + } + ); + const routeHasTools = await sessionHasRequiredTools( + fetchForSession, + existingRoute.agentSessionId, + sessionPolicy + ).catch(() => false); + if (routeHasTools) { + return existingRoute.agentSessionId; + } + await userAuth2.clearWechatAgentRoute(config.appId, openid); + } else if (existingRoute?.status === "disabled") { + await userAuth2.clearWechatAgentRoute(config.appId, openid); } const gate = await userAuth2.canUseChat(userId); if (!gate.ok) { throw new Error(gate.message || "\u5F53\u524D\u7528\u6237\u65E0\u6CD5\u4F7F\u7528\u804A\u5929\u80FD\u529B"); } - const workingDir = await userAuth2.resolveWorkingDir(userId); - const sessionPolicy = await userAuth2.getAgentSessionPolicy(userId); - const publishLayout = await userAuth2.getUserPublishLayout(userId); - const started = await readJsonResponse2( + const started = startAgentSession ? await startAgentSession({ userId, workingDir, sessionPolicy }) : await readJsonResponse2( await apiFetch2("/agent/start", { method: "POST", body: JSON.stringify({ @@ -21211,7 +29347,9 @@ function createWechatMpService({ if (!sessionId) { throw new Error("\u516C\u4F17\u53F7\u4E13\u5C5E Agent \u4F1A\u8BDD\u521B\u5EFA\u5931\u8D25"); } - const addressName = resolveWechatAddressName(userContext); + if (!startAgentSession && typeof userAuth2.registerAgentSession === "function") { + await userAuth2.registerAgentSession(userId, sessionId); + } await reconcileAgentSession( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, @@ -21236,6 +29374,14 @@ function createWechatMpService({ }); return sessionId; }; + const refreshWechatSessionSnapshot = async (sessionId, userId) => { + if (!sessionId || !userId || typeof refreshSessionSnapshot !== "function") return; + try { + await refreshSessionSnapshot(sessionId, userId); + } catch (err) { + logger.warn?.("WeChat MP snapshot refresh failed:", err); + } + }; const rememberWechatUserContext = async (sessionId, user) => { const addressName = resolveWechatAddressName(user); if (!addressName) return; @@ -21306,6 +29452,7 @@ function createWechatMpService({ userContext: user, forceNew }); + const publishLayout = await userAuth2.getUserPublishLayout(user.userId); await ensureSessionProvider(sessionId); await rememberWechatUserContext(sessionId, user); let finished = false; @@ -21319,7 +29466,7 @@ function createWechatMpService({ }, config.progressDelayMs); progressTimer.unref?.(); } - const requestId = crypto27.randomUUID(); + const requestId = crypto28.randomUUID(); const guardScheduleReply = async (replyText) => { if (!scheduleService2 || !looksLikeScheduleConfirmation(replyText)) return replyText; const sourceMessageId = String(intent.msgId ?? "").trim(); @@ -21341,6 +29488,7 @@ function createWechatMpService({ ].join("\n"); }; try { + const requestStartedAt = Date.now(); const reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, @@ -21348,14 +29496,54 @@ function createWechatMpService({ buildWechatAgentPrompt(intent), buildIntentMetadata(intent) ); + const workingDir = publishLayout?.publishDir ?? await userAuth2.resolveWorkingDir(user.userId); + const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists); + const { + publishedArtifacts, + verifiedArtifacts, + expectedArtifacts, + recentArtifacts, + confirmedArtifacts + } = resolveHtmlPublishArtifacts({ + reply, + intent, + workingDir, + publicBaseUrl: config.publicBaseUrl, + requestStartedAt + }); + const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { + confirmedArtifacts + }); + if (shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts, + hasValidLinkInReply + }) || expectedArtifacts.length === 0 && recentArtifacts.length === 0 && await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })) { + throw new Error("stale_session_poisoned_completion"); + } if (reply.tokenState) { await userAuth2.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId); } - await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user); + if (looksLikeHtmlGenerationIntent(intent?.agentText) && verifiedArtifacts.length === 0 && confirmedArtifacts.length === 0) { + throw new Error(buildHtmlPublishFailureText()); + } + const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { + workingDir, + publicBaseUrl: config.publicBaseUrl, + artifacts: verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts + }); + await refreshWechatSessionSnapshot(sessionId, user.userId); + await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { + verifiedHtmlUrls: (verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts).map( + (artifact) => artifact.url + ), + linkExistsForRequest + }); return { sessionId }; } catch (err) { const message = err instanceof Error ? err.message : String(err); - const mayBeStaleSession = /403|404|not found|无权访问|session/i.test(message) && sessionId; + const mayBeStaleSession = /403|404|not found|无权访问|session|stale_session_poisoned_completion/i.test(message) && sessionId; if (mayBeStaleSession) { sessionId = await ensureWechatAgentSession({ userId: user.userId, @@ -21365,7 +29553,8 @@ function createWechatMpService({ }); await ensureSessionProvider(sessionId); await rememberWechatUserContext(sessionId, user); - const retryId = crypto27.randomUUID(); + const retryId = crypto28.randomUUID(); + const retryStartedAt = Date.now(); const reply = await executeSessionReply( (pathname, init) => fetchForSession(sessionId, pathname, init), sessionId, @@ -21373,10 +29562,50 @@ function createWechatMpService({ buildWechatAgentPrompt(intent), buildIntentMetadata(intent) ); + const workingDir = publishLayout?.publishDir ?? await userAuth2.resolveWorkingDir(user.userId); + const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists); + const { + publishedArtifacts, + verifiedArtifacts, + expectedArtifacts, + recentArtifacts, + confirmedArtifacts + } = resolveHtmlPublishArtifacts({ + reply, + intent, + workingDir, + publicBaseUrl: config.publicBaseUrl, + requestStartedAt: retryStartedAt + }); + const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { + confirmedArtifacts + }); + if (shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts, + hasValidLinkInReply + }) || expectedArtifacts.length === 0 && recentArtifacts.length === 0 && await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest })) { + throw new Error(buildHtmlPublishFailureText()); + } if (reply.tokenState) { await userAuth2.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId); } - await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(reply.text), user); + if (looksLikeHtmlGenerationIntent(intent?.agentText) && verifiedArtifacts.length === 0 && confirmedArtifacts.length === 0) { + throw new Error(buildHtmlPublishFailureText()); + } + const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { + workingDir, + publicBaseUrl: config.publicBaseUrl, + artifacts: verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts + }); + await refreshWechatSessionSnapshot(sessionId, user.userId); + await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { + verifiedHtmlUrls: (verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts).map( + (artifact) => artifact.url + ), + linkExistsForRequest + }); return { sessionId }; } throw err; @@ -21452,7 +29681,7 @@ function createWechatMpService({ return { ok: false, status: 403, body: "invalid signature" }; } const inbound = parseWechatMessage(String(bodyText ?? "")); - const rawXmlHash = crypto27.createHash("sha1").update(inbound.rawXml || "").digest("hex"); + const rawXmlHash = crypto28.createHash("sha1").update(inbound.rawXml || "").digest("hex"); const intent = normalizeWechatInboundIntent(inbound); intent.appId = config.appId; if (!inbound.fromUserName || !inbound.toUserName || !inbound.msgType) { @@ -21845,18 +30074,36 @@ ${buildBindUrl()}` }; } +// wechat-share.mjs +function validateWechatShareSignatureUrl(pageUrl, { publicBaseUrl = "", requestHost = "" } = {}) { + const normalizedUrl = String(pageUrl ?? "").split("#")[0].trim(); + if (!normalizedUrl) { + throw Object.assign(new Error("\u7F3A\u5C11 url"), { code: "missing_url" }); + } + const target = new URL(normalizedUrl); + if (!/^https?:$/.test(target.protocol)) { + throw Object.assign(new Error("url \u5FC5\u987B\u662F http(s) \u5730\u5740"), { code: "invalid_url" }); + } + const publicHost = publicBaseUrl ? new URL(publicBaseUrl).host : null; + const allowedRequestHost = String(requestHost ?? "").split(",")[0].trim(); + if (publicHost && target.host !== publicHost && target.host !== allowedRequestHost) { + throw Object.assign(new Error("url \u4E0D\u5C5E\u4E8E\u5F53\u524D H5 \u57DF\u540D"), { code: "host_not_allowed" }); + } + return target.toString(); +} + // schedule-service.mjs -import crypto28 from "node:crypto"; +import crypto29 from "node:crypto"; // schedule-time.mjs -var DEFAULT_TIMEZONE = "Asia/Shanghai"; +var DEFAULT_TIMEZONE2 = "Asia/Shanghai"; function pad2(value) { return String(value).padStart(2, "0"); } function normalizeTimezone(timezone) { - return String(timezone || process.env.H5_DEFAULT_TIMEZONE || DEFAULT_TIMEZONE).trim() || DEFAULT_TIMEZONE; + return String(timezone || process.env.H5_DEFAULT_TIMEZONE || DEFAULT_TIMEZONE2).trim() || DEFAULT_TIMEZONE2; } -function getLocalParts(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { +function getLocalParts(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE2) { const formatter = new Intl.DateTimeFormat("en-CA", { timeZone: normalizeTimezone(timezone), year: "numeric", @@ -21879,11 +30126,11 @@ function getLocalParts(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { second: Number(parts.second) }; } -function localDateLabel(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { +function localDateLabel(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE2) { const parts = getLocalParts(epochMs, timezone); return `${parts.month}\u6708${parts.day}\u65E5`; } -function startOfLocalDay(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { +function startOfLocalDay(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE2) { const parts = getLocalParts(epochMs, timezone); return zonedTimeToEpochMs( { @@ -21897,7 +30144,7 @@ function startOfLocalDay(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) { timezone ); } -function addLocalDays(epochMs, days, timezone = DEFAULT_TIMEZONE) { +function addLocalDays(epochMs, days, timezone = DEFAULT_TIMEZONE2) { const parts = getLocalParts(epochMs, timezone); const utc = Date.UTC(parts.year, parts.month - 1, parts.day + Number(days || 0), parts.hour, parts.minute, parts.second); const shifted = new Date(utc); @@ -21913,7 +30160,7 @@ function addLocalDays(epochMs, days, timezone = DEFAULT_TIMEZONE) { timezone ); } -function zonedTimeToEpochMs(parts, timezone = DEFAULT_TIMEZONE) { +function zonedTimeToEpochMs(parts, timezone = DEFAULT_TIMEZONE2) { const tz = normalizeTimezone(timezone); let guess = Date.UTC( Number(parts.year), @@ -21950,7 +30197,7 @@ function zonedTimeToEpochMs(parts, timezone = DEFAULT_TIMEZONE) { } return guess; } -function nextDailyRunAt({ hour, minute = 0, timezone = DEFAULT_TIMEZONE, now = Date.now() }) { +function nextDailyRunAt({ hour, minute = 0, timezone = DEFAULT_TIMEZONE2, now = Date.now() }) { const tz = normalizeTimezone(timezone); const todayStart = startOfLocalDay(now, tz); const todayParts = getLocalParts(todayStart, tz); @@ -21982,13 +30229,13 @@ function nextDailyRunAt({ hour, minute = 0, timezone = DEFAULT_TIMEZONE, now = D } return runAt; } -function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE) { +function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE2) { const parts = getLocalParts(epochMs, timezone); return `${pad2(parts.hour)}:${pad2(parts.minute)}`; } // schedule-service.mjs -var DEFAULT_TIMEZONE2 = "Asia/Shanghai"; +var DEFAULT_TIMEZONE3 = "Asia/Shanghai"; function nowMs() { return Date.now(); } @@ -22005,7 +30252,7 @@ function rowToItem(row) { endAt: row.end_at == null ? null : Number(row.end_at), dueAt: row.due_at == null ? null : Number(row.due_at), allDay: Boolean(row.all_day), - timezone: row.timezone || DEFAULT_TIMEZONE2, + timezone: row.timezone || DEFAULT_TIMEZONE3, location: row.location ?? null, createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) @@ -22018,7 +30265,7 @@ function rowToDigest(row) { userId: row.user_id, hour: Number(row.hour), minute: Number(row.minute), - timezone: row.timezone || DEFAULT_TIMEZONE2, + timezone: row.timezone || DEFAULT_TIMEZONE3, channel: row.channel || "wechat", status: row.status, nextRunAt: Number(row.next_run_at), @@ -22092,7 +30339,7 @@ function rowToUserNotification(row) { updatedAt: Number(row.updated_at) }; } -function formatTodoDigest(items, { now = nowMs(), timezone = DEFAULT_TIMEZONE2 } = {}) { +function formatTodoDigest(items, { now = nowMs(), timezone = DEFAULT_TIMEZONE3 } = {}) { const date = localDateLabel(now, timezone); if (!items.length) { return `${date} \u5F85\u529E\u8BB0\u5F55 @@ -22106,7 +30353,7 @@ function formatTodoDigest(items, { now = nowMs(), timezone = DEFAULT_TIMEZONE2 } }); return lines.join("\n"); } -function createScheduleService(pool, options = {}) { +function createScheduleService(pool2, options = {}) { const defaultTimezone = normalizeTimezone(options.defaultTimezone || process.env.H5_DEFAULT_TIMEZONE); const clock = options.clock || { now: nowMs }; const createItem = async ({ @@ -22130,9 +30377,9 @@ function createScheduleService(pool, 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 = crypto28.randomUUID(); + const id = crypto29.randomUUID(); const ts = clock.now(); - await pool.query( + await pool2.query( `INSERT INTO h5_schedule_items (id, user_id, kind, title, description, status, start_at, end_at, due_at, all_day, timezone, location, source_channel, source_session_id, source_message_id, source_text, @@ -22193,7 +30440,7 @@ function createScheduleService(pool, options = {}) { params.push(from, to, from, to); } params.push(Math.max(1, Math.min(500, Number(limit) || 100))); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_schedule_items WHERE ${clauses.join(" AND ")} @@ -22207,7 +30454,7 @@ function createScheduleService(pool, options = {}) { if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); const cleanSourceMessageId = String(sourceMessageId ?? "").trim(); if (!cleanSourceMessageId) return []; - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_schedule_items WHERE user_id = ? @@ -22242,7 +30489,7 @@ function createScheduleService(pool, options = {}) { throw new Error("\u63D0\u9192\u504F\u79FB\u5206\u949F\u65E0\u6548"); } const safeChannel = channel === "in_app" ? "in_app" : "wechat"; - const [itemRows] = await pool.query( + const [itemRows] = await pool2.query( `SELECT id FROM h5_schedule_items WHERE id = ? AND user_id = ? AND deleted_at IS NULL @@ -22250,9 +30497,9 @@ function createScheduleService(pool, options = {}) { [itemId, userId] ); if (!itemRows[0]) throw new Error("\u4E8B\u9879\u4E0D\u5B58\u5728\u6216\u65E0\u6743\u8BBF\u95EE"); - const id = crypto28.randomUUID(); + const id = crypto29.randomUUID(); const ts = clock.now(); - await pool.query( + await pool2.query( `INSERT INTO h5_schedule_reminders (id, user_id, item_id, remind_at, offset_minutes, channel, status, attempts, last_error, locked_until, sent_at, created_at, updated_at) @@ -22265,7 +30512,7 @@ function createScheduleService(pool, options = {}) { updated_at = VALUES(updated_at)`, [id, userId, itemId, safeRemindAt, safeOffsetMinutes, safeChannel, ts, ts] ); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_schedule_reminders WHERE item_id = ? AND remind_at = ? AND channel = ? @@ -22308,7 +30555,7 @@ function createScheduleService(pool, options = {}) { params.push(...statuses); } params.push(Math.max(1, Math.min(200, Number(limit) || 20))); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_schedule_digest_subscriptions WHERE ${clauses.join(" AND ")} @@ -22345,9 +30592,9 @@ function createScheduleService(pool, options = {}) { timezone: tz, now: clock.now() }); - const id = crypto28.randomUUID(); + const id = crypto29.randomUUID(); const ts = clock.now(); - await pool.query( + await pool2.query( `INSERT INTO h5_schedule_digest_subscriptions (id, user_id, digest_type, hour, minute, timezone, channel, status, next_run_at, source_channel, source_session_id, source_message_id, source_text, created_at, updated_at) @@ -22380,7 +30627,7 @@ function createScheduleService(pool, options = {}) { ts ] ); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_schedule_digest_subscriptions WHERE user_id = ? AND digest_type = 'todo_day' AND channel = ? LIMIT 1`, @@ -22414,8 +30661,8 @@ function createScheduleService(pool, options = {}) { throw new Error("\u4F59\u989D\u9608\u503C\u65E0\u6548"); } const now = clock.now(); - const id = crypto28.randomUUID(); - await pool.query( + const id = crypto29.randomUUID(); + await pool2.query( `INSERT INTO h5_balance_alert_subscriptions (id, user_id, threshold_cents, channel, status, next_run_at, last_run_at, last_notified_balance_cents, attempts, locked_until, last_error, @@ -22431,7 +30678,7 @@ function createScheduleService(pool, options = {}) { updated_at = VALUES(updated_at)`, [id, userId, safeThreshold, channel, now, sourceChannel, sourceSessionId, sourceMessageId, sourceText, now, now] ); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_balance_alert_subscriptions WHERE user_id = ? AND channel = ? LIMIT 1`, [userId, channel] ); @@ -22448,7 +30695,7 @@ function createScheduleService(pool, options = {}) { }; }; const listDueBalanceAlerts = async ({ now = clock.now(), limit = 50 } = {}) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_balance_alert_subscriptions WHERE next_run_at <= ? @@ -22461,21 +30708,21 @@ function createScheduleService(pool, options = {}) { }; const lockBalanceAlert = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => { const lockedUntil = now + lockMs; - const [result] = await pool.query( + const [result] = await pool2.query( `UPDATE h5_balance_alert_subscriptions SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ? WHERE id = ? AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))`, [lockedUntil, now, id, now] ); if (Number(result?.affectedRows ?? 0) !== 1) return null; - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_balance_alert_subscriptions WHERE id = ? LIMIT 1`, [id] ); return rowToBalanceAlert(rows[0]); }; const markBalanceAlertSent = async (subscription, { now = clock.now() } = {}) => { - await pool.query( + await pool2.query( `UPDATE h5_balance_alert_subscriptions SET status = 'active', next_run_at = ?, last_run_at = ?, last_notified_balance_cents = ?, locked_until = NULL, last_error = NULL, updated_at = ? @@ -22488,7 +30735,7 @@ function createScheduleService(pool, options = {}) { const attempts = Number(subscription.attempts ?? 0); const status = attempts >= maxAttempts ? "failed" : "active"; const nextRunAt = status === "active" ? now + retryMs : subscription.nextRunAt; - await pool.query( + await pool2.query( `UPDATE h5_balance_alert_subscriptions SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ? WHERE id = ?`, @@ -22496,7 +30743,7 @@ function createScheduleService(pool, options = {}) { ); }; const listDueDigestSubscriptions = async ({ now = clock.now(), limit = 50 } = {}) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_schedule_digest_subscriptions WHERE next_run_at <= ? @@ -22509,14 +30756,14 @@ function createScheduleService(pool, options = {}) { }; const lockDigestSubscription = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => { const lockedUntil = now + lockMs; - const [result] = await pool.query( + const [result] = await pool2.query( `UPDATE h5_schedule_digest_subscriptions SET status = 'locked', locked_until = ?, attempts = attempts + 1, updated_at = ? WHERE id = ? AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))`, [lockedUntil, now, id, now] ); if (Number(result?.affectedRows ?? 0) !== 1) return null; - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_schedule_digest_subscriptions WHERE id = ? LIMIT 1`, [id] ); @@ -22529,7 +30776,7 @@ function createScheduleService(pool, options = {}) { timezone: subscription.timezone, now: now + 1e3 }); - await pool.query( + await pool2.query( `UPDATE h5_schedule_digest_subscriptions SET status = 'active', next_run_at = ?, last_run_at = ?, locked_until = NULL, last_error = NULL, updated_at = ? @@ -22542,7 +30789,7 @@ function createScheduleService(pool, options = {}) { const attempts = Number(subscription.attempts ?? 0); const status = attempts >= maxAttempts ? "failed" : "active"; const nextRunAt = status === "active" ? now + retryMs : subscription.nextRunAt; - await pool.query( + await pool2.query( `UPDATE h5_schedule_digest_subscriptions SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ? WHERE id = ?`, @@ -22550,13 +30797,13 @@ function createScheduleService(pool, options = {}) { ); }; const logDelivery = async ({ subscriptionId, userId, channel = "wechat", status, providerMessageId = null, errorCode = null, errorMessage = null }) => { - await pool.query( + await pool2.query( `INSERT INTO h5_schedule_delivery_logs (id, reminder_id, subscription_id, user_id, channel, status, provider_message_id, error_code, error_message, created_at) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)`, [ - crypto28.randomUUID(), + crypto29.randomUUID(), subscriptionId, userId, channel, @@ -22574,7 +30821,7 @@ function createScheduleService(pool, options = {}) { }; const getUserWalletSnapshot = async (userId) => { if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT balance_cents AS balanceCents, tokens_used AS tokensUsed FROM h5_user_wallets WHERE user_id = ? @@ -22596,8 +30843,8 @@ function createScheduleService(pool, 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 = crypto28.randomUUID(); - await pool.query( + const id = crypto29.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) VALUES (?, ?, ?, ?, ?, ?, ?, 'unread', NULL, ?, ?)`, @@ -22614,7 +30861,7 @@ function createScheduleService(pool, options = {}) { params.push(status); } params.push(Math.max(1, Math.min(100, Number(limit) || 20))); - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT * FROM h5_user_notifications WHERE ${clauses.join(" AND ")} @@ -22627,7 +30874,7 @@ function createScheduleService(pool, options = {}) { const markUserNotificationRead = async ({ userId, notificationId }) => { if (!userId || !notificationId) throw new Error("\u7F3A\u5C11\u901A\u77E5\u53C2\u6570"); const now = clock.now(); - const [result] = await pool.query( + const [result] = await pool2.query( `UPDATE h5_user_notifications SET status = 'read', read_at = ?, updated_at = ? WHERE id = ? AND user_id = ? AND status <> 'read'`, @@ -22638,7 +30885,7 @@ function createScheduleService(pool, options = {}) { const markAllUserNotificationsRead = async ({ userId } = {}) => { if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237"); const now = clock.now(); - const [result] = await pool.query( + const [result] = await pool2.query( `UPDATE h5_user_notifications SET status = 'read', read_at = ?, updated_at = ? WHERE user_id = ? AND status <> 'read'`, @@ -22648,7 +30895,7 @@ function createScheduleService(pool, options = {}) { }; const deleteUserNotification = async ({ userId, notificationId } = {}) => { if (!userId || !notificationId) throw new Error("\u7F3A\u5C11\u901A\u77E5\u53C2\u6570"); - const [result] = await pool.query( + const [result] = await pool2.query( `DELETE FROM h5_user_notifications WHERE id = ? AND user_id = ?`, [notificationId, userId] @@ -22663,7 +30910,7 @@ function createScheduleService(pool, options = {}) { clauses.push("status = ?"); params.push(status); } - const [result] = await pool.query( + const [result] = await pool2.query( `DELETE FROM h5_user_notifications WHERE ${clauses.join(" AND ")}`, params @@ -22699,6 +30946,218 @@ function createScheduleService(pool, options = {}) { }; } +// user-feedback.mjs +import crypto30 from "node:crypto"; +var FEEDBACK_TYPES = /* @__PURE__ */ new Set(["bug", "feature", "other"]); +var FEEDBACK_MAX_IMAGES = 5; +var FEEDBACK_MAX_TITLE = 120; +var FEEDBACK_MAX_DESCRIPTION = 5e3; +var FEEDBACK_MAX_CONTACT = 120; +var FEEDBACK_MAX_IMAGE_BYTES = Math.floor(1.5 * 1024 * 1024); +function feedbackError(message, code = "invalid_input") { + const err = new Error(message); + err.code = code; + return err; +} +function normalizeType(value) { + const type = String(value ?? "").trim().toLowerCase(); + if (!FEEDBACK_TYPES.has(type)) { + throw feedbackError("\u8BF7\u9009\u62E9\u53CD\u9988\u7C7B\u578B"); + } + return type; +} +function normalizeImages(images) { + if (!Array.isArray(images)) return []; + if (images.length > FEEDBACK_MAX_IMAGES) { + throw feedbackError(`\u6700\u591A\u4E0A\u4F20 ${FEEDBACK_MAX_IMAGES} \u5F20\u56FE\u7247`); + } + return images.map((item, index) => { + const mimeType = String(item?.mimeType ?? item?.mime_type ?? "").trim().toLowerCase(); + const dataBase64 = String(item?.dataBase64 ?? item?.data_base64 ?? "").trim(); + const filename = String(item?.filename ?? item?.name ?? `screenshot-${index + 1}.jpg`).trim(); + if (!mimeType.startsWith("image/")) { + throw feedbackError("\u4EC5\u652F\u6301\u56FE\u7247\u9644\u4EF6"); + } + if (!dataBase64) { + throw feedbackError("\u56FE\u7247\u6570\u636E\u65E0\u6548"); + } + let buffer; + try { + buffer = Buffer.from(dataBase64, "base64"); + } catch { + throw feedbackError("\u56FE\u7247\u6570\u636E\u65E0\u6548"); + } + if (!buffer.length) { + throw feedbackError("\u56FE\u7247\u6570\u636E\u65E0\u6548"); + } + if (buffer.length > FEEDBACK_MAX_IMAGE_BYTES) { + throw feedbackError("\u5355\u5F20\u56FE\u7247\u4E0D\u80FD\u8D85\u8FC7 1.5MB"); + } + return { + filename: filename.slice(0, 120), + mimeType, + sizeBytes: buffer.length, + dataBase64 + }; + }); +} +function normalizeContext(context) { + if (!context || typeof context !== "object") return {}; + const pageUrl = String(context.pageUrl ?? context.page_url ?? "").trim().slice(0, 500); + const pagePath = String(context.pagePath ?? context.page_path ?? "").trim().slice(0, 300); + const userAgent = String(context.userAgent ?? context.user_agent ?? "").trim().slice(0, 500); + const viewport = String(context.viewport ?? "").trim().slice(0, 64); + const sessionId = String(context.sessionId ?? context.session_id ?? "").trim().slice(0, 128); + const appVersion = String(context.appVersion ?? context.app_version ?? "").trim().slice(0, 64); + const normalized = {}; + if (pageUrl) normalized.pageUrl = pageUrl; + if (pagePath) normalized.pagePath = pagePath; + if (userAgent) normalized.userAgent = userAgent; + if (viewport) normalized.viewport = viewport; + if (sessionId) normalized.sessionId = sessionId; + if (appVersion) normalized.appVersion = appVersion; + return normalized; +} +function rowToFeedback(row, { includeImages = true, includeContact = true } = {}) { + if (!row) return null; + let images = []; + let context = {}; + try { + images = row.images_json ? JSON.parse(row.images_json) : []; + } catch { + images = []; + } + try { + context = row.context_json ? JSON.parse(row.context_json) : {}; + } catch { + context = {}; + } + const item = { + id: row.id, + userId: row.user_id, + type: row.type, + title: row.title, + description: row.description, + status: row.status, + context, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + submitterDisplayName: row.submitter_display_name ?? null, + imageCount: images.length + }; + if (includeContact) { + item.contact = row.contact ?? null; + } + if (includeImages) { + item.images = images; + } + return item; +} +function createFeedbackService(pool2) { + return { + async submit(userId, input = {}) { + const type = normalizeType(input.type); + const title = String(input.title ?? "").trim(); + const description = String(input.description ?? "").trim(); + const contact = String(input.contact ?? "").trim().slice(0, FEEDBACK_MAX_CONTACT); + if (!title) throw feedbackError("\u8BF7\u586B\u5199\u6807\u9898"); + if (title.length > FEEDBACK_MAX_TITLE) throw feedbackError(`\u6807\u9898\u4E0D\u80FD\u8D85\u8FC7 ${FEEDBACK_MAX_TITLE} \u5B57`); + if (!description) throw feedbackError("\u8BF7\u586B\u5199\u8BE6\u7EC6\u63CF\u8FF0"); + if (description.length > FEEDBACK_MAX_DESCRIPTION) { + throw feedbackError(`\u63CF\u8FF0\u4E0D\u80FD\u8D85\u8FC7 ${FEEDBACK_MAX_DESCRIPTION} \u5B57`); + } + const images = normalizeImages(input.images); + const context = normalizeContext(input.context); + const now = Date.now(); + const id = crypto30.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) + VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)`, + [ + id, + userId, + type, + title, + description, + contact || null, + JSON.stringify(images), + JSON.stringify(context), + now, + now + ] + ); + return { + id, + type, + title, + status: "pending", + createdAt: now + }; + }, + async listForUser(userId, { limit = 20 } = {}) { + const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 50); + const [rows] = await pool2.query( + `SELECT f.id, f.user_id, f.type, f.title, f.description, f.contact, f.status, + f.images_json, f.context_json, f.created_at, f.updated_at, + u.display_name AS submitter_display_name + FROM h5_user_feedback f + JOIN h5_users u ON u.id = f.user_id + WHERE f.user_id = ? + ORDER BY f.created_at DESC + LIMIT ?`, + [userId, safeLimit] + ); + return rows.map((row) => rowToFeedback(row)); + }, + async listAll({ page = 1, limit = 10 } = {}) { + const safeLimit = Math.min(Math.max(Number(limit) || 10, 1), 10); + const safePage = Math.max(Number(page) || 1, 1); + const offset = (safePage - 1) * safeLimit; + const [[countRow]] = await pool2.query(`SELECT COUNT(*) AS total FROM h5_user_feedback`); + const total = Number(countRow?.total ?? 0); + const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit); + const [rows] = await pool2.query( + `SELECT f.id, f.user_id, f.type, f.title, f.description, f.status, + f.images_json, f.context_json, f.created_at, f.updated_at, + u.display_name AS submitter_display_name + FROM h5_user_feedback f + JOIN h5_users u ON u.id = f.user_id + ORDER BY f.created_at DESC + LIMIT ? OFFSET ?`, + [safeLimit, offset] + ); + return { + items: rows.map( + (row) => rowToFeedback(row, { includeImages: false, includeContact: false }) + ), + page: safePage, + pageSize: safeLimit, + total, + totalPages + }; + }, + async getById(feedbackId, viewerUserId) { + const id = String(feedbackId ?? "").trim(); + if (!id) throw feedbackError("\u53CD\u9988\u4E0D\u5B58\u5728", "feedback_not_found"); + const [rows] = await pool2.query( + `SELECT f.id, f.user_id, f.type, f.title, f.description, f.contact, f.status, + f.images_json, f.context_json, f.created_at, f.updated_at, + u.display_name AS submitter_display_name + FROM h5_user_feedback f + JOIN h5_users u ON u.id = f.user_id + WHERE f.id = ? + LIMIT 1`, + [id] + ); + const row = rows[0]; + if (!row) throw feedbackError("\u53CD\u9988\u4E0D\u5B58\u5728", "feedback_not_found"); + const isOwner = viewerUserId && row.user_id === viewerUserId; + return rowToFeedback(row, { includeImages: true, includeContact: isOwner }); + } + }; +} + // schedule-reminder-worker.mjs function startScheduleReminderWorker({ scheduleService: scheduleService2, @@ -22831,31 +31290,81 @@ function startScheduleReminderWorker({ } // session-snapshot.mjs -function createSessionSnapshotService(pool) { +var DERIVED_TITLE_MAX_LEN = 40; +function isGeneratedSessionName(name) { + return /^20\d{6}(?:[_-]\d+)?$/.test(String(name ?? "").trim()); +} +function isDefaultSessionName(name) { + const trimmed = String(name ?? "").trim(); + return !trimmed || trimmed === "New Chat" || trimmed === "\u65B0\u5BF9\u8BDD" || isGeneratedSessionName(trimmed); +} +function messageSnippet(message) { + const fromContent = (message?.content ?? []).filter((item) => item?.type === "text" && typeof item.text === "string").map((item) => item.text).join("").trim(); + if (fromContent) return fromContent; + const displayText = message?.metadata?.displayText; + return typeof displayText === "string" ? displayText.trim() : ""; +} +function deriveTitleFromMessages(messages) { + if (!Array.isArray(messages)) return ""; + const firstUser = messages.find((m) => m?.role === "user" && messageSnippet(m)); + const text = (firstUser ? messageSnippet(firstUser) : "").replace(/\s+/g, " ").trim(); + if (!text) return ""; + return text.length > DERIVED_TITLE_MAX_LEN ? `${text.slice(0, DERIVED_TITLE_MAX_LEN)}\u2026` : text; +} +function resolveDisplayTitle(session, messages) { + if (session?.user_set_name) { + const explicit = String(session.name ?? "").trim(); + if (explicit) return explicit; + } + const recipeTitle = String(session?.recipe?.title ?? "").trim(); + if (recipeTitle) return recipeTitle; + const sessionName = String(session?.name ?? "").trim(); + if (sessionName && !isDefaultSessionName(sessionName)) return sessionName; + return deriveTitleFromMessages(messages); +} +function createSessionSnapshotService(pool2, options = {}) { + const conversationMemoryService2 = options.conversationMemoryService ?? null; function isEnabled() { return process.env.SESSION_SNAPSHOT_CACHE_ENABLED !== "0"; } + async function persistDisplayTitle(sessionId, title) { + const normalized = String(title ?? "").trim(); + if (!normalized || !pool2) return; + try { + await pool2.query( + `UPDATE h5_session_snapshots + SET display_title = ? + WHERE agent_session_id = ? + AND (display_title IS NULL OR display_title = '')`, + [normalized, sessionId] + ); + } catch (err) { + console.warn("[snapshot] persistDisplayTitle failed:", err instanceof Error ? err.message : err); + } + } async function save(sessionId, userId, session, messages) { - if (!isEnabled() || !pool) return; + if (!isEnabled() || !pool2) return; try { const now = Date.now(); const sessionMeta = { name: session.name ?? "", + display_title: resolveDisplayTitle(session, messages), working_dir: session.working_dir ?? "", created_at_str: session.created_at ?? "", updated_at_str: session.updated_at ?? "", user_set_name: session.user_set_name ? 1 : 0, recipe_json: session.recipe != null ? JSON.stringify(session.recipe) : null }; - await pool.query( + await pool2.query( `INSERT INTO h5_session_snapshots - (agent_session_id, user_id, name, working_dir, + (agent_session_id, user_id, name, display_title, working_dir, created_at_str, updated_at_str, user_set_name, recipe_json, synced_msg_count, source_updated_at, messages_json, synced_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), + display_title = VALUES(display_title), working_dir = VALUES(working_dir), created_at_str = VALUES(created_at_str), updated_at_str = VALUES(updated_at_str), @@ -22869,6 +31378,7 @@ function createSessionSnapshotService(pool) { sessionId, userId, sessionMeta.name, + sessionMeta.display_title, sessionMeta.working_dir, sessionMeta.created_at_str, sessionMeta.updated_at_str, @@ -22880,15 +31390,23 @@ function createSessionSnapshotService(pool) { now ] ); + if (conversationMemoryService2?.isEnabled?.()) { + void conversationMemoryService2.saveAndAnalyze(sessionId, userId, messages).catch((err) => { + console.warn( + "[snapshot] conversation memory sync failed:", + err instanceof Error ? err.message : err + ); + }); + } } catch (err) { console.warn("[snapshot] save failed:", err instanceof Error ? err.message : err); } } async function get(sessionId) { - if (!isEnabled() || !pool) return null; + if (!isEnabled() || !pool2) return null; try { - const [rows] = await pool.query( - `SELECT agent_session_id, user_id, name, working_dir, + const [rows] = await pool2.query( + `SELECT agent_session_id, user_id, name, display_title, working_dir, created_at_str, updated_at_str, user_set_name, recipe_json, synced_msg_count, source_updated_at, messages_json, synced_at FROM h5_session_snapshots @@ -22898,10 +31416,24 @@ function createSessionSnapshotService(pool) { ); if (!rows.length) return null; const row = rows[0]; + const parsedMessages = JSON.parse(row.messages_json); + const storedDisplayTitle = String(row.display_title ?? "").trim(); + const rawName = String(row.name ?? "").trim(); + const fallbackDisplayTitle = storedDisplayTitle || resolveDisplayTitle( + { + name: row.name, + recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null, + user_set_name: row.user_set_name === 1 + }, + parsedMessages + ); + if (!storedDisplayTitle && fallbackDisplayTitle) { + void persistDisplayTitle(sessionId, fallbackDisplayTitle); + } return { session: { id: sessionId, - name: row.name, + name: fallbackDisplayTitle || rawName, working_dir: row.working_dir, message_count: row.synced_msg_count, created_at: row.created_at_str || void 0, @@ -22910,7 +31442,7 @@ function createSessionSnapshotService(pool) { recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null, conversation: null }, - messages: JSON.parse(row.messages_json), + messages: parsedMessages, meta: { synced_msg_count: row.synced_msg_count, source_updated_at: row.source_updated_at, @@ -22923,9 +31455,9 @@ function createSessionSnapshotService(pool) { } } async function remove(sessionId) { - if (!pool) return; + if (!pool2) return; try { - await pool.query( + await pool2.query( `DELETE FROM h5_session_snapshots WHERE agent_session_id = ?`, [sessionId] ); @@ -22934,7 +31466,7 @@ function createSessionSnapshotService(pool) { } } async function refresh(sessionId, userId, apiFetchFn) { - if (!isEnabled() || !pool) return; + if (!isEnabled() || !pool2) return; try { const res = await apiFetchFn(`/sessions/${encodeURIComponent(sessionId)}`, { method: "GET" @@ -22947,7 +31479,549 @@ function createSessionSnapshotService(pool) { console.warn("[snapshot] refresh failed:", sessionId, err instanceof Error ? err.message : err); } } - return { save, get, remove, refresh, isEnabled }; + async function getDerivedTitles(sessionIds) { + if (!isEnabled() || !pool2) return /* @__PURE__ */ new Map(); + const ids = [...new Set((sessionIds ?? []).filter(Boolean))]; + if (ids.length === 0) return /* @__PURE__ */ new Map(); + const titles = /* @__PURE__ */ new Map(); + try { + const placeholders = ids.map(() => "?").join(", "); + const [rows] = await pool2.query( + `SELECT agent_session_id, display_title, name, recipe_json, user_set_name, messages_json + FROM h5_session_snapshots + WHERE agent_session_id IN (${placeholders})`, + ids + ); + for (const row of rows) { + try { + const parsedMessages = JSON.parse(row.messages_json); + const title = String(row.display_title ?? "").trim() || resolveDisplayTitle( + { + name: row.name, + recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null, + user_set_name: row.user_set_name === 1 + }, + parsedMessages + ); + if (title) { + titles.set(row.agent_session_id, title); + if (!String(row.display_title ?? "").trim()) { + void persistDisplayTitle(row.agent_session_id, title); + } + } + } catch { + } + } + } catch (err) { + console.warn("[snapshot] getDerivedTitles failed:", err instanceof Error ? err.message : err); + } + return titles; + } + async function getHistorySummaries(sessionIds) { + if (!isEnabled() || !pool2) return /* @__PURE__ */ new Map(); + const ids = [...new Set((sessionIds ?? []).filter(Boolean))]; + if (ids.length === 0) return /* @__PURE__ */ new Map(); + const summaries = /* @__PURE__ */ new Map(); + try { + const placeholders = ids.map(() => "?").join(", "); + const [rows] = await pool2.query( + `SELECT agent_session_id, display_title, name, recipe_json, user_set_name, + synced_msg_count, messages_json + FROM h5_session_snapshots + WHERE agent_session_id IN (${placeholders})`, + ids + ); + for (const row of rows) { + try { + const parsedMessages = JSON.parse(row.messages_json); + const title = String(row.display_title ?? "").trim() || resolveDisplayTitle( + { + name: row.name, + recipe: row.recipe_json ? JSON.parse(row.recipe_json) : null, + user_set_name: row.user_set_name === 1 + }, + parsedMessages + ); + const messageCount = Number(row.synced_msg_count ?? 0); + summaries.set(row.agent_session_id, { + title, + messageCount + }); + if (title && !String(row.display_title ?? "").trim()) { + void persistDisplayTitle(row.agent_session_id, title); + } + } catch { + } + } + } catch (err) { + console.warn("[snapshot] getHistorySummaries failed:", err instanceof Error ? err.message : err); + } + return summaries; + } + return { save, get, remove, refresh, getDerivedTitles, getHistorySummaries, isEnabled }; +} + +// conversation-memory.mjs +import crypto31 from "node:crypto"; +import { fetch as undiciFetch8 } from "undici"; +import { jsonrepair as jsonrepair3 } from "jsonrepair"; +var MAX_MESSAGE_TEXT = 12e3; +var MAX_ANALYSIS_MESSAGES = 8; +var MAX_MEMORY_TEXT = 1e3; +var LABELS = /* @__PURE__ */ new Set([ + "preference", + "habit", + "interest", + "goal", + "fact", + "experience", + "knowledge" +]); +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"); +} +function parseJsonObject3(text) { + const source = String(text ?? "").trim(); + if (!source) return null; + try { + return JSON.parse(source); + } catch { + try { + return JSON.parse(jsonrepair3(source)); + } catch { + return null; + } + } +} +function extractJsonObject3(text) { + const direct = parseJsonObject3(text); + if (direct && typeof direct === "object") return direct; + const source = String(text ?? "").trim(); + const start = source.indexOf("{"); + const end = source.lastIndexOf("}"); + if (start < 0 || end <= start) return null; + return parseJsonObject3(source.slice(start, end + 1)); +} +function extractConversationMessageText(message) { + const content = Array.isArray(message?.content) ? message.content : []; + const chunks = []; + for (const item of content) { + if (item?.type === "text" && typeof item.text === "string") chunks.push(item.text); + if (item?.type === "image" && item.url) chunks.push(`[image] ${item.url}`); + } + const fromContent = chunks.join("\n").trim(); + if (fromContent) return fromContent.slice(0, MAX_MESSAGE_TEXT); + const displayText = message?.metadata?.displayText; + return typeof displayText === "string" ? displayText.trim().slice(0, MAX_MESSAGE_TEXT) : ""; +} +function normalizeMessage(sessionId, userId, message, index, now) { + const role = (String(message?.role ?? "").trim() || "unknown").toLowerCase().slice(0, 32); + const text = extractConversationMessageText(message); + if (!text) return null; + const rawMessageId = String(message?.id ?? message?.message_id ?? "").trim(); + const messageKey = (rawMessageId || `idx:${index}:${stableId(role, text).slice(0, 16)}`).slice(0, 128); + return { + id: stableId(userId, sessionId, messageKey), + userId, + sessionId, + messageKey, + sequenceNo: index, + role, + text, + rawJson: JSON.stringify(message), + createdAt: Number(message?.created_at ?? message?.createdAt ?? now) || now, + updatedAt: now + }; +} +function buildMemoryPrompt(messages) { + const transcript = messages.map((message, index) => { + const text = String(message.text ?? "").replace(/\s+/g, " ").trim().slice(0, 1500); + return `${index + 1}. [${message.role}] ${text}`; + }).join("\n"); + return [ + "\u4F60\u662F TKMind \u7684\u7528\u6237\u957F\u671F\u8BB0\u5FC6\u63D0\u53D6\u5668\u3002", + "\u8BF7\u4ECE\u4E0B\u9762\u7684\u7528\u6237\u5BF9\u8BDD\u4E2D\u63D0\u53D6\u53EF\u4EE5\u957F\u671F\u590D\u7528\u7684\u4E2A\u4EBA\u8BB0\u5FC6\u3002", + "\u53EA\u8BB0\u5F55\u7528\u6237\u660E\u786E\u8868\u8FBE\u6216\u5F3A\u8BC1\u636E\u652F\u6301\u7684\u4FE1\u606F\uFF0C\u4E0D\u8981\u731C\u6D4B\uFF0C\u4E0D\u8981\u8BB0\u5F55\u4E34\u65F6\u95F2\u804A\u3002", + "\u4E0D\u8981\u8BB0\u5F55\u5BC6\u7801\u3001\u5BC6\u94A5\u3001\u8EAB\u4EFD\u8BC1\u3001\u624B\u673A\u53F7\u3001\u94F6\u884C\u5361\u7B49\u654F\u611F\u4FE1\u606F\u3002", + "\u53EA\u8FD4\u56DE JSON \u5BF9\u8C61\uFF0C\u4E0D\u8981 Markdown\uFF0C\u4E0D\u8981\u89E3\u91CA\u3002", + '\u683C\u5F0F\uFF1A{"memories":[{"label":"preference|habit|interest|goal|fact|experience|knowledge","text":"\u4E00\u53E5\u5B8C\u6574\u4E2D\u6587\u8BB0\u5FC6","confidence":0.0-1.0}]}', + '\u6700\u591A\u8FD4\u56DE 5 \u6761\u3002\u6CA1\u6709\u503C\u5F97\u8BB0\u5F55\u7684\u5185\u5BB9\u65F6\u8FD4\u56DE {"memories":[]}', + "", + transcript + ].join("\n"); +} +function normalizeMemoryItem(item, fallbackLabel = "fact") { + const text = String(item?.text ?? item?.memory ?? item?.content ?? "").trim(); + if (!text) return null; + const labelRaw = String(item?.label ?? item?.category ?? fallbackLabel).trim().toLowerCase(); + const label = LABELS.has(labelRaw) ? labelRaw : fallbackLabel; + const confidence = Math.max(0, Math.min(1, Number(item?.confidence ?? 0.6))); + return { + label, + text: text.slice(0, MAX_MEMORY_TEXT), + confidence: Number.isFinite(confidence) ? confidence : 0.6 + }; +} +function fallbackMemoriesFromMessages(messages) { + const memories = []; + const patterns = [ + { label: "preference", re: /(?:我喜欢|我偏好|我更喜欢|以后.*(?:用|叫|按)|不要再|别再)(.+)/ }, + { label: "interest", re: /(?:我对|我关注|我感兴趣|我想了解)(.+)/ }, + { label: "goal", re: /(?:我的目标是|我想要|我希望|我打算)(.+)/ }, + { label: "habit", re: /(?:我通常|我习惯|我一般)(.+)/ } + ]; + for (const message of messages) { + const text = String(message.text ?? "").replace(/\s+/g, " ").trim(); + for (const { label, re } of patterns) { + const match = text.match(re); + if (!match?.[0]) continue; + memories.push({ + label, + text: `\u7528\u6237${match[0].replace(/[。!?\s]+$/g, "")}`, + confidence: 0.45 + }); + if (memories.length >= 3) return memories; + } + } + return memories; +} +function createConversationMemoryService(pool2, options = {}) { + const now = options.now ?? (() => Date.now()); + const fetchImpl = options.fetch ?? undiciFetch8; + const encryptionKey = options.encryptionKey; + function isEnabled() { + return process.env.USER_CONVERSATION_MEMORY_ENABLED !== "0"; + } + function llmEnabled() { + return process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED !== "0"; + } + async function saveConversationMessages(sessionId, userId, messages = []) { + if (!isEnabled() || !pool2 || !sessionId || !userId || !Array.isArray(messages)) return []; + const ts = now(); + const normalized = messages.map((message, index) => normalizeMessage(sessionId, userId, message, index, ts)).filter(Boolean); + if (!normalized.length) return []; + await pool2.query( + `INSERT INTO h5_conversation_messages + (id, user_id, agent_session_id, message_key, sequence_no, role, text, raw_json, + created_at, updated_at) + VALUES ? + ON DUPLICATE KEY UPDATE + sequence_no = VALUES(sequence_no), + role = VALUES(role), + text = VALUES(text), + raw_json = VALUES(raw_json), + updated_at = VALUES(updated_at)`, + [ + normalized.map((message) => [ + message.id, + message.userId, + message.sessionId, + message.messageKey, + message.sequenceNo, + message.role, + message.text, + message.rawJson, + message.createdAt, + message.updatedAt + ]) + ] + ); + return normalized; + } + async function loadUnanalyzedUserMessages(userId, limit = MAX_ANALYSIS_MESSAGES) { + const [rows] = await pool2.query( + `SELECT id, user_id, agent_session_id, message_key, sequence_no, role, text, created_at + FROM h5_conversation_messages + WHERE user_id = ? AND role = 'user' AND analyzed_at IS NULL + ORDER BY created_at ASC, sequence_no ASC + LIMIT ?`, + [userId, Math.max(1, limit)] + ); + return rows; + } + async function markAnalyzed(messageIds) { + if (!messageIds.length) return; + await pool2.query( + `UPDATE h5_conversation_messages SET analyzed_at = ? WHERE id IN (?)`, + [now(), messageIds] + ); + } + async function selectedProvider() { + const [rows] = await pool2.query( + `SELECT * FROM h5_llm_provider_keys WHERE is_selected = 1 AND status = 'active' LIMIT 1` + ); + return rows[0] ?? null; + } + async function extractWithLlm(messages) { + const row = await selectedProvider(); + if (!row) return null; + const apiUrl = normalizeApiUrl(row.api_url); + const url = resolveChatCompletionsUrl(apiUrl); + if (!url) return null; + let apiKey; + try { + apiKey = decryptSecret( + { + ciphertext: row.api_key_ciphertext, + iv: row.api_key_iv, + tag: row.api_key_tag + }, + resolveEncryptionKey3(encryptionKey) + ); + } catch { + return null; + } + const upstream = await fetchImpl(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model: row.default_model, + messages: [{ role: "user", content: buildMemoryPrompt(messages) }], + stream: false, + temperature: 0, + ...row.relay_provider ? { provider: row.relay_provider } : {} + }) + }); + const text = await upstream.text().catch(() => ""); + if (!upstream.ok) return null; + const payload = extractJsonObject3(text); + const content = payload?.choices?.[0]?.message?.content ?? payload?.content ?? text; + const parsed = extractJsonObject3(content); + const rawItems = Array.isArray(parsed?.memories) ? parsed.memories : []; + return rawItems.map((item) => normalizeMemoryItem(item)).filter(Boolean); + } + async function storeMemories(userId, sourceMessageIds, memories, rawJson = null) { + const rows = []; + const ts = now(); + const sourceSessionId = sourceMessageIds[0]?.agent_session_id ?? null; + const evidenceMessageId = sourceMessageIds[0]?.id ?? null; + for (const item of memories) { + const normalized = normalizeMemoryItem(item); + if (!normalized) continue; + const hash = stableId(userId, normalized.label, normalized.text); + rows.push([ + stableId("memory", hash), + userId, + normalized.label, + hash, + normalized.text, + evidenceMessageId, + sourceSessionId, + normalized.confidence, + rawJson ? JSON.stringify(rawJson) : null, + ts, + ts + ]); + } + if (!rows.length) return 0; + await pool2.query( + `INSERT INTO h5_user_memory_items + (id, user_id, label, memory_hash, memory_text, evidence_message_id, + source_session_id, confidence, raw_json, created_at, updated_at) + VALUES ? + ON DUPLICATE KEY UPDATE + confidence = GREATEST(confidence, VALUES(confidence)), + updated_at = VALUES(updated_at)`, + [rows] + ); + return rows.length; + } + async function analyzeUser(userId) { + if (!isEnabled() || !pool2 || !userId) return { ok: false, reason: "disabled" }; + const messages = await loadUnanalyzedUserMessages(userId); + if (!messages.length) return { ok: true, analyzed: 0, memories: 0 }; + let memories = null; + let extractionSucceeded = false; + if (llmEnabled()) { + try { + memories = await extractWithLlm(messages); + extractionSucceeded = Array.isArray(memories); + } catch (err) { + console.warn("[conversation-memory] LLM extraction failed:", err instanceof Error ? err.message : err); + } + } + if (!memories) memories = fallbackMemoriesFromMessages(messages); + const stored = await storeMemories(userId, messages, memories); + if (!llmEnabled() || extractionSucceeded || stored > 0) { + await markAnalyzed(messages.map((message) => message.id)); + } + return { ok: true, analyzed: messages.length, memories: stored }; + } + async function saveAndAnalyze(sessionId, userId, messages = []) { + const saved = await saveConversationMessages(sessionId, userId, messages); + if (!saved.length) return { saved: 0, analyzed: 0, memories: 0 }; + const result = await analyzeUser(userId); + return { + saved: saved.length, + analyzed: result.analyzed ?? 0, + memories: result.memories ?? 0 + }; + } + async function listMemories(userId, { limit = 50 } = {}) { + const [rows] = await pool2.query( + `SELECT id, label, memory_text, confidence, source_session_id, created_at, updated_at + FROM h5_user_memory_items + WHERE user_id = ? AND status = 'active' + ORDER BY updated_at DESC + LIMIT ?`, + [userId, Math.max(1, Math.min(200, Number(limit) || 50))] + ); + return rows.map((row) => ({ + id: row.id, + label: row.label, + text: row.memory_text, + confidence: Number(row.confidence ?? 0), + sourceSessionId: row.source_session_id ?? null, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at) + })); + } + return { + isEnabled, + saveConversationMessages, + analyzeUser, + saveAndAnalyze, + listMemories + }; +} + +// experience-service.mjs +import crypto32 from "node:crypto"; +function createExperienceService(pool2, options = {}) { + const now = options.now ?? (() => Date.now()); + const halfLifeMs = Math.max( + 6e4, + Number(options.recencyHalfLifeMs ?? 30 * 24 * 60 * 60 * 1e3) + ); + function normalizeTags2(tags) { + if (!Array.isArray(tags)) return []; + return [...new Set(tags.map((t) => String(t).trim()).filter(Boolean))]; + } + function rowToExperience(row) { + return { + id: row.id, + scope: row.scope, + kind: row.kind, + title: row.title, + body: row.body, + tags: row.tags_json ? safeJsonArray(row.tags_json) : [], + sourceSessionId: row.source_session_id ?? null, + sourceUserId: row.source_user_id ?? null, + useCount: Number(row.use_count ?? 0), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at) + }; + } + function safeJsonArray(value) { + if (Array.isArray(value)) return value; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + function rankRows(rows, queryTerms, nowMs2) { + const terms = queryTerms; + return rows.map((row) => { + const haystack = `${row.title} +${row.body}`.toLowerCase(); + let keywordScore = 0; + for (const term of terms) { + if (haystack.includes(term)) keywordScore += 1; + } + const ageMs = Math.max(0, nowMs2 - Number(row.updated_at)); + const recency = Math.pow(0.5, ageMs / halfLifeMs); + const score = keywordScore * recency; + return { row, score }; + }).filter((entry) => entry.score > 0).sort((a, b) => b.score - a.score); + } + function tokenize(query) { + return [ + ...new Set( + String(query ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).map((t) => t.trim()).filter((t) => t.length >= 2) + ) + ]; + } + async function record(input) { + const title = String(input?.title ?? "").trim(); + 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 ts = now(); + const scope = String(input?.scope ?? "global").trim() || "global"; + const kind = String(input?.kind ?? "lesson").trim() || "lesson"; + const tags = normalizeTags2(input?.tags); + await pool2.query( + `INSERT INTO h5_experience + (id, scope, kind, title, body, tags_json, source_session_id, source_user_id, + use_count, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, + [ + id, + scope, + kind, + title, + body, + JSON.stringify(tags), + input?.sourceSessionId ?? null, + input?.sourceUserId ?? null, + ts, + ts + ] + ); + return rowToExperience({ + id, + scope, + kind, + title, + body, + tags_json: tags, + source_session_id: input?.sourceSessionId ?? null, + source_user_id: input?.sourceUserId ?? null, + use_count: 0, + created_at: ts, + updated_at: ts + }); + } + async function search(query, { scope = "global", limit = 5 } = {}) { + const terms = tokenize(query); + if (terms.length === 0) return []; + const [rows] = await pool2.query( + `SELECT id, scope, kind, title, body, tags_json, source_session_id, + source_user_id, use_count, created_at, updated_at + FROM h5_experience + WHERE scope = ? + ORDER BY updated_at DESC + LIMIT 500`, + [scope] + ); + const ranked = rankRows(rows, terms, now()).slice(0, Math.max(1, limit)); + if (ranked.length > 0) { + const ids = ranked.map((entry) => entry.row.id); + const placeholders = ids.map(() => "?").join(","); + await pool2.query( + `UPDATE h5_experience SET use_count = use_count + 1 WHERE id IN (${placeholders})`, + ids + ).catch(() => { + }); + } + return ranked.map((entry) => rowToExperience(entry.row)); + } + async function reflect() { + return { ok: false, reason: "not_implemented" }; + } + return { record, search, reflect }; +} +function experienceError(message, code) { + return Object.assign(new Error(message), { code }); } // asr-proxy.mjs @@ -23018,10 +32092,10 @@ function attachAsrRoutes(api2, deps) { } // server.mjs -var __dirname5 = path22.dirname(fileURLToPath6(import.meta.url)); +var __dirname5 = path28.dirname(fileURLToPath6(import.meta.url)); function loadEnvFile(filePath) { - if (!fs23.existsSync(filePath)) return; - for (const line of fs23.readFileSync(filePath, "utf8").split("\n")) { + if (!fs26.existsSync(filePath)) return; + for (const line of fs26.readFileSync(filePath, "utf8").split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eq = trimmed.indexOf("="); @@ -23031,20 +32105,26 @@ function loadEnvFile(filePath) { if (!process.env[key]) process.env[key] = value; } } -loadEnvFile(path22.join(__dirname5, "../../.env.local")); -loadEnvFile(path22.join(__dirname5, ".env")); +loadEnvFile(path28.join(__dirname5, "../../.env.local")); +loadEnvFile(path28.join(__dirname5, ".env")); +function parseApiTargets() { + const csvTargets = (process.env.TKMIND_API_TARGETS ?? "").split(",").map((value) => value.trim()).filter(Boolean); + const legacyTargets = [ + process.env.TKMIND_API_TARGET ?? "https://127.0.0.1:18006", + process.env.TKMIND_API_TARGET_1 + ].filter(Boolean); + return [.../* @__PURE__ */ new Set([...csvTargets, ...legacyTargets])]; +} var PORT = Number(process.env.H5_PORT ?? 8081); -var API_TARGET = process.env.TKMIND_API_TARGET ?? "https://127.0.0.1:18006"; -var API_TARGETS = [ - API_TARGET, - ...process.env.TKMIND_API_TARGET_1 ? [process.env.TKMIND_API_TARGET_1] : [] -]; +var HOST = String(process.env.H5_HOST ?? "127.0.0.1").trim() || "127.0.0.1"; +var API_TARGETS = parseApiTargets(); +var API_TARGET = API_TARGETS[0] ?? "https://127.0.0.1:18006"; var API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? "local-dev-secret"; var INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET; var ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD; var WECHAT_MP_CONFIG = loadWechatMpConfig(); var WORKSPACE_MAINTENANCE_ENABLED = process.env.MEMIND_WORKSPACE_MAINTENANCE !== "0"; -var USERS_ROOT = process.env.H5_USERS_ROOT ?? path22.join(__dirname5, "users"); +var USERS_ROOT = process.env.H5_USERS_ROOT ?? path28.join(__dirname5, "users"); var app = express2(); app.set("trust proxy", 1); var isSecureRequest = (req) => req.secure || req.get("x-forwarded-proto")?.split(",")[0]?.trim() === "https"; @@ -23093,14 +32173,17 @@ var rawUploadBody = express2.raw({ type: "application/octet-stream", limit: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES) }); -var wikiAuth = createWikiAuth(path22.join(__dirname5, PUBLISH_ROOT_DIR, "wiki-db")); +var wikiAuth = createWikiAuth(path28.join(__dirname5, PUBLISH_ROOT_DIR, "wiki-db")); var legacyAuth = null; -if (ACCESS_PASSWORD) { +if (ACCESS_PASSWORD && !isDatabaseConfigured()) { legacyAuth = createAuthManager({ password: ACCESS_PASSWORD }); +} else if (ACCESS_PASSWORD && isDatabaseConfigured()) { + console.log("H5_ACCESS_PASSWORD ignored: multi-user database auth is configured"); } var userAuth = null; var tkmindProxy = null; var sessionSnapshotService = null; +var conversationMemoryService = null; var mindSpace = null; var mindSpaceAssets = null; var mindSpaceAudit = null; @@ -23124,6 +32207,7 @@ var wechatPayClient = null; var wechatOAuthService = null; var wechatMpService = null; var scheduleService = null; +var feedbackService = null; var scheduleReminderWorker = null; var llmProviderService = null; var wordFilterService = null; @@ -23131,64 +32215,75 @@ var authPool = null; async function bootstrapUserAuth() { try { if (!isDatabaseConfigured()) return false; - const pool = createDbPool(); - authPool = pool; - await initSchema(pool); - await ensureMindSpaceConfig(pool, { + const pool2 = createDbPool(); + authPool = pool2; + await initSchema(pool2); + await ensureMindSpaceConfig(pool2, { env: process.env }); - scheduleService = createScheduleService(pool, { + scheduleService = createScheduleService(pool2, { defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || "Asia/Shanghai" }); - mindSpace = createMindSpaceService(pool, { + feedbackService = createFeedbackService(pool2); + mindSpace = createMindSpaceService(pool2, { maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES), aiDailyLimit: Number(process.env.MINDSPACE_FREE_AI_DAILY_LIMIT ?? 10), publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5), monthlyViewLimit: Number(process.env.MINDSPACE_FREE_MONTHLY_VIEW_LIMIT ?? 1e3), scheduleService }); - mindSpaceAssets = createAssetService(pool, { + mindSpaceAssets = createAssetService(pool2, { h5Root: __dirname5, - storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path22.join(__dirname5, "data", "mindspace"), + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"), maxFileBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES) }); - mindSpacePages = createPageService(pool, { + mindSpacePages = createPageService(pool2, { h5Root: __dirname5, - storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path22.join(__dirname5, "data", "mindspace") + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace") }); mindSpacePageLiveEdit = createPageLiveEditService({ pageService: mindSpacePages, resolveUserIdForAgentSession: async (sessionId) => { - const [rows] = await pool.query( + const [rows] = await pool2.query( `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, [sessionId] ); return rows[0]?.user_id ?? null; } }); - mindSpacePublications = createPublicationService(pool, { - storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path22.join(__dirname5, "data", "mindspace"), + mindSpacePublications = createPublicationService(pool2, { + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"), publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5) }); - await ensureAlgorithmConfig(pool); - const plazaAlgorithmConfig = await loadAlgorithmConfig(pool); - plazaRedis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool); + setInterval(async () => { + try { + const result = await mindSpacePublications.cleanupExpiredUnconfirmedPublications(); + if (result.cleaned > 0) { + console.log(`[Publication Cleanup] Auto-privatized ${result.cleaned} expired unconfirmed publications`); + } + } catch (err) { + console.error("[Publication Cleanup Error]", err instanceof Error ? err.message : err); + } + }, 60 * 1e3); + await ensureAlgorithmConfig(pool2); + const plazaAlgorithmConfig = await loadAlgorithmConfig(pool2); + plazaRedis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool2); if (plazaRedis.enabled) { console.log("Plaza Redis enabled"); } - plazaSeo = createPlazaSeoService(pool); - plazaInteractions = createPlazaInteractionService(pool, { + plazaSeo = createPlazaSeoService(pool2); + plazaInteractions = createPlazaInteractionService(pool2, { formatPostRow, plazaRedis }); - plazaEvents = createPlazaEventService(pool); - plazaRecommend = createPlazaRecommendService(pool, { + plazaEvents = createPlazaEventService(pool2); + plazaRecommend = createPlazaRecommendService(pool2, { eventService: plazaEvents, formatPostRow, loadViewerReactions: (viewerId, postIds) => plazaInteractions.loadViewerReactions(viewerId, postIds), algorithmConfig: plazaAlgorithmConfig }); - plazaPosts = createPlazaPostService(pool, { + plazaPosts = createPlazaPostService(pool2, { loadViewerReactions: (viewerId, postIds) => plazaInteractions.loadViewerReactions(viewerId, postIds), plazaRedis, algorithmConfig: plazaAlgorithmConfig, @@ -23199,36 +32294,36 @@ async function bootstrapUserAuth() { return plazaOps.loadActiveFeaturedPosts(viewerId); } }); - plazaOps = createPlazaOpsService(pool, { + plazaOps = createPlazaOpsService(pool2, { formatPostRow, reviewPost: (...args) => plazaPosts.reviewPost(...args), invalidateFeedCaches: () => plazaRedis?.invalidateFeedCaches?.() }); startPlazaTasks({ - pool, + pool: pool2, plazaRedis, recalculateHotScores, writebackPublications }); - mindSpaceCleanup = createCleanupService(pool, { - storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path22.join(__dirname5, "data", "mindspace"), + mindSpaceCleanup = createCleanupService(pool2, { + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"), h5Root: __dirname5 }); - await ensurePlanCatalogSchema(pool); - const planCatalogService = createPlanCatalogService(pool); - subscriptionService = createSubscriptionService(pool, { + await ensurePlanCatalogSchema(pool2); + const planCatalogService = createPlanCatalogService(pool2); + subscriptionService = createSubscriptionService(pool2, { getPlanAsync: (planType) => planCatalogService.getPlan(planType) }); subscriptionService._planCatalogService = planCatalogService; - userAuth = createUserAuth(pool, { + userAuth = createUserAuth(pool2, { usersRoot: USERS_ROOT, h5Root: __dirname5, defaultSignupBalanceCents: Number(process.env.H5_SIGNUP_BALANCE_CENTS ?? 500), subscriptionService }); wechatPayClient = createWechatPayClient(loadWechatPayConfig()); - wechatOAuthService = createWechatOAuthService(pool, loadWechatOAuthConfig(), { userAuth }); - rechargeService = createRechargeService(pool, { + wechatOAuthService = createWechatOAuthService(pool2, loadWechatOAuthConfig(), { userAuth }); + rechargeService = createRechargeService(pool2, { userAuth, wechatPay: wechatPayClient }); @@ -23238,26 +32333,100 @@ async function bootstrapUserAuth() { if (wechatOAuthService.enabled) { console.log("WeChat OAuth login enabled"); } - mindSpaceAgentJobs = createAgentJobService(pool, { + mindSpaceAgentJobs = createAgentJobService(pool2, { pageService: mindSpacePages, - storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path22.join(__dirname5, "data", "mindspace"), + storageRoot: process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"), maxOutputBytes: Number(process.env.MINDSPACE_MAX_FILE_BYTES ?? DEFAULT_MAX_FILE_BYTES) }); + let experienceService = null; + if (process.env.MINDSPACE_EXPERIENCE_ENABLED !== "false") { + if (process.env.EXPERIENCE_PG_URL) { + try { + const { createPgExperienceService: createPgExperienceService2 } = await Promise.resolve().then(() => (init_experience_service_pg(), experience_service_pg_exports)); + experienceService = await createPgExperienceService2({ + connectionString: process.env.EXPERIENCE_PG_URL + }); + console.log("Experience store: PostgreSQL + pgvector"); + } catch (error) { + console.error( + "Experience PG init failed, falling back to MySQL store:", + error instanceof Error ? error.message : error + ); + experienceService = createExperienceService(pool2); + } + } else { + experienceService = createExperienceService(pool2); + } + } mindSpaceAgentRunner = createMindSpaceAgentRunner({ apiTarget: API_TARGET, apiSecret: API_SECRET, userAuth, - agentJobService: mindSpaceAgentJobs + agentJobService: mindSpaceAgentJobs, + experienceService }); - mindSpaceAudit = createMindSpaceAuditWriter(pool); + mindSpaceAudit = createMindSpaceAuditWriter(pool2); + if (process.env.MINDSPACE_AGENT_WORKER_ENABLED === "true") { + const workerConcurrency = Math.max( + 1, + Number(process.env.MINDSPACE_AGENT_WORKER_CONCURRENCY ?? 2) + ); + const workerPollMs = Math.max( + 200, + Number(process.env.MINDSPACE_AGENT_WORKER_POLL_MS ?? 1e3) + ); + const workerStaleMs = Math.max( + 1e4, + Number(process.env.MINDSPACE_AGENT_WORKER_STALE_MS ?? 5 * 60 * 1e3) + ); + let inFlight = 0; + let draining = false; + const drainQueue = async () => { + if (draining) return; + draining = true; + try { + while (inFlight < workerConcurrency) { + const claim = await mindSpaceAgentJobs.claimNextJob(); + if (!claim) break; + inFlight += 1; + void mindSpaceAgentRunner.runJob(claim.jobId, claim).catch((error) => { + console.error("Agent worker job failed:", error); + }).finally(() => { + inFlight -= 1; + }); + } + } catch (error) { + console.error("Agent worker drain failed:", error); + } finally { + draining = false; + } + }; + const workerTimer = setInterval(() => { + void drainQueue(); + }, workerPollMs); + const reaperTimer = setInterval(() => { + void mindSpaceAgentJobs.reapStaleJobs(workerStaleMs).then((reaped) => { + if (reaped > 0) { + console.warn(`Agent worker reaped ${reaped} stale running job(s)`); + } + }).catch((error) => { + console.error("Agent worker reaper failed:", error); + }); + }, Math.min(workerStaleMs, 6e4)); + workerTimer.unref?.(); + reaperTimer.unref?.(); + console.log( + `Agent job worker enabled (concurrency=${workerConcurrency}, poll=${workerPollMs}ms)` + ); + } if (WORKSPACE_MAINTENANCE_ENABLED) { - startWorkspaceThumbnailWatcher(path22.join(__dirname5, PUBLISH_ROOT_DIR)); + startWorkspaceThumbnailWatcher(path28.join(__dirname5, PUBLISH_ROOT_DIR)); startWorkspaceAssetSyncWatcher({ - publishRoot: path22.join(__dirname5, PUBLISH_ROOT_DIR), + publishRoot: path28.join(__dirname5, PUBLISH_ROOT_DIR), syncUserWorkspaceByDirKey: async (dirKey, options) => { let userId = dirKey; if (!PUBLISH_KEY_UUID.test(dirKey)) { - const [rows] = await pool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ + const [rows] = await pool2.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ dirKey ]); userId = rows[0]?.id; @@ -23276,11 +32445,12 @@ async function bootstrapUserAuth() { console.log("Workspace maintenance daemons disabled (MEMIND_WORKSPACE_MAINTENANCE=0)"); } await userAuth.ensureAdminUser(); - llmProviderService = createLlmProviderService(pool, { + llmProviderService = createLlmProviderService(pool2, { apiTarget: API_TARGET, + apiTargets: API_TARGETS, apiSecret: API_SECRET }); - wordFilterService = createWordFilterService(pool); + wordFilterService = createWordFilterService(pool2); void llmProviderService.ensureBootstrapRelay().then((result) => { if (result.created) { console.log(`LLM relay bootstrap created: ${RELAY_BOOTSTRAP.name}`); @@ -23291,7 +32461,10 @@ async function bootstrapUserAuth() { void llmProviderService.syncSelectedToGoosed().catch((err) => { console.warn("LLM provider boot sync skipped:", err instanceof Error ? err.message : err); }); - sessionSnapshotService = createSessionSnapshotService(pool); + conversationMemoryService = createConversationMemoryService(pool2); + sessionSnapshotService = createSessionSnapshotService(pool2, { + conversationMemoryService + }); tkmindProxy = createTkmindProxy({ apiTarget: API_TARGET, apiTargets: API_TARGETS, @@ -23299,9 +32472,11 @@ async function bootstrapUserAuth() { userAuth, llmProviderService, subscriptionService, + sessionSnapshotService, + conversationMemoryService, localFetchAsset: mindSpaceAssets ? async (userId, assetId) => { const { asset, path: assetPath } = await mindSpaceAssets.readAsset(userId, assetId); - const buffer = await fs23.promises.readFile(assetPath); + const buffer = await fs26.promises.readFile(assetPath); return { buffer, mimeType: asset.mimeType }; } : null }); @@ -23309,12 +32484,17 @@ async function bootstrapUserAuth() { config: WECHAT_MP_CONFIG, userAuth, apiFetch: tkmindProxy.apiFetch, + startAgentSession: ({ userId, workingDir, sessionPolicy }) => tkmindProxy.startSessionForUser(userId, { workingDir, sessionPolicy }), sessionApiFetch: async (sessionId, pathname, init) => { const target = await tkmindProxy.resolveTarget(sessionId); return tkmindProxy.apiFetchTo(target, pathname, init); }, scheduleService: process.env.H5_SCHEDULE_ENABLED === "1" ? scheduleService : null, - applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId) + applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId), + refreshSessionSnapshot: sessionSnapshotService?.isEnabled() ? (sessionId, userId) => sessionSnapshotService.refresh(sessionId, userId, async (pathname, init) => { + const target = await tkmindProxy.resolveTarget(sessionId); + return tkmindProxy.apiFetchTo(target, pathname, init); + }) : null }); userAuth.setRechargeNotifier(async ({ userId, title, body }) => { if (!wechatMpService?.enabled) return; @@ -23357,6 +32537,12 @@ ${body}`.trim()); return true; } catch (err) { console.error("User auth bootstrap failed:", err); + if (isDatabaseConfigured() && process.env.NODE_ENV === "production") { + console.error( + "Fatal: database is configured but user auth bootstrap failed; exiting so launchd can retry" + ); + process.exit(1); + } return false; } } @@ -23403,6 +32589,13 @@ app.get("/auth/status", async (req, res) => { unrestricted: capabilityState.unrestricted }); } + if (isDatabaseConfigured()) { + return res.status(503).json({ + authenticated: false, + mode: "unavailable", + message: "\u7528\u6237\u8BA4\u8BC1\u670D\u52A1\u4E0D\u53EF\u7528\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5" + }); + } if (legacyAuth) { return res.json({ authenticated: legacyAuth.verify(legacySessionToken(req)), @@ -23507,13 +32700,11 @@ app.get("/auth/wechat/js-sdk-signature", async (req, res) => { return res.status(400).json({ message: "\u7F3A\u5C11 url" }); } try { - const publicHost = WECHAT_MP_CONFIG.publicBaseUrl ? new URL(WECHAT_MP_CONFIG.publicBaseUrl).host : null; - const target = new URL(pageUrl); - const requestHost = req.get("x-forwarded-host") || req.get("host") || ""; - if (publicHost && target.host !== publicHost && target.host !== requestHost) { - return res.status(400).json({ message: "url \u4E0D\u5C5E\u4E8E\u5F53\u524D H5 \u57DF\u540D" }); - } - const payload = await wechatMpService.createJsSdkSignature(pageUrl); + const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, { + publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl, + requestHost: req.get("x-forwarded-host") || req.get("host") || "" + }); + const payload = await wechatMpService.createJsSdkSignature(normalizedUrl); return res.json(payload); } catch (err) { const message = err instanceof Error ? err.message : "\u5FAE\u4FE1 JS-SDK \u7B7E\u540D\u5931\u8D25"; @@ -23521,6 +32712,28 @@ app.get("/auth/wechat/js-sdk-signature", async (req, res) => { return res.status(502).json({ message }); } }); +app.get("/auth/wechat/public-js-sdk-signature", async (req, res) => { + await userAuthReady; + if (!wechatMpService?.enabled) { + return res.status(503).json({ message: "\u5FAE\u4FE1 JS-SDK \u672A\u542F\u7528" }); + } + const pageUrl = String(req.query?.url ?? "").split("#")[0]; + if (!pageUrl) { + return res.status(400).json({ message: "\u7F3A\u5C11 url" }); + } + try { + const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, { + publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl, + requestHost: req.get("x-forwarded-host") || req.get("host") || "" + }); + const payload = await wechatMpService.createJsSdkSignature(normalizedUrl); + return res.json(payload); + } catch (err) { + const message = err instanceof Error ? err.message : "\u5FAE\u4FE1 JS-SDK \u7B7E\u540D\u5931\u8D25"; + console.warn("WeChat public JS-SDK signature failed:", message); + return res.status(502).json({ message }); + } +}); app.get("/auth/wechat/status", async (req, res) => { await userAuthReady; if (!userAuth || !wechatOAuthService?.enabled) { @@ -23767,6 +32980,79 @@ app.get("/auth/usage", async (req, res) => { const records = await userAuth.listUsageRecords({ userId: me.id, limit: 30 }); res.json({ records }); }); +app.post("/auth/feedback", jsonBody, async (req, res) => { + await userAuthReady; + if (!userAuth || !feedbackService) { + return res.status(503).json({ message: "\u53CD\u9988\u670D\u52A1\u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + try { + const result = await feedbackService.submit(me.id, { + type: req.body?.type, + title: req.body?.title, + description: req.body?.description, + contact: req.body?.contact, + images: req.body?.images, + context: req.body?.context + }); + res.status(201).json({ feedback: result }); + } catch (err) { + const code = err && typeof err === "object" && "code" in err ? String(err.code) : ""; + if (code === "invalid_input") { + return res.status(400).json({ message: err instanceof Error ? err.message : "\u63D0\u4EA4\u5185\u5BB9\u65E0\u6548" }); + } + console.warn("Submit feedback failed:", err instanceof Error ? err.message : err); + return res.status(500).json({ message: "\u53CD\u9988\u63D0\u4EA4\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5" }); + } +}); +app.get("/auth/feedback", async (req, res) => { + await userAuthReady; + if (!userAuth || !feedbackService) { + return res.status(503).json({ message: "\u53CD\u9988\u670D\u52A1\u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + const limit = Math.min(Math.max(Number(req.query?.limit) || 20, 1), 50); + const items = await feedbackService.listForUser(me.id, { limit }); + res.json({ items }); +}); +app.get("/auth/feedback/board", async (req, res) => { + await userAuthReady; + if (!userAuth || !feedbackService) { + return res.status(503).json({ message: "\u53CD\u9988\u670D\u52A1\u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + const page = Math.max(Number(req.query?.page) || 1, 1); + const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 10); + try { + const result = await feedbackService.listAll({ page, limit }); + res.json(result); + } catch (err) { + console.warn("List feedback board failed:", err instanceof Error ? err.message : err); + return res.status(500).json({ message: "\u53CD\u9988\u5217\u8868\u52A0\u8F7D\u5931\u8D25" }); + } +}); +app.get("/auth/feedback/:feedbackId", async (req, res) => { + await userAuthReady; + if (!userAuth || !feedbackService) { + return res.status(503).json({ message: "\u53CD\u9988\u670D\u52A1\u672A\u542F\u7528" }); + } + const me = await userAuth.getMe(userToken(req)); + if (!me) return res.status(401).json({ message: "\u672A\u767B\u5F55" }); + try { + const item = await feedbackService.getById(req.params.feedbackId, me.id); + res.json({ item, isMine: item.userId === me.id }); + } catch (err) { + const code = err && typeof err === "object" && "code" in err ? String(err.code) : ""; + if (code === "feedback_not_found") { + return res.status(404).json({ message: err instanceof Error ? err.message : "\u53CD\u9988\u4E0D\u5B58\u5728" }); + } + console.warn("Get feedback detail failed:", err instanceof Error ? err.message : err); + return res.status(500).json({ message: "\u53CD\u9988\u8BE6\u60C5\u52A0\u8F7D\u5931\u8D25" }); + } +}); app.get("/auth/notifications", async (req, res) => { await userAuthReady; if (!userAuth || !scheduleService) { @@ -24228,6 +33514,9 @@ api.use(async (req, res, next) => { if (req.path.startsWith("/internal/agent/")) return next(); if (req.path === "/agent/mindspace_page_patch") return next(); if (req.path === "/config/blocked-words") return next(); + if (req.method === "GET" && /^\/mindspace\/v1\/assets\/[^/]+\/download$/.test(req.path)) { + return next(); + } const plazaPublic = isPlazaPublicRead(req.path, req.method); if (userAuth && tkmindProxy) { if (req.userSession) { @@ -24266,6 +33555,106 @@ api.get("/status", async (_req, res, next) => { } return next(); }); +async function ensureUserMemoryCapability(req, res) { + if (!userAuth) { + res.status(503).json({ message: "\u672A\u542F\u7528\u7528\u6237\u7CFB\u7EDF" }); + return null; + } + const userRow = await userAuth.getUserById(req.currentUser.id); + if (!userRow) { + res.status(404).json({ message: "\u7528\u6237\u4E0D\u5B58\u5728" }); + return null; + } + const capabilityState = await userAuth.resolveUserCapabilities(userRow); + if (!capabilityState.unrestricted && !capabilityState.capabilities.memory_store) { + res.status(403).json({ message: "\u5F53\u524D\u8D26\u6237\u672A\u5F00\u901A\u957F\u671F\u8BB0\u5FC6\uFF0C\u65E0\u6CD5\u8BBF\u95EE\u8BE5 API" }); + return null; + } + return capabilityState; +} +async function loadUserVisibleConversation(sessionId) { + const target = await tkmindProxy.resolveTarget(sessionId); + const upstream = await tkmindProxy.apiFetchTo(target, `/sessions/${encodeURIComponent(sessionId)}`, { + method: "GET" + }); + if (!upstream.ok) { + const message = await upstream.text().catch(() => ""); + throw new Error(message || "\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25"); + } + const session = await upstream.json(); + return (session?.conversation ?? []).filter((message) => message?.metadata?.userVisible); +} +async function syncUserMemoriesIntoSession(userId, sessionId) { + if (!tkmindProxy || !sessionId) return false; + await tkmindProxy.reconcileSessionPolicyForUser(userId, sessionId); + return true; +} +api.post("/user-memory/v1/remember-recent", async (req, res) => { + if (!conversationMemoryService?.isEnabled?.()) { + return res.status(503).json({ message: "\u957F\u671F\u8BB0\u5FC6\u529F\u80FD\u672A\u542F\u7528" }); + } + if (!tkmindProxy) { + return res.status(503).json({ message: "\u4F1A\u8BDD\u4EE3\u7406\u5C1A\u672A\u5C31\u7EEA" }); + } + const capabilityState = await ensureUserMemoryCapability(req, res); + if (!capabilityState) return; + const sessionId = String(req.body?.sessionId ?? "").trim(); + if (!sessionId) { + return res.status(400).json({ message: "\u7F3A\u5C11 sessionId" }); + } + const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); + if (!owns) { + return res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); + } + try { + const messages = await loadUserVisibleConversation(sessionId); + const result = await conversationMemoryService.saveAndAnalyze( + sessionId, + req.currentUser.id, + messages + ); + const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId); + const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 }); + return res.json({ + ok: true, + analyzed: result.analyzed ?? 0, + memories: result.memories ?? 0, + totalMemories: memories.length, + syncedToSession + }); + } catch (err) { + return res.status(500).json({ message: err instanceof Error ? err.message : "\u4FDD\u5B58\u957F\u671F\u8BB0\u5FC6\u5931\u8D25" }); + } +}); +api.post("/user-memory/v1/sync", async (req, res) => { + if (!conversationMemoryService?.isEnabled?.()) { + return res.status(503).json({ message: "\u957F\u671F\u8BB0\u5FC6\u529F\u80FD\u672A\u542F\u7528" }); + } + const capabilityState = await ensureUserMemoryCapability(req, res); + if (!capabilityState) return; + const sessionId = String(req.body?.sessionId ?? "").trim(); + if (!sessionId) { + return res.status(400).json({ message: "\u7F3A\u5C11 sessionId" }); + } + const owns = await userAuth.ownsSession(req.currentUser.id, sessionId); + if (!owns) { + return res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" }); + } + try { + const result = await conversationMemoryService.analyzeUser(req.currentUser.id); + const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId); + const memories = await conversationMemoryService.listMemories(req.currentUser.id, { limit: 200 }); + return res.json({ + ok: true, + analyzed: result.analyzed ?? 0, + memories: result.memories ?? 0, + totalMemories: memories.length, + syncedToSession + }); + } catch (err) { + return res.status(500).json({ message: err instanceof Error ? err.message : "\u5237\u65B0\u957F\u671F\u8BB0\u5FC6\u5931\u8D25" }); + } +}); api.get("/mindspace/v1/space", async (req, res) => { if (!mindSpace || !ensureMindSpaceEnabled(res, req)) return; const space = await mindSpace.getSpace(req.currentUser.id); @@ -24315,18 +33704,18 @@ api.post("/mindspace/v1/space/cleanup", async (req, res) => { return mindSpaceError(res, req, error); } }); -function isPlazaPublicRead(path23, method) { - if (!path23.startsWith("/plaza/v1/")) return false; - if (method === "POST" && path23 === "/plaza/v1/events") return true; +function isPlazaPublicRead(path29, method) { + if (!path29.startsWith("/plaza/v1/")) return false; + if (method === "POST" && path29 === "/plaza/v1/events") return true; if (method !== "GET") return false; - return path23 === "/plaza/v1/feed" || path23 === "/plaza/v1/categories" || path23 === "/plaza/v1/seo/sitemap" || /^\/plaza\/v1\/posts\/[^/]+$/.test(path23) || /^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path23) || /^\/plaza\/v1\/users\/[^/]+$/.test(path23) || /^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path23); + return path29 === "/plaza/v1/feed" || path29 === "/plaza/v1/categories" || path29 === "/plaza/v1/seo/sitemap" || /^\/plaza\/v1\/posts\/[^/]+$/.test(path29) || /^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path29) || /^\/plaza\/v1\/users\/[^/]+$/.test(path29) || /^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path29); } var PLAZA_SID_COOKIE = "plaza_sid"; function resolvePlazaSessionId(req, res) { const cookies = parseCookies(req.get("cookie")); let sessionId = cookies[PLAZA_SID_COOKIE]; if (!sessionId) { - sessionId = crypto29.randomUUID(); + sessionId = crypto34.randomUUID(); res.append( "Set-Cookie", `${PLAZA_SID_COOKIE}=${sessionId}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly` @@ -24489,6 +33878,71 @@ api.get("/mindspace/v1/agent/jobs/:jobId", async (req, res) => { return mindSpaceError(res, req, error); } }); +api.get("/mindspace/v1/agent/jobs/:jobId/stream", async (req, res) => { + if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; + const TERMINAL = /* @__PURE__ */ new Set(["completed", "failed", "cancelled", "timed_out"]); + let job; + try { + job = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId); + } catch (error) { + return mindSpaceError(res, req, error); + } + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no" + }); + const send = (event, payload) => { + res.write(`event: ${event} +`); + res.write(`data: ${JSON.stringify(payload)} + +`); + }; + let lastSignature = ""; + const emitIfChanged = (current) => { + const signature = `${current.status}:${JSON.stringify(current.progress ?? {})}`; + if (signature !== lastSignature) { + lastSignature = signature; + send("progress", current); + } + return signature; + }; + emitIfChanged(job); + if (TERMINAL.has(job.status)) { + send("done", job); + return res.end(); + } + let closed = false; + const cleanup = () => { + if (closed) return; + closed = true; + clearInterval(pollTimer); + clearInterval(keepAliveTimer); + }; + const pollTimer = setInterval(async () => { + if (closed) return; + try { + const current = await mindSpaceAgentJobs.getJob(req.currentUser.id, req.params.jobId); + emitIfChanged(current); + if (TERMINAL.has(current.status)) { + send("done", current); + cleanup(); + res.end(); + } + } catch { + cleanup(); + res.end(); + } + }, Math.max(500, Number(process.env.MINDSPACE_AGENT_SSE_POLL_MS ?? 1e3))); + const keepAliveTimer = setInterval(() => { + if (!closed) res.write(": keep-alive\n\n"); + }, 15e3); + pollTimer.unref?.(); + keepAliveTimer.unref?.(); + req.on("close", cleanup); +}); api.get("/mindspace/v1/agent/jobs", async (req, res) => { if (!mindSpaceAgentJobs || !ensureMindSpaceEnabled(res, req, { agent: true })) return; try { @@ -24572,7 +34026,7 @@ api.get("/internal/agent/jobs/:jobId/assets/:assetId", async (req, res) => { res.set("Content-Disposition", `inline; filename="${encodeURIComponent(asset.displayName)}"`); res.set("Cache-Control", "private, no-store"); res.setHeader("X-Request-Id", req.requestId); - return res.send(await fs23.promises.readFile(asset.path)); + return res.send(await fs26.promises.readFile(asset.path)); } catch (error) { return mindSpaceError(res, req, error); } @@ -24676,25 +34130,109 @@ api.get("/mindspace/v1/assets", async (req, res) => { return mindSpaceError(res, req, error); } }); +api.get("/mindspace/v1/authorize-image", async (req, res) => { + if (!mindSpacePublications) { + return res.status(503).json({ error: "Service unavailable" }); + } + const assetId = String(req.query.asset_id ?? ""); + if (!assetId) { + return res.status(400).json({ error: "Missing asset_id parameter" }); + } + try { + const [refs] = await pool.query( + `SELECT pr.access_mode, pr.expires_at + FROM h5_publication_asset_refs refs + JOIN h5_publish_records pr ON refs.publication_id = pr.id + WHERE refs.asset_id = ? AND pr.status = 'online' + ORDER BY CASE + WHEN pr.access_mode = 'public' THEN 0 + WHEN pr.access_mode = 'time_limited' THEN 1 + ELSE 2 + END, + pr.expires_at DESC + LIMIT 1`, + [assetId] + ); + if (!refs[0]) { + return res.status(403).json({ error: "Forbidden" }); + } + const publication = refs[0]; + const now = Date.now(); + if (publication.access_mode === "public") { + res.set("Cache-Control", "public, max-age=31536000, immutable"); + return res.status(200).json({ ok: true }); + } + if (publication.access_mode === "time_limited" && publication.expires_at && Number(publication.expires_at) > now) { + res.set("Cache-Control", "public, max-age=60"); + return res.status(200).json({ ok: true }); + } + return res.status(403).json({ error: "Forbidden" }); + } catch (error) { + console.error("[authorize-image]", error instanceof Error ? error.message : error); + return res.status(500).json({ error: "Internal server error" }); + } +}); api.get("/mindspace/v1/assets/:assetId/download", async (req, res) => { if (!mindSpaceAssets || !ensureMindSpaceEnabled(res, req)) return; try { - const { asset, path: assetPath } = await mindSpaceAssets.readAsset( - req.currentUser.id, - req.params.assetId - ); + const assetId = req.params.assetId; + const currentUserId = req.currentUser?.id ?? null; + let allowedByPublication = false; + let publicationAccessMode = null; + if (!currentUserId) { + const referrer = String(req.get("referer") ?? req.get("referrer") ?? ""); + const isPublicPageReferrer = (() => { + if (!referrer) return false; + try { + return new URL(referrer, resolveRequestOrigin(req) || "http://localhost").pathname.includes("/public/"); + } catch { + return referrer.includes("/public/"); + } + })(); + const [refs] = await authPool.query( + `SELECT pr.access_mode, pr.expires_at + FROM h5_publication_asset_refs refs + JOIN h5_publish_records pr ON refs.publication_id = pr.id + WHERE refs.asset_id = ? AND pr.status = 'online' + ORDER BY CASE + WHEN pr.access_mode = 'public' THEN 0 + WHEN pr.access_mode = 'time_limited' THEN 1 + ELSE 2 + END, + pr.expires_at DESC + LIMIT 1`, + [assetId] + ); + const publication = refs[0] ?? null; + if (publication?.access_mode === "public") { + allowedByPublication = true; + publicationAccessMode = "public"; + } else if (publication?.access_mode === "time_limited" && publication.expires_at && Number(publication.expires_at) > Date.now()) { + allowedByPublication = true; + publicationAccessMode = "time_limited"; + } + if (!allowedByPublication && isPublicPageReferrer) { + allowedByPublication = true; + publicationAccessMode = "public-page-referrer"; + } + if (!allowedByPublication) { + return res.status(401).json({ message: "\u672A\u6388\u6743\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55" }); + } + } + const { asset, path: assetPath } = currentUserId ? await mindSpaceAssets.readAsset(currentUserId, assetId) : await mindSpaceAssets.readPublicAsset(assetId); await mindSpaceAudit?.write({ - userId: req.currentUser.id, + userId: req.currentUser?.id ?? null, action: "asset.download", objectType: "asset", - objectId: req.params.assetId, + objectId: assetId, ip: req.ip, - riskLevel: asset.riskLevel + riskLevel: asset.riskLevel, + detail: allowedByPublication ? { accessMode: publicationAccessMode } : void 0 }); res.type(asset.mimeType); const inline = req.query.inline === "1" || req.query.disposition === "inline" || req.get("sec-fetch-dest") === "iframe"; if (inline && asset.mimeType.startsWith("image/") && wantsInlineImageViewer(req)) { - const downloadUrl = `/api/mindspace/v1/assets/${encodeURIComponent(req.params.assetId)}/download?inline=1`; + const downloadUrl = `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download?inline=1`; const html = renderImageAssetViewerHtml({ asset, downloadUrl }); res.set("Content-Type", "text/html; charset=utf-8"); res.set("Cache-Control", "private, no-store"); @@ -24709,7 +34247,7 @@ api.get("/mindspace/v1/assets/:assetId/download", async (req, res) => { return res.sendFile(assetPath); } catch (error) { await mindSpaceAudit?.write({ - userId: req.currentUser.id, + userId: req.currentUser?.id ?? null, action: "asset.download", objectType: "asset", objectId: req.params.assetId, @@ -24756,7 +34294,7 @@ api.post("/mindspace/v1/pages/from-asset", async (req, res) => { req.currentUser.id, assetId ); - const content = await fs23.promises.readFile(assetPath, "utf8"); + const content = await fs26.promises.readFile(assetPath, "utf8"); const contentFormat = asset.mimeType === "text/html" ? "html" : "markdown"; const page = await mindSpacePages.createFromChat( req.currentUser.id, @@ -24798,7 +34336,7 @@ api.delete("/mindspace/v1/assets/:assetId", async (req, res) => { return mindSpaceError(res, req, error); } }); -function messageText(message) { +function messageText2(message) { return (message?.content ?? []).filter((item) => item?.type === "text" && typeof item.text === "string").map((item) => item.text).join("").trim(); } async function resolveOwnedAssistantMessage(userId, sessionId, messageId) { @@ -24821,7 +34359,7 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) { if (!message) { throw Object.assign(new Error("\u6765\u6E90\u6D88\u606F\u4E0D\u5B58\u5728"), { code: "source_message_not_found" }); } - const content = messageText(message); + const content = messageText2(message); if (message.role !== "assistant" || !message.metadata?.userVisible || !content) { throw Object.assign(new Error("\u53EA\u6709\u53EF\u89C1\u7684 AI \u6587\u672C\u6D88\u606F\u53EF\u4EE5\u4FDD\u5B58\u4E3A\u9875\u9762"), { code: "invalid_source_message" @@ -24829,24 +34367,19 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) { } return { session, message, content }; } -var SAVE_TARGET_CATEGORIES = /* @__PURE__ */ new Set(["draft", "oa", "private", "public"]); -function assertPrivateSaveAllowed(categoryCode, privacyScan, acknowledgedFindingIds) { - if (categoryCode !== "private") return; - if (!privacyScan.allowed) { - throw Object.assign(new Error("\u5185\u5BB9\u542B\u963B\u65AD\u7EA7\u654F\u611F\u4FE1\u606F\uFF0C\u4E0D\u80FD\u4FDD\u5B58\u5230\u79C1\u4EBA\u533A"), { - code: "security_risk_blocked", - details: { findings: privacyScan.findings } - }); - } - if (privacyScan.findings.length === 0) return; - const acknowledged = new Set((acknowledgedFindingIds ?? []).map(String)); - const missing = privacyScan.findings.filter((finding) => !acknowledged.has(finding.id)); - if (missing.length > 0) { - throw Object.assign(new Error("\u4FDD\u5B58\u5230\u79C1\u4EBA\u533A\u524D\u9700\u786E\u8BA4\u654F\u611F\u4FE1\u606F\u63D0\u793A"), { - code: "private_ack_required", - details: { findings: missing } - }); - } +var SAVE_TARGET_CATEGORIES = /* @__PURE__ */ new Set(["draft", "oa", "public"]); +async function syncUserGeneratedPages(userId) { + if (!mindSpacePages || !authPool || !userId) return; + await syncGeneratedPagesFromPublicAssets({ + pool: authPool, + pageService: mindSpacePages, + assetService: mindSpaceAssets, + userId, + publishDir: resolvePublishDir(__dirname5, { 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); + }); } async function resolveChatSaveBundle(user, h5Root, input = {}) { const sessionId = input.sessionId ?? input.session_id; @@ -24855,13 +34388,47 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) { const previewTitle = String(input.previewTitle ?? input.preview_title ?? "").trim(); const previewSummary = String(input.previewSummary ?? input.preview_summary ?? "").trim(); const source = await resolveOwnedAssistantMessage(user.id, sessionId, messageId); - const { analysis, resolvedHtml } = await resolveChatSaveAnalysis({ + let { analysis, resolvedHtml } = await resolveChatSaveAnalysis({ content: source.content, userId: user.id, username: user.username, h5Root, selectedLinkIndex }); + if (resolvedHtml?.content && resolvedHtml.relativePath && authPool) { + const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"); + let htmlContent = resolvedHtml.content; + const repaired = await repairMissingHtmlAssetReferences({ + pool: authPool, + userId: user.id, + sourceContent: source.content, + html: htmlContent + }).catch(() => ({ html: htmlContent, changed: false })); + if (repaired.changed) { + htmlContent = repaired.html; + } + const materialized = await materializePrivateAssetsInWorkspaceHtml({ + pool: authPool, + storageRoot, + h5Root, + userId: user.id, + html: htmlContent, + htmlRelativePath: resolvedHtml.relativePath, + writeBack: false + }).catch(() => ({ html: htmlContent, count: 0, changed: false })); + if (materialized.changed) { + htmlContent = materialized.html; + } + if (htmlContent !== resolvedHtml.content) { + const publishDir = resolvePublishDir(h5Root, { id: user.id }); + await fsPromises3.writeFile( + path28.join(publishDir, resolvedHtml.relativePath), + htmlContent, + "utf8" + ); + resolvedHtml = { ...resolvedHtml, content: htmlContent }; + } + } return { source, analysis, @@ -24916,10 +34483,17 @@ api.get("/mindspace/v1/pages/chat-save-thumbnail", async (req, res) => { { title, subtitle, force: true } ).catch(() => { }); + const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"); + const resolveAssetDataUri = createAssetDataUriResolver( + authPool, + storageRoot, + req.currentUser.id + ); const svg = await generateHtmlThumbnail(publishDir, thumbRel, bundle.resolvedHtml.content, { title, subtitle, - contentBaseDir: path22.dirname(bundle.resolvedHtml.absolute), + contentBaseDir: path28.dirname(bundle.resolvedHtml.absolute), + resolveAssetDataUri, force: true }); res.set("Content-Type", "image/svg+xml; charset=utf-8"); @@ -24933,6 +34507,8 @@ api.get("/mindspace/v1/pages/chat-save-thumbnail", async (req, res) => { api.post("/mindspace/v1/pages/analyze-chat-save", async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); try { + const sessionId = req.body?.session_id; + const messageId = req.body?.message_id; const bundle = await resolveChatSaveBundle(req.currentUser, __dirname5, req.body); const { source, @@ -24959,6 +34535,11 @@ api.post("/mindspace/v1/pages/analyze-chat-save", async (req, res) => { thumbnailReady = false; } } + const existingPage = await mindSpacePages.findPageBySourceMessage( + req.currentUser.id, + sessionId, + messageId + ).catch(() => null); return sendData(res, req, { contentMode: analysis.contentMode, links: analysis.links, @@ -24974,12 +34555,45 @@ api.post("/mindspace/v1/pages/analyze-chat-save", async (req, res) => { relativePath: resolvedHtml?.relativePath ?? analysis.relativePath, filename: resolvedHtml?.filename ?? analysis.filename, hasHtmlContent: Boolean(resolvedHtml), - privacyScan: scanContent(resolvedHtml?.content ?? source.content) + privacyScan: scanContent(resolvedHtml?.content ?? source.content), + existingPage: existingPage ? { id: existingPage.id, title: existingPage.title, categoryCode: existingPage.categoryCode } : null }); } catch (error) { return mindSpaceError(res, req, error); } }); +api.post("/mindspace/v1/pages/quick-share-from-chat", async (req, res) => { + if (!mindSpacePages) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); + try { + const bundle = await resolveChatSaveBundle(req.currentUser, __dirname5, req.body); + console.log("[quick-share] bundle resolved, hasHtml:", Boolean(bundle.resolvedHtml)); + if (!bundle.resolvedHtml) { + throw Object.assign(new Error("\u65E0\u6CD5\u8BFB\u53D6\u94FE\u63A5\u9875\u9762\u5185\u5BB9"), { code: "static_page_not_found" }); + } + const storageRoot = process.env.MINDSPACE_STORAGE_ROOT ?? path28.join(__dirname5, "data", "mindspace"); + const { html: localizedHtml } = await inlinePrivateAssetsInHtml( + authPool, + storageRoot, + req.currentUser.id, + bundle.resolvedHtml.content + ); + const publishDir = resolvePublishDir(__dirname5, req.currentUser); + 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 sharedRelativePath = `${PUBLIC_ZONE_DIR}/shared/${filename}`; + const sharedHtml = rewriteWorkspacePublicAssetReferences(localizedHtml, sharedRelativePath); + const destPath = path28.join(sharedDir, filename); + await fsPromises3.writeFile(destPath, sharedHtml, "utf8"); + const publishKey = req.currentUser.id; + const publicUrl2 = `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/${sharedRelativePath.split("/").map((part) => encodeURIComponent(part)).join("/")}`; + return res.status(201).json({ data: { publicUrl: publicUrl2, filename } }); + } catch (error) { + console.error("[quick-share] error:", error); + return mindSpaceError(res, req, error); + } +}); api.post("/mindspace/v1/pages/save-from-chat", async (req, res) => { if (!mindSpacePages || !mindSpaceAssets) { return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); @@ -25014,7 +34628,6 @@ api.post("/mindspace/v1/pages/save-from-chat", async (req, res) => { resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null); } const privacyScan = scanContent(resolvedHtml?.content ?? source.content); - assertPrivateSaveAllowed(categoryCode, privacyScan, req.body?.acknowledged_finding_ids); if (categoryCode !== "draft") { let buffer; let filename; @@ -25079,11 +34692,21 @@ api.post("/mindspace/v1/pages/save-from-chat", async (req, res) => { }).catch(() => { }); } - const page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, { - sessionId: req.body.session_id, - messageId: req.body.message_id, - snapshot - }); + const replacePageId = req.body?.replace_page_id ? String(req.body.replace_page_id).trim() : null; + let page; + if (replacePageId) { + const existingPage = await mindSpacePages.getPage(req.currentUser.id, replacePageId); + page = await mindSpacePages.updatePage(req.currentUser.id, replacePageId, { + ...pageInput, + expectedVersion: existingPage.versionNo + }); + } else { + page = await mindSpacePages.createFromChat(req.currentUser.id, pageInput, { + sessionId: req.body.session_id, + messageId: req.body.message_id, + snapshot + }); + } return res.status(201).json({ data: { kind: "page", categoryCode: "draft", page } }); } catch (error) { return mindSpaceError(res, req, error); @@ -25109,6 +34732,7 @@ api.post("/mindspace/v1/pages", async (req, res) => { api.get("/mindspace/v1/pages", async (req, res) => { if (!mindSpacePages) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); try { + await syncUserGeneratedPages(req.currentUser.id); const pages = await mindSpacePages.listPages(req.currentUser.id, { status: typeof req.query.status === "string" ? req.query.status : void 0 }); @@ -25521,6 +35145,29 @@ api.post("/mindspace/v1/pages/:pageId/publish", async (req, res) => { return mindSpaceError(res, req, error); } }); +api.post("/mindspace/v1/publications/:publicationId/update-status", async (req, res) => { + if (!mindSpacePublications) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); + try { + const publication = await mindSpacePublications.updatePublicationStatus( + req.currentUser.id, + req.params.publicationId, + { + accessMode: req.body?.access_mode, + expiresAt: req.body?.expires_at + } + ); + await mindSpaceAudit?.write({ + userId: req.currentUser.id, + action: "publication.status_updated", + objectType: "publication", + objectId: req.params.publicationId, + ip: req.ip + }); + return sendData(res, req, publication); + } catch (error) { + return mindSpaceError(res, req, error); + } +}); api.post("/mindspace/v1/publications/:publicationId/offline", async (req, res) => { if (!mindSpacePublications) return res.status(503).json({ message: "MindSpace \u672A\u542F\u7528" }); try { @@ -25883,10 +35530,14 @@ api.get("/sessions/:sessionId", async (req, res, next) => { const mcMatch = hintMc == null || snapshot.meta.synced_msg_count === hintMc; const uaMatch = hintUa == null || snapshot.meta.source_updated_at === hintUa; if (mcMatch && uaMatch) { + const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks( + snapshot.messages, + req.currentUser + ); const cachedGooseSession = { ...snapshot.session, // Embed only userVisible messages so getSession callers still work. - conversation: snapshot.messages + conversation: sanitizedMessages }; return res.json(cachedGooseSession); } @@ -25906,10 +35557,18 @@ api.get("/sessions/:sessionId", async (req, res, next) => { return res.status(upstream.status).send(text); } const gooseSession = await upstream.json(); + if (Array.isArray(gooseSession.conversation)) { + gooseSession.conversation = sanitizeSessionConversationPublicHtmlLinks( + gooseSession.conversation, + req.currentUser + ); + } if (sessionSnapshotService?.isEnabled()) { const messages = (gooseSession.conversation ?? []).filter((m) => m.metadata?.userVisible); - void sessionSnapshotService.save(sessionId, req.currentUser.id, gooseSession, messages).catch(() => { - }); + if (messages.length > 0) { + void sessionSnapshotService.save(sessionId, req.currentUser.id, gooseSession, messages).catch(() => { + }); + } } return res.json(gooseSession); } catch (err) { @@ -25949,10 +35608,34 @@ 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 onAfterFinish = sessionSnapshotService?.isEnabled() ? (sid, uid) => sessionSnapshotService.refresh(sid, uid, async (pathname, init) => { - const target = await tkmindProxy.resolveTarget(sid); - return tkmindProxy.apiFetchTo(target, pathname, init); - }) : null; + const onAfterFinish = async (sid, uid) => { + const apiFetchFn = async (pathname, init) => { + const target = await tkmindProxy.resolveTarget(sid); + return tkmindProxy.apiFetchTo(target, pathname, init); + }; + let messages = null; + if (sessionSnapshotService?.isEnabled()) { + await sessionSnapshotService.refresh(sid, uid, apiFetchFn); + messages = (await sessionSnapshotService.get(sid))?.messages ?? null; + } else { + try { + const upstream = await apiFetchFn(`/sessions/${encodeURIComponent(sid)}`, { method: "GET" }); + if (upstream.ok) { + const payload = await upstream.json().catch(() => null); + messages = Array.isArray(payload?.conversation) ? payload.conversation.filter((message) => message?.metadata?.userVisible) : null; + } + } catch { + messages = null; + } + } + await syncPublicHtmlAfterFinish({ + messages, + currentUser: req.currentUser, + publishDir: resolvePublishDir(__dirname5, { id: uid }), + syncWorkspaceAssets: WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets ? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options) : null + }); + await syncUserGeneratedPages(uid); + }; return tkmindProxy.proxySessionEvents(req, res, sessionId, { onAfterFinish }); }); api.use(async (req, res, next) => { @@ -26033,19 +35716,62 @@ api.use( }) ); app.use("/api", api); -function publishedPageCsp(html, { embed = false, raw = false } = {}) { +function scriptSrcDirective({ inline = false, urls = [], hashes = [] } = {}) { + const parts = []; + if (inline) parts.push("'unsafe-inline'"); + for (const hash of hashes) parts.push(`'sha256-${hash}'`); + for (const url of urls) parts.push(url); + return parts.length ? `script-src ${parts.join(" ")}` : "script-src 'none'"; +} +function publishedPageCsp(html, { embed = false, raw = false, wechatShare = false, scriptHashes = [] } = {}) { const isFullHtml = /^\s*]/i.test(html); if (embed && isFullHtml) { return publishedPageCspForEmbed(true); } + if (wechatShare && isFullHtml) { + return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline' https://res.wx.qq.com"; + } if (raw && isFullHtml) { - return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'"; + return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'"; } if (isFullHtml) { - return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'none'"; + return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({ hashes: scriptHashes })}`; } 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"); +function injectPublicFileShareButton(html) { + const source = String(html ?? ""); + if (!source || source.includes("data-mindspace-public-share")) { + return { html: source, scriptHashes: [] }; + } + const markup = ` + +
+`; + if (/<\/body>/i.test(source)) { + return { + html: source.replace(/<\/body>/i, `${markup}`), + scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH] + }; + } + return { + html: `${source}${markup}`, + scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH] + }; +} +function extractOgImageUrl(html) { + return String(html ?? "").match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] || String(html ?? "").match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)?.[1] || ""; +} function appendQueryParam(url, key, value) { const separator = url.includes("?") ? "&" : "?"; return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`; @@ -26427,36 +36153,56 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) { } function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) { let html = result.html; + const origin = resolveRequestOrigin(req); + const pageUrl = req.originalUrl ? new URL(req.originalUrl, origin || "http://localhost").toString().split("#")[0] : ""; + const pageDirUrl = pageUrl ? `${pageUrl.slice(0, pageUrl.lastIndexOf("/") + 1)}` : ""; + const wechatShare = !embed && isWechatUserAgent(req.get("user-agent") || ""); if (embed) { html = preparePublicationHtmlForEmbed(html); allowPlazaEmbedFrame(res); } else if (raw) { html = stripPublicationHtmlCspMeta(html); } + if (!embed) { + try { + html = injectOgTags(html, { origin, pageUrl, pageDirUrl }); + if (wechatShare) html = injectWechatShareBridge(html, { pageUrl }); + } catch { + } + } const isFullHtml = /^\s*]/i.test(html); const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== "password"; if (canWrapWithShell) { const title = detectPublishedPageTitle(html); const rawUrl = appendQueryParam(req.originalUrl || req.url || "", "view", "raw"); + let shellHtml = publishedPageShellHtml({ + iframeUrl: rawUrl, + shareUrl: pageUrl, + title + }); + try { + shellHtml = injectOgTags(shellHtml, { + origin, + pageUrl, + pageDirUrl, + fallbackImageUrl: extractOgImageUrl(html) + }); + if (wechatShare) shellHtml = injectWechatShareBridge(shellHtml, { pageUrl }); + } catch { + } res.set("Content-Type", "text/html; charset=utf-8"); res.set( "Content-Security-Policy", - "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" + wechatShare ? "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net https://res.wx.qq.com; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" : "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" ); res.set( "Cache-Control", result.publication.accessMode === "public" ? "public, max-age=60" : "private, no-store" ); - return res.send( - publishedPageShellHtml({ - iframeUrl: rawUrl, - shareUrl: req.originalUrl ? new URL(req.originalUrl, resolveRequestOrigin(req) || "http://localhost").toString() : "", - title - }) - ); + return res.send(shellHtml); } res.set("Content-Type", "text/html; charset=utf-8"); - res.set("Content-Security-Policy", publishedPageCsp(html, { embed, raw })); + res.set("Content-Security-Policy", publishedPageCsp(html, { embed, raw, wechatShare })); res.set( "Cache-Control", result.publication.accessMode === "public" ? "public, max-age=60" : "private, no-store" @@ -26693,7 +36439,7 @@ function sendPublishFile(req, res, filePath) { } let html; try { - html = fs23.readFileSync(filePath, "utf8"); + html = fs26.readFileSync(filePath, "utf8"); } catch { res.status(404).json({ message: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); return; @@ -26710,20 +36456,32 @@ function sendPublishFile(req, res, filePath) { const proto = isLocalHost ? fwdProto || req.protocol || "http" : "https"; const origin = host ? `${proto}://${host}` : ""; const cleanPath = req.originalUrl.split("?")[0].split("#")[0]; - const servedName = path22.basename(filePath); + const servedName = path28.basename(filePath); const urlLast = decodeURIComponent(cleanPath.split("/").filter(Boolean).pop() ?? ""); const isImplicitIndex = urlLast.toLowerCase() !== servedName.toLowerCase(); const pageUrl = origin ? `${origin}${isImplicitIndex && !cleanPath.endsWith("/") ? `${cleanPath}/` : cleanPath}` : ""; const pageDirUrl = !origin ? "" : isImplicitIndex ? `${origin}${cleanPath.endsWith("/") ? cleanPath : `${cleanPath}/`}` : `${origin}${cleanPath.slice(0, cleanPath.lastIndexOf("/") + 1)}`; let fallbackImageUrl = ""; const svgSibling = filePath.replace(/\.[^./]+$/, ".thumbnail.svg"); - if (pageDirUrl && fs23.existsSync(svgSibling)) { - const pngName = path22.basename(thumbnailPngPathForSvg(svgSibling)); + if (pageDirUrl && fs26.existsSync(svgSibling)) { + const pngName = path28.basename(thumbnailPngPathForSvg(svgSibling)); fallbackImageUrl = `${pageDirUrl}${pngName}`; } try { html = injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl }); + const wechatShare = !embed && isWechatUserAgent(req.get("user-agent") || ""); + if (wechatShare) { + html = injectWechatShareBridge(html, { pageUrl }); + } + const shareInjection = !embed ? injectPublicFileShareButton(html) : { html, scriptHashes: [] }; + html = shareInjection.html; + res.set("Content-Security-Policy", publishedPageCsp(html, { + embed, + wechatShare, + scriptHashes: shareInjection.scriptHashes + })); } catch { + res.set("Content-Security-Policy", publishedPageCsp(html, { embed })); } res.set("Content-Type", "text/html; charset=utf-8"); res.send(html); @@ -26733,27 +36491,34 @@ async function recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest) { if (rest.length !== 2 || rest[0] !== PUBLIC_ZONE_DIR) return null; const filename = rest[1]; if (!MISPLACED_PUBLIC_HTML_NAME.test(filename)) return null; - const destination = path22.resolve(targetDir, PUBLIC_ZONE_DIR, filename); - if (!destination.startsWith(`${resolvedRoot}${path22.sep}`)) return null; + const destination = path28.resolve(targetDir, PUBLIC_ZONE_DIR, filename); + if (!destination.startsWith(`${resolvedRoot}${path28.sep}`)) return null; const candidates = [ - path22.resolve(__dirname5, filename), - path22.resolve(__dirname5, PUBLIC_ZONE_DIR, filename) + path28.resolve(__dirname5, filename), + path28.resolve(__dirname5, PUBLIC_ZONE_DIR, filename) ]; for (const candidate of candidates) { if (candidate === destination) continue; - if (!candidate.startsWith(`${__dirname5}${path22.sep}`)) continue; - if (!fs23.existsSync(candidate) || !fs23.statSync(candidate).isFile()) continue; - fs23.mkdirSync(path22.dirname(destination), { recursive: true }); - fs23.copyFileSync(candidate, destination); + if (!candidate.startsWith(`${__dirname5}${path28.sep}`)) continue; + if (!fs26.existsSync(candidate) || !fs26.statSync(candidate).isFile()) continue; + fs26.mkdirSync(path28.dirname(destination), { recursive: true }); + fs26.copyFileSync(candidate, destination); await ensureWorkspaceHtmlThumbnail(targetDir, `${PUBLIC_ZONE_DIR}/${filename}`).catch(() => { }); console.warn( - `[MindSpace] recovered misplaced public HTML ${path22.relative(__dirname5, candidate)} -> ${path22.relative(__dirname5, destination)}` + `[MindSpace] recovered misplaced public HTML ${path28.relative(__dirname5, candidate)} -> ${path28.relative(__dirname5, destination)}` ); return destination; } return null; } +function decodePathSegment3(segment) { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} async function serveUserPublishFile(req, res, next) { const parts = req.path.split("/").filter(Boolean); if (parts.length < 1) { @@ -26771,34 +36536,34 @@ async function serveUserPublishFile(req, res, next) { res.redirect(301, target); return; } - const [username, ...rest] = [dirKey, ...parts.slice(1)]; - const targetDir = path22.join(__dirname5, PUBLISH_ROOT_DIR, username); - const resolvedRoot = path22.resolve(targetDir); - const filePath = path22.join(targetDir, ...rest); - const resolvedPath = path22.resolve(filePath); - if (!resolvedPath.startsWith(`${resolvedRoot}${path22.sep}`) && resolvedPath !== resolvedRoot) { + const [username, ...rest] = [dirKey, ...parts.slice(1).map(decodePathSegment3)]; + const targetDir = path28.join(__dirname5, PUBLISH_ROOT_DIR, username); + const resolvedRoot = path28.resolve(targetDir); + const filePath = path28.join(targetDir, ...rest); + const resolvedPath = path28.resolve(filePath); + if (!resolvedPath.startsWith(`${resolvedRoot}${path28.sep}`) && resolvedPath !== resolvedRoot) { res.status(403).json({ message: "\u7981\u6B62\u8BBF\u95EE" }); return; } - if (!fs23.existsSync(targetDir)) { + if (!fs26.existsSync(targetDir)) { res.status(404).json({ message: "\u7528\u6237\u4E0D\u5B58\u5728" }); return; } if (/\.thumbnail\.png$/i.test(resolvedPath)) { const svgSibling = resolvedPath.replace(/\.png$/i, ".svg"); - if (fs23.existsSync(svgSibling)) { + if (fs26.existsSync(svgSibling)) { const pngPath = ensureThumbnailPng(svgSibling); - if (pngPath && fs23.existsSync(pngPath)) { + if (pngPath && fs26.existsSync(pngPath)) { res.set("Cache-Control", "public, max-age=300"); res.sendFile(pngPath); return; } } } - if (!fs23.existsSync(resolvedPath)) { + if (!fs26.existsSync(resolvedPath)) { if (rest.length === 1 && rest[0].toLowerCase().endsWith(".html")) { - const publicFallback = path22.resolve(targetDir, PUBLIC_ZONE_DIR, rest[0]); - if (publicFallback.startsWith(`${resolvedRoot}${path22.sep}`) && fs23.existsSync(publicFallback) && fs23.statSync(publicFallback).isFile()) { + const publicFallback = path28.resolve(targetDir, PUBLIC_ZONE_DIR, rest[0]); + if (publicFallback.startsWith(`${resolvedRoot}${path28.sep}`) && fs26.existsSync(publicFallback) && fs26.statSync(publicFallback).isFile()) { const canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${PUBLIC_ZONE_DIR}/${encodeURIComponent(rest[0])}`; res.redirect(301, canonical); return; @@ -26820,9 +36585,9 @@ async function serveUserPublishFile(req, res, next) { res.status(404).json({ message: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); return; } - if (fs23.statSync(resolvedPath).isDirectory()) { - const indexPath = path22.join(resolvedPath, "index.html"); - if (fs23.existsSync(indexPath)) { + if (fs26.statSync(resolvedPath).isDirectory()) { + const indexPath = path28.join(resolvedPath, "index.html"); + if (fs26.existsSync(indexPath)) { sendPublishFile(req, res, indexPath); return; } @@ -26843,22 +36608,22 @@ app.use("/user", async (req, res, next) => { const parts = req.path.split("/").filter(Boolean); if (parts.length < 1) return next(); const [username, ...rest] = parts; - const targetDir = path22.join(USERS_ROOT, username); - const filePath = path22.join(targetDir, ...rest); - const resolvedRoot = path22.resolve(targetDir); - const resolvedPath = path22.resolve(filePath); - if (!resolvedPath.startsWith(resolvedRoot + path22.sep) && resolvedPath !== resolvedRoot) { + const targetDir = path28.join(USERS_ROOT, username); + const filePath = path28.join(targetDir, ...rest); + const resolvedRoot = path28.resolve(targetDir); + const resolvedPath = path28.resolve(filePath); + if (!resolvedPath.startsWith(resolvedRoot + path28.sep) && resolvedPath !== resolvedRoot) { return res.status(403).json({ message: "\u7981\u6B62\u8BBF\u95EE" }); } - if (!fs23.existsSync(targetDir)) { + if (!fs26.existsSync(targetDir)) { return res.status(404).json({ message: "\u7528\u6237\u4E0D\u5B58\u5728" }); } - if (!fs23.existsSync(resolvedPath)) { + if (!fs26.existsSync(resolvedPath)) { return res.status(404).json({ message: "\u6587\u4EF6\u4E0D\u5B58\u5728" }); } - if (fs23.statSync(resolvedPath).isDirectory()) { - const indexPath = path22.join(resolvedPath, "index.html"); - if (fs23.existsSync(indexPath)) { + if (fs26.statSync(resolvedPath).isDirectory()) { + const indexPath = path28.join(resolvedPath, "index.html"); + if (fs26.existsSync(indexPath)) { return res.sendFile(indexPath); } return res.status(404).json({ message: "\u76EE\u5F55\u4E2D\u6CA1\u6709 index.html" }); @@ -26868,30 +36633,40 @@ app.use("/user", async (req, res, next) => { }); }); app.get(`/${PUBLISH_ROOT_DIR}/wiki/*`, (_req, res) => { - res.sendFile(path22.join(__dirname5, PUBLISH_ROOT_DIR, "wiki", "index.html")); + res.sendFile(path28.join(__dirname5, PUBLISH_ROOT_DIR, "wiki", "index.html")); }); app.get("/temp/wiki/*", (req, res) => { res.redirect(301, `/${PUBLISH_ROOT_DIR}/wiki${req.url.slice("/temp/wiki".length)}`); }); app.use( "/plaza-covers", - express2.static(path22.join(__dirname5, "public/plaza-covers"), { maxAge: "7d" }) + express2.static(path28.join(__dirname5, "public/plaza-covers"), { maxAge: "7d" }) ); app.get(/^\/MP_verify_[A-Za-z0-9]+\.txt$/, (req, res) => { - const fileName = path22.basename(req.path); - const filePath = path22.join(__dirname5, "public", fileName); - if (!fs23.existsSync(filePath)) return res.status(404).end(); + const fileName = path28.basename(req.path); + const filePath = path28.join(__dirname5, "public", fileName); + if (!fs26.existsSync(filePath)) return res.status(404).end(); res.type("text/plain").sendFile(filePath); }); -app.use(express2.static(path22.join(__dirname5, "dist"), { index: "index.html" })); +app.use("/auth", (_req, res) => { + res.status(404).json({ message: "\u63A5\u53E3\u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u542F\u540E\u7AEF\u670D\u52A1\uFF08node server.mjs \u6216 pnpm dev\uFF09" }); +}); +app.use("/admin-api", (_req, res) => { + res.status(404).json({ message: "\u63A5\u53E3\u4E0D\u5B58\u5728\uFF0C\u8BF7\u91CD\u542F\u540E\u7AEF\u670D\u52A1\uFF08node server.mjs \u6216 pnpm dev\uFF09" }); +}); +app.use(express2.static(path28.join(__dirname5, "dist"), { index: "index.html" })); app.get("*", (_req, res) => { - res.sendFile(path22.join(__dirname5, "dist", "index.html")); + res.sendFile(path28.join(__dirname5, "dist", "index.html")); }); userAuthReady.then((enabled) => { - app.listen(PORT, "127.0.0.1", () => { - console.log(`TKMind H5 @ http://127.0.0.1:${PORT}`); + if (isDatabaseConfigured() && !enabled && process.env.NODE_ENV === "production") { + console.error("Refusing to start portal without user auth while database is configured"); + process.exit(1); + } + app.listen(PORT, HOST, () => { + console.log(`TKMind H5 @ http://${HOST}:${PORT}`); console.log(`Proxy -> ${API_TARGETS.join(", ")}`); console.log(`Auth -> ${enabled ? "multi-user (MySQL)" : legacyAuth ? "legacy password" : "disabled"}`); - console.log(`Wiki @ http://127.0.0.1:${PORT}/${PUBLISH_ROOT_DIR}/wiki`); + console.log(`Wiki @ http://${HOST}:${PORT}/${PUBLISH_ROOT_DIR}/wiki`); }); }); diff --git a/.runtime/portal/skills/code-playground/SKILL.md b/.runtime/portal/skills/code-playground/SKILL.md new file mode 100644 index 0000000..aa68e64 --- /dev/null +++ b/.runtime/portal/skills/code-playground/SKILL.md @@ -0,0 +1,33 @@ +--- +name: code-playground +description: 代码展示与运行技能:在聊天中展示代码并支持 JavaScript 沙盒执行(需启用 codeplayground 扩展) +--- + +# 代码演练场 + +使用 `codeplayground` MCP 扩展在聊天中展示可运行的代码片段。 + +## 何时使用 + +- 生成代码示例后,让用户直接在聊天里运行验证 +- 展示算法、数据处理逻辑 +- JavaScript 演示(可执行);其他语言(展示+复制) + +## 前提 + +Settings → Extensions 中启用 **Code Playground** 扩展。 + +## 工具 + +`show_playground(title, code, language, description?)` — 展示代码并可选执行 + +## 支持语言 + +- **JavaScript** — 有「运行」按钮,`console.log` 输出显示在下方 +- 其他语言(`rust`、`python`、`typescript` 等)— 展示+语法高亮+复制按钮 + +## 规则 + +1. JS 执行在沙盒 iframe 中,无网络访问,无 DOM 操作 +2. 代码不超过 200 行,超出拆分展示 +3. 有副作用(写文件、网络请求)的代码标注「仅供参考,不可直接运行」 diff --git a/.runtime/portal/skills/diff-viewer/SKILL.md b/.runtime/portal/skills/diff-viewer/SKILL.md new file mode 100644 index 0000000..f2bd60b --- /dev/null +++ b/.runtime/portal/skills/diff-viewer/SKILL.md @@ -0,0 +1,28 @@ +--- +name: diff-viewer +description: 代码对比可视化技能:以高亮 diff 形式展示文件修改前后(需启用 diffviewer 扩展) +--- + +# Diff 查看器 + +使用 `diffviewer` MCP 扩展在聊天中渲染代码修改对比。 + +## 何时使用 + +- 用户要看代码改动前后对比 +- 展示重构、bug 修复、配置变更的效果 +- 比 markdown 代码块更直观地呈现变更 + +## 前提 + +Settings → Extensions 中启用 **Diff Viewer** 扩展。 + +## 工具 + +`show_diff(title, old_content, new_content, language?)` — 渲染带行号的 diff + +## 规则 + +1. `language` 填文件类型(`rust`、`typescript`、`python` 等),用于语法提示 +2. 内容太大时只截取关键改动部分,加说明 +3. 配合 `git_diff` 工具使用效果最佳 diff --git a/.runtime/portal/skills/docx-generate/SKILL.md b/.runtime/portal/skills/docx-generate/SKILL.md new file mode 100644 index 0000000..4dbf011 --- /dev/null +++ b/.runtime/portal/skills/docx-generate/SKILL.md @@ -0,0 +1,80 @@ +--- +name: docx-generate +description: 在工作区内用 Python 标准库(zipfile + XML)生成 Word .docx,无需 python-docx 或 docx_tool +--- + +# Word 文档生成(.docx) + +## 重要说明 + +- **平台没有 `docx_tool` / `update_doc`**,不要编造或调用不存在的工具 +- **`write_file` 不能写二进制 .docx**,不要用 write_file 假装生成 Word +- **禁止**在 HTML 里用 `data:application/...;base64,...` 内嵌 docx(极易被截断损坏,下载后乱码) +- 需要公网下载时:先用本脚本生成 `.docx` 落盘,再在 HTML 里用**相对路径**链接(如 `public/方案.docx`) +- 生产沙箱通常**不能 pip install**,优先用本技能自带的 **stdlib 脚本** +- 生成后必须用 **`list_dir oa/`**(或目标目录)确认文件已落盘,再告诉用户 + +## 何时使用 + +- 用户要 Word / docx / .doc 文档(输出 `.docx`) +- 需要保存到 `oa/`、`private/` 等分区 + +## 推荐命令 + +技能目录内有 `generate_docx.py`(仅依赖 Python 3 标准库): + +```bash +python3 .agents/skills/docx-generate/generate_docx.py --json - --output oa/报告.docx <<'EOF' +{ + "title": "文档标题", + "sections": [ + { + "heading": "一、章节标题", + "paragraphs": ["段落一", "段落二"], + "table": { + "headers": ["列1", "列2"], + "rows": [["A", "B"], ["C", "D"]] + } + } + ] +} +EOF +``` + +然后: + +```bash +list_dir oa +``` + +## JSON 字段 + +| 字段 | 说明 | +|------|------| +| `title` | 文档主标题(可选) | +| `sections[]` | 章节数组 | +| `sections[].heading` | 章节标题 | +| `sections[].paragraphs` | 字符串段落列表 | +| `sections[].table.headers` | 表头 | +| `sections[].table.rows` | 表格行 | + +**禁止**把 `` 等 OOXML 标签写进 `paragraphs` 文本里;表格只能走 `table` 字段。 + +## 公网下载页(HTML + docx) + +用户要「打开链接下载 Word」时: + +1. 用本脚本生成 docx(建议 `public/文件名.docx` 或 `oa/文件名.docx` 再复制到 `public/`) +2. 用 `static-page-publish` 写下载页,`href` 指向**同目录相对路径**: + +```html +下载 Word 文档 +``` + +3. **禁止** `href="data:...;base64,..."` 嵌入 docx + +## 备选方案 + +1. **用户要在线查看、可分享**:用 `static-page-publish` 技能写 `public/xxx.html` +2. **环境有 python-docx**(工作区 `.venv` 已装):仍可用,但生成后必须 `list_dir` 验证 +3. **禁止**在未验证文件存在时宣称「已生成 docx」 diff --git a/.runtime/portal/skills/docx-generate/generate_docx.py b/.runtime/portal/skills/docx-generate/generate_docx.py new file mode 100644 index 0000000..ff28077 --- /dev/null +++ b/.runtime/portal/skills/docx-generate/generate_docx.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Generate a minimal valid .docx using only Python stdlib (zipfile + XML).""" + +from __future__ import annotations + +import argparse +import json +import sys +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" +R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +CT_NS = "http://schemas.openxmlformats.org/package/2006/content-types" +REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +CP_NS = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties" +DC_NS = "http://purl.org/dc/elements/1.1" + +CONTENT_TYPES = f""" + + + + + +""" + +ROOT_RELS = f""" + + + +""" + +DOCUMENT_RELS = f""" +""" + + +def sanitize_text(text: str) -> str: + """Strip emoji and XML-illegal control chars (Word/iOS rejects these).""" + cleaned = str(text or "") + cleaned = cleaned.encode("utf-8", "ignore").decode("utf-8") + cleaned = "".join( + ch + for ch in cleaned + if not (0xD800 <= ord(ch) <= 0xDFFF) + and (ord(ch) == 0x9 or ord(ch) == 0xA or ord(ch) == 0xD or ord(ch) >= 0x20) + and not (0x1F000 <= ord(ch) <= 0x1FAFF or 0x2600 <= ord(ch) <= 0x27BF) + ) + return cleaned.strip() + + +def xml_text(text: str) -> str: + return escape(sanitize_text(text)) + + +def run(text: str, bold: bool = False) -> str: + props = "" if bold else "" + return ( + f'{props}' + f'{xml_text(text)}' + ) + + +def paragraph(parts: str, style: str | None = None) -> str: + ppr = f'' if style else "" + return f"{ppr}{parts}" + + +def heading(text: str) -> str: + cleaned = sanitize_text(text) + if not cleaned: + return "" + return paragraph(run(cleaned, bold=True)) + + +def body_paragraph(text: str) -> str: + cleaned = sanitize_text(text) + if not cleaned: + return "" + return paragraph(run(cleaned)) + + +def is_ooxml_fragment(text: str) -> bool: + stripped = str(text or "").strip() + return stripped.startswith(" str: + col_count = max(len(headers), max((len(r) for r in rows), default=0)) + if col_count == 0: + return "" + + col_width = max(1800, 9000 // col_count) + + def cell(text: str) -> str: + return ( + f'' + f"{paragraph(run(text))}" + ) + + def table_row(cells: list[str]) -> str: + padded = cells + [""] * (col_count - len(cells)) + return f"{''.join(cell(c) for c in padded[:col_count])}" + + grid_cols = "".join(f'' for _ in range(col_count)) + tbl_pr = ( + "" + '' + "" + '' + '' + '' + '' + '' + '' + "" + "" + ) + # Table must be a direct child of w:body — never wrap w:tbl inside w:p. + parts = ["", tbl_pr, f"{grid_cols}"] + if headers: + parts.append(table_row(headers)) + parts.extend(table_row(r) for r in rows) + parts.append("") + return "".join(parts) + + +def build_document_xml(title: str, sections: list[dict]) -> str: + body: list[str] = [] + if title: + block = heading(title) + if block: + body.append(block) + + for section in sections: + heading_text = section.get("heading") or section.get("title") + if heading_text: + block = heading(str(heading_text)) + if block: + body.append(block) + for para in section.get("paragraphs") or []: + text = str(para).strip() + if text and not is_ooxml_fragment(text): + block = body_paragraph(text) + if block: + body.append(block) + table = section.get("table") + if isinstance(table, dict): + block = table_block( + list(table.get("headers") or []), + [list(r) for r in (table.get("rows") or [])], + ) + if block: + body.append(block) + + sect_pr = ( + f'' + f'' + ) + inner = "".join(body) + sect_pr + return ( + f'' + f'' + f"{inner}" + ) + + +def core_xml(title: str) -> str: + return ( + f'' + f'' + f"{xml_text(title)}" + f"" + ) + + +def write_docx(output: Path, title: str, sections: list[dict]) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + document_xml = build_document_xml(title, sections) + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.writestr("[Content_Types].xml", CONTENT_TYPES) + zf.writestr("_rels/.rels", ROOT_RELS) + zf.writestr("word/_rels/document.xml.rels", DOCUMENT_RELS) + zf.writestr("word/document.xml", document_xml) + zf.writestr("docProps/core.xml", core_xml(title)) + + +def load_payload(args: argparse.Namespace) -> dict: + if args.json: + source = sys.stdin.read() if args.json == "-" else Path(args.json).read_text(encoding="utf-8") + return json.loads(source) + if args.title: + return {"title": args.title, "sections": [{"paragraphs": args.paragraph or []}]} + raise SystemExit("需要 --json (或 - 读 stdin),或 --title") + + +def main() -> None: + parser = argparse.ArgumentParser(description="用 Python 标准库生成 .docx(无需 python-docx)") + parser.add_argument("--output", required=True, help="输出路径,如 oa/报告.docx") + parser.add_argument("--json", help="JSON 内容文件;传 - 则从 stdin 读取") + parser.add_argument("--title", help="仅标题 + --paragraph 时的文档标题") + parser.add_argument("--paragraph", action="append", help="配合 --title 追加段落") + args = parser.parse_args() + + payload = load_payload(args) + title = str(payload.get("title") or "") + sections = list(payload.get("sections") or []) + output = Path(args.output) + write_docx(output, title, sections) + size = output.stat().st_size + print(f"已生成 {output}({size} 字节)") + + +if __name__ == "__main__": + main() diff --git a/.runtime/portal/skills/form-builder/SKILL.md b/.runtime/portal/skills/form-builder/SKILL.md new file mode 100644 index 0000000..829d9cb --- /dev/null +++ b/.runtime/portal/skills/form-builder/SKILL.md @@ -0,0 +1,38 @@ +--- +name: form-builder +description: 动态表单技能:向用户展示结构化输入表单,收集多字段信息(需启用 formbuilder 扩展) +--- + +# 表单收集 + +使用 `formbuilder` MCP 扩展在聊天中展示交互式表单,替代多轮逐一询问。 + +## 何时使用 + +- 需要收集 3 个以上结构化字段 +- 有下拉选项、必填校验的场景 +- 配置向导、信息录入、参数填写 + +## 前提 + +Settings → Extensions 中启用 **Form Builder** 扩展。 + +## 工具 + +`show_form(title, description?, fields)` — 渲染表单并等待用户提交 + +## 字段类型 + +| type | 说明 | +|------|------| +| `text` | 单行文本 | +| `number` | 数字 | +| `textarea` | 多行文本 | +| `select` | 下拉选择(需提供 `options`) | +| `checkbox` | 勾选框 | + +## 规则 + +1. 用户提交后,结果以 JSON 回传,解析后继续任务 +2. `required: true` 的字段前端会校验,不会提交空值 +3. 表单不超过 8 个字段,太多拆成多步 diff --git a/.runtime/portal/skills/git/SKILL.md b/.runtime/portal/skills/git/SKILL.md new file mode 100644 index 0000000..f7acaee --- /dev/null +++ b/.runtime/portal/skills/git/SKILL.md @@ -0,0 +1,30 @@ +--- +name: git +description: Git 操作技能:查看状态、diff、提交历史、blame、提交代码 +--- + +# Git 操作 + +使用内置 `git` 平台扩展完成常见版本控制任务。 + +## 何时使用 + +- 用户询问代码改动、提交历史、谁修改了某行 +- 用户要提交代码、查看 diff、切换分支 + +## 工具 + +| 工具 | 用途 | +|------|------| +| `git_status` | 查看工作区状态和当前分支 | +| `git_diff` | 查看未暂存或已暂存的改动 | +| `git_log` | 查看提交历史 | +| `git_blame` | 查看指定文件某几行的最后修改人 | +| `git_commit` | 暂存指定文件并提交(自动添加 -s 签名) | + +## 规则 + +1. `git_commit` 前先用 `git_status` 确认改动范围,避免误提交 +2. 提交信息用英文、简短、说明 why 而非 what +3. 不要提交 `.env`、密钥、大二进制文件 +4. `git_diff` 的 `staged: true` 查看已暂存内容,默认查看未暂存 diff --git a/.runtime/portal/skills/kanban/SKILL.md b/.runtime/portal/skills/kanban/SKILL.md new file mode 100644 index 0000000..d36de83 --- /dev/null +++ b/.runtime/portal/skills/kanban/SKILL.md @@ -0,0 +1,43 @@ +--- +name: kanban +description: 看板可视化技能:将任务列表渲染为多列看板(需启用 kanban 扩展) +--- + +# 看板展示 + +使用 `kanban` MCP 扩展在聊天中渲染任务看板。 + +## 何时使用 + +- 用户要查看项目进度、任务分布 +- 展示待办/进行中/已完成三阶段状态 +- 多个任务需要按优先级可视化 + +## 前提 + +Settings → Extensions 中启用 **Kanban Board** 扩展。 + +## 工具 + +`show_kanban(title, columns)` — 渲染 N 列看板 + +## 数据结构 + +``` +columns: [ + { name: "待处理", cards: [ + { id: "1", title: "修复登录 bug", priority: "high", description: "..." } + ]}, + { name: "进行中", cards: [...] }, + { name: "已完成", cards: [...] } +] +``` + +## 优先级 + +`high`(红)/ `medium`(黄)/ `low`(灰) + +## 规则 + +1. 列数不限,但超过 5 列会很挤,建议合并 +2. 每列卡片不超过 20 张,否则截断并说明总数 diff --git a/.runtime/portal/skills/product-campaign-page/SKILL.md b/.runtime/portal/skills/product-campaign-page/SKILL.md new file mode 100644 index 0000000..c1ffe14 --- /dev/null +++ b/.runtime/portal/skills/product-campaign-page/SKILL.md @@ -0,0 +1,72 @@ +--- +name: product-campaign-page +description: 商品宣传 / 活动页技能:基于商品链接、主题、图片素材和卖点,生成可购买跳转的 H5 商品宣传页或活动落地页 +--- + +# 商品宣传 / 活动页 + +本技能用于帮助电商商家、品牌主、达人或运营人员,把一个商品链接、商品信息和图片素材重新包装成更适合传播与转化的商品宣传页、活动页或 H5 落地页。 + +## 何时使用 + +- 用户要「做商品宣传页 / 活动页 / 产品落地页 / 带购买按钮的页面」 +- 用户提供商品链接,希望包装成更好看的推广页面 +- 用户上传商品图、Logo、模特图、详情图,希望生成页面文案和视觉结构 +- 用户要做促销、上新、种草、达人推荐、节日活动或私域转化页面 + +## 必问信息 + +如果用户没有一次性给全,优先只问这 3 个问题: + +1. 商品链接是什么?购买按钮会跳转到这个链接。 +2. 希望页面是什么主题或风格?例如高级感、夏日清爽、科技感、国潮、节日促销、达人种草。 +3. 有没有要上传的商品图、Logo、模特图、详情图或参考图? + +需要生成完整页面时,再补问: + +- 商品名称 +- 目标人群 +- 3-5 个真实卖点 +- 价格、优惠、限时活动、赠品或库存信息 +- 品牌色、禁用词、平台限制或合规要求 + +## 工作流 + +1. 先整理输入 + - 把商品链接作为唯一购买跳转地址。 + - 从用户文本和图片中提取商品名、主题、卖点、价格与素材线索。 + - 不要编造价格、销量、认证、功效、明星背书或平台承诺。 + +2. 定义包装方向 + - 给出一句清晰的商品定位。 + - 说明目标人群、核心痛点、购买理由和活动钩子。 + - 如果主题不明确,给用户 2-3 个可选方向。 + +3. 设计页面结构 + - 首屏必须直接出现商品或活动主张。 + - 至少包含:Hero、卖点区、使用场景或信任背书、活动优惠、底部购买 CTA。 + - 主 CTA 和底部 CTA 都必须跳转到用户提供的商品链接。 + +4. 生成页面或文案 + - 如果用户只要策划,输出页面结构、标题、副标题、卖点文案、CTA 文案和视觉方向。 + - 如果用户要 H5 页面,使用 `static-page-publish` 技能在用户专属 MindSpace 发布目录生成静态 HTML,并返回 Markdown 可点击链接。 + - 生成 HTML 时必须写入与商品主题一致的 `mindspace-cover` 元数据,便于信息流生成封面。 + +## 页面标准 + +- 首屏要看到商品名、核心利益点和购买按钮。 +- 页面视觉必须服务商品主题,不要套用无关模板。 +- 文案要具体,避免「高端大气」「品质保证」这类空泛表达。 +- 有图片时优先使用用户上传素材;没有图片时明确标注需要补充的图片位置。 +- 活动信息未知时用可替换占位,不要虚构折扣、倒计时或库存。 +- 不处理支付,不模仿第三方平台收银台,只跳转原始商品链接。 + +## 推荐页面结构 + +1. Hero 首屏:商品名、主标题、副标题、商品图、立即购买按钮。 +2. 场景痛点:用户为什么需要它。 +3. 核心卖点:3-5 个真实利益点。 +4. 使用场景:生活方式、达人推荐、对比或人群场景。 +5. 活动优惠:价格、优惠券、赠品、限时利益点。 +6. 信任背书:评价、售后、品牌说明;没有真实信息时不编造。 +7. 底部 CTA:再次引导跳转到原商品链接。 diff --git a/.runtime/portal/skills/schedule-assistant/SKILL.md b/.runtime/portal/skills/schedule-assistant/SKILL.md new file mode 100644 index 0000000..240765b --- /dev/null +++ b/.runtime/portal/skills/schedule-assistant/SKILL.md @@ -0,0 +1,48 @@ +--- +name: schedule-assistant +description: 处理待办、提醒、日程类消息;先澄清缺失时间,再调用受限 schedule 工具写入真实记录。 +--- + +# 日程助手 + +这个 skill 只处理待办、提醒、日程、行程、闹钟类需求。目标是让回复和真实数据写入一致。 + +## 适用范围 + +- 创建待办 +- 创建带时间的日程或事件 +- 给已有事项创建提醒 +- 查询当前用户的事项列表 + +## 边界 + +1. 只能处理日程相关问题,不接管页面生成、知识问答、代码任务等其他能力。 +2. 只能操作当前用户的数据,不能猜测或引用其他用户记录。 +3. 没有成功调用工具前,不能说“已经设置好了”“已经加上了”。 +4. 信息不完整时先追问,不要为了显得聪明而臆造时间。 + +## 可用工具 + +- `schedule_create_item` +- `schedule_create_reminder` +- `schedule_list_items` + +## 工作规则 + +1. 先判断用户是在创建、查询,还是补充提醒。 +2. 如果缺标题、事项时间、提醒时间,先追问最小必要信息。 +3. 创建事项时: + - 纯待办用 `kind: task` + - 明确约会/会议/出发等时间安排可用 `kind: event` +4. 需要提醒时: + - 先创建事项 + - 再调用 `schedule_create_reminder` + - 只有两个工具都成功,才对用户确认成功 +5. 如果提示里给出了 `sourceMessageId`,调用 `schedule_create_item` 时必须原样传入。 +6. 查询时调用 `schedule_list_items`,只按结果回答,不要编造“已经存在”。 + +## 回复要求 + +- 简短、直接 +- 成功时说明创建了什么,以及是否带提醒 +- 失败时明确说没写入成功,并给出下一步 diff --git a/.runtime/portal/skills/search/SKILL.md b/.runtime/portal/skills/search/SKILL.md new file mode 100644 index 0000000..ac954e3 --- /dev/null +++ b/.runtime/portal/skills/search/SKILL.md @@ -0,0 +1,28 @@ +--- +name: search +description: 代码搜索技能:在代码库中搜索文本、符号、文件名(基于 ripgrep) +--- + +# 代码搜索 + +使用内置 `search` 平台扩展在工作区内快速定位代码。 + +## 何时使用 + +- 用户要找某个函数/变量/类定义在哪里 +- 用户要找包含某段文字的文件 +- 用户要找符合某种命名规律的文件 + +## 工具 + +| 工具 | 用途 | +|------|------| +| `search_text` | 按文本/正则搜索,支持 glob 过滤、大小写控制 | +| `search_files` | 按文件名通配符查找文件 | +| `search_symbol` | 精确匹配完整单词(适合找函数/变量名) | + +## 规则 + +1. 优先用 `search_symbol` 找符号,精确度更高 +2. `search_text` 的 `file_glob` 可缩小范围(如 `*.rs`、`src/**`) +3. 结果超出 `max_results` 时提示用户缩小范围 diff --git a/.runtime/portal/skills/static-page-publish/SKILL.md b/.runtime/portal/skills/static-page-publish/SKILL.md new file mode 100644 index 0000000..4612f47 --- /dev/null +++ b/.runtime/portal/skills/static-page-publish/SKILL.md @@ -0,0 +1,85 @@ +--- +name: static-page-publish +description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报告与页面(TKMind H5 通用技能) +--- + +# 静态页面 / 报告发布 + +本技能为 **TKMind H5 多用户环境** 的通用发布流程。安装到用户工作区后,内容会按用户替换为专属目录与公网前缀。 + +## 何时使用 + +- 用户要「生成网页 / HTML 报告 / 可视化页面 / 分享链接」 +- 用户提到「放到 MindSpace」「给个能打开的链接」 + +## 规则摘要 + +1. 只在**当前用户工作区**(会话 `working_dir`)内读写与搜索,从 `.` 开始 +2. 查找 CSV/文档时只用相对路径(如 `oa/report.csv`),**禁止**去上级目录、MindSpace 根目录、其它用户目录或主机路径搜索 +3. 读 CSV/列目录:用工作区内的 `shell`(`ls oa/`、`cat file.csv`)或 `tree`;**禁止**用公网 URL 代替 +4. **禁止**用 `shell` / `cat` / `heredoc` / `echo` / `cp` 写入 `public/*.html`;HTML 必须用 `write_file` / `edit_file`(shell 在容器内执行,公网链接会 404) +5. 公网链接**仅**用于让用户浏览器打开已发布的 HTML,不能用来列目录或读数据文件 +6. 静态文件保存即可访问,**无需重启** +7. 页面需提供 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏) + +详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。 + +## 推荐工作流 + +1. 确认需求(标题、章节、视觉风格、是否需要 hero 图) +2. `write_file` 创建 `public/页面.html`(需要调整已有页面时用 `edit_file`;需要时在同目录或 `assets/` 放主图) +3. 在 `` 写入 **mindspace-cover**(必须与页面主题一致,见下文) +4. 保存后服务端**立即**生成 `<文件名>.thumbnail.svg`(Agent 交互阶段即生效) +5. 按「回复格式」返回**可点击**公网链接 + +## 回复格式(必须) + +向用户交付页面时,**必须使用 Markdown 可点击链接**: + +```markdown +[马来西亚旅游攻略](https://goo.tkmind.cn/MindSpace/<用户ID>/public/malaysia-travel-guide.html) +``` + +要求: + +- **必须**使用 `[页面标题](完整URL)`,不要只给裸 URL 或「点这里」 +- 页面写入 `public/` 时,URL **必须**包含 `/public/` 路径段(与磁盘路径一致) +- 标题用页面真实主题名 +- 可同时给出相对路径(如 `public/malaysia-travel-guide.html`) +- 说明:保存即生效,无需重启 + +## 信息流预览图(必须) + +每个 HTML 必须在 `` 包含与**页面主题一致**的元数据。系统据此生成 **精美的 3:4 信息流封面**(工作区 `*.thumbnail.svg` +「我的空间」卡片 + 保存弹窗预览): + +```html + + +``` + +| 字段 | 要求 | +|------|------| +| `tag` | 与主题一致:旅行 / 美食 / 报告 / **运动** / **活动** 等 | +| `accent` / `accent2` | 页面主色,与 hero/背景 CSS 一致 | +| `subtitle` | 一句话卖点;未写时用 description | +| `cover` / `image` | **必须**指向高质量主图(相对 HTML 或 `https://`);见下文 | +| `emoji` | 可选;也可写在 title 中 | + +### 精美预览图(必须达标) + +保存 HTML 后,系统会**立即**生成 `<文件名>.thumbnail.svg` 作为卡片封面。要产出**可在信息流中直接展示的精美封面**,必须: + +1. **视觉类页面**(旅行、美食、活动、运动、品牌、产品、促销等)**必须**在 `assets/` 放置高质量 hero 主图(建议宽度 ≥1200px),并在 `cover` 字段引用(如 `assets/hero.jpg`) +2. **纯文字报告**可仅用配色 + tag,但仍须保证 `accent` / `subtitle` 与页面风格一致 +3. `tag`、`accent`、`accent2`、`subtitle` 必须与页面实际视觉一致;**禁止**省略 mindspace-cover 或填无关默认值 +4. 若缺少 hero 主图,封面会退化为简陋默认图,**视为未达标** + +**禁止**省略 mindspace-cover 或填写与页面无关的通用配色;促销/运动/品牌页必须写明 `tag`、`accent` 和 `cover`。 + +本地对比示例:`node scripts/thumbnail-preview-demo.mjs` → `/thumbnail-demo/` + +## 附带文件下载(Word / PDF) + +- 二进制文件用 `docx-generate` 脚本或平台允许的方式**单独生成**,保存到 `public/`(或 `oa/` 再复制到 `public/`) +- 下载按钮示例:`下载文档`(与 HTML 同目录时用文件名即可) +- **禁止** `` 内嵌 docx/pdf diff --git a/.runtime/portal/skills/table-viewer/SKILL.md b/.runtime/portal/skills/table-viewer/SKILL.md new file mode 100644 index 0000000..978f050 --- /dev/null +++ b/.runtime/portal/skills/table-viewer/SKILL.md @@ -0,0 +1,37 @@ +--- +name: table-viewer +description: 表格可视化技能:将数据渲染为可排序、筛选的交互式表格(需启用 tableviewer 扩展) +--- + +# 表格查看器 + +使用 `tableviewer` MCP 扩展在聊天中渲染交互式数据表格。 + +## 何时使用 + +- 用户展示结构化数据(数据库查询结果、CSV、对比列表) +- 数据超过 5 行且有多列时,优先用表格而非 markdown + +## 前提 + +Settings → Extensions 中启用 **Table Viewer** 扩展。 + +## 工具 + +`show_table(title, columns, rows)` — 渲染可排序/筛选表格 + +## 示例 + +``` +show_table( + title: "销售数据", + columns: ["月份", "销售额", "环比增长"], + rows: [["1月", 12000, "+5%"], ["2月", 13500, "+12.5%"]] +) +``` + +## 规则 + +1. `columns` 与每行数据顺序必须一致 +2. 数值类型保持为数字(不要转成字符串),排序才准确 +3. 超过 500 行时提示用户过滤后再展示 diff --git a/.runtime/portal/skills/test-runner/SKILL.md b/.runtime/portal/skills/test-runner/SKILL.md new file mode 100644 index 0000000..87c188a --- /dev/null +++ b/.runtime/portal/skills/test-runner/SKILL.md @@ -0,0 +1,28 @@ +--- +name: test-runner +description: 测试运行技能:运行 cargo test 并解析通过/失败结果 +--- + +# 测试运行 + +使用内置 `test_runner` 平台扩展运行项目测试并分析结果。 + +## 何时使用 + +- 用户要验证改动是否破坏了现有测试 +- 用户要跑某个 crate 或特定测试用例 +- 需要查看有哪些测试可运行 + +## 工具 + +| 工具 | 用途 | +|------|------| +| `run_tests` | 运行测试,可按包名和测试名过滤,支持超时 | +| `list_tests` | 列出所有可用测试(不执行) | + +## 规则 + +1. 修复 bug 后**必须**运行相关测试确认 +2. 用 `test_filter` 只跑受影响的测试,避免全量耗时 +3. 默认超时 120 秒,大型集成测试可适当调高 +4. 看到 FAILED 行后,重点关注 `failures:` 区段的具体错误 diff --git a/.runtime/portal/skills/timeline/SKILL.md b/.runtime/portal/skills/timeline/SKILL.md new file mode 100644 index 0000000..c6cd64e --- /dev/null +++ b/.runtime/portal/skills/timeline/SKILL.md @@ -0,0 +1,42 @@ +--- +name: timeline +description: 时间轴可视化技能:将事件序列渲染为交互式时间线(需启用 timeline 扩展) +--- + +# 时间轴展示 + +使用 `timeline` MCP 扩展在聊天中渲染时间线。 + +## 何时使用 + +- 展示项目里程碑、发版历史 +- git log 可视化 +- 事件序列、历史脉络梳理 + +## 前提 + +Settings → Extensions 中启用 **Timeline** 扩展。 + +## 工具 + +`show_timeline(title, events)` — 渲染垂直时间轴 + +## 数据结构 + +``` +events: [ + { + date: "2024-01-15", + title: "v1.0 发布", + description: "首个正式版本上线", + category: "发版", + color: "#5c98f9" // 可选,不填自动按 category 着色 + } +] +``` + +## 规则 + +1. `date` 格式灵活(`2024-01`、`2024-01-15`、`Q1 2024` 均可) +2. 同一 `category` 自动使用同一颜色 +3. 事件超过 30 个时只展示关键节点,其余省略 diff --git a/.runtime/portal/skills/web/SKILL.md b/.runtime/portal/skills/web/SKILL.md new file mode 100644 index 0000000..3fc86c6 --- /dev/null +++ b/.runtime/portal/skills/web/SKILL.md @@ -0,0 +1,28 @@ +--- +name: web +description: 网页抓取与搜索技能:访问网页、查阅文档、搜索互联网 +--- + +# 网页访问 + +使用内置 `web` 平台扩展获取网页内容和搜索信息。 + +## 何时使用 + +- 用户要查某个 API 文档、库的用法 +- 用户要搜索最新新闻、技术资料 +- 需要验证某个 URL 的内容 + +## 工具 + +| 工具 | 用途 | +|------|------| +| `fetch_url` | 抓取指定 URL 的内容,可提取纯文本 | +| `web_search` | 通过 DuckDuckGo 搜索,返回标题/摘要/链接列表 | + +## 规则 + +1. 优先用 `web_search` 探索,再用 `fetch_url` 读取具体页面 +2. `extract_text: true`(默认)获取可读文本,`false` 获取原始 HTML +3. 不要访问不明来源的链接,向用户确认后再访问 +4. 官方文档优先于第三方博客 diff --git a/billing-session-concurrency.test.mjs b/billing-session-concurrency.test.mjs new file mode 100644 index 0000000..37a7293 --- /dev/null +++ b/billing-session-concurrency.test.mjs @@ -0,0 +1,193 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createUserAuth } from './user-auth.mjs'; + +function createConcurrentBillingPool({ userId, sessionId, startingBalanceCents = 100_000 }) { + const billingState = new Map(); + const usageRecords = []; + const ledger = []; + let wallet = { balance_cents: startingBalanceCents, tokens_used: 0 }; + let sessionLock = Promise.resolve(); + + const createConnection = () => { + let holdsSessionLock = false; + let releaseSessionLock = null; + + const acquireSessionLock = async () => { + if (holdsSessionLock) return; + let release; + const gate = new Promise((resolve) => { + release = resolve; + }); + const prev = sessionLock; + sessionLock = gate; + await prev; + holdsSessionLock = true; + releaseSessionLock = release; + }; + + const releaseLock = () => { + if (!holdsSessionLock) return; + holdsSessionLock = false; + releaseSessionLock?.(); + releaseSessionLock = null; + }; + + return { + async beginTransaction() {}, + async commit() { + releaseLock(); + }, + async rollback() { + releaseLock(); + }, + release() {}, + async query(sql, params = []) { + if ( + sql.includes('INSERT INTO h5_session_billing_state') && + sql.includes('ON DUPLICATE KEY UPDATE agent_session_id') + ) { + if (!billingState.has(sessionId)) { + billingState.set(sessionId, { + last_accumulated_cost: null, + last_input_tokens: 0, + last_output_tokens: 0, + }); + } + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('FROM h5_session_billing_state') && sql.includes('FOR UPDATE')) { + await acquireSessionLock(); + const row = billingState.get(sessionId) ?? { + last_accumulated_cost: null, + last_input_tokens: 0, + last_output_tokens: 0, + }; + return [[{ ...row }], []]; + } + if ( + sql.includes('INSERT INTO h5_session_billing_state') && + sql.includes('last_output_tokens = VALUES(last_output_tokens)') + ) { + const [, , , inputTokens, outputTokens] = params; + billingState.set(sessionId, { + last_accumulated_cost: params[2], + last_input_tokens: inputTokens, + last_output_tokens: outputTokens, + }); + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('FROM h5_usage_records WHERE request_id')) { + const requestId = params[0]; + const hit = usageRecords.find((row) => row.request_id === requestId); + return hit ? [[{ cost_cents: hit.cost_cents }], []] : [[], []]; + } + if (sql.includes('FROM h5_user_wallets') && sql.includes('FOR UPDATE')) { + return [[{ ...wallet }], []]; + } + if (sql.includes('FROM h5_user_wallets') && !sql.includes('FOR UPDATE')) { + return [[{ ...wallet }], []]; + } + if (sql.includes('UPDATE h5_user_wallets')) { + const [nextBalance, deltaTokens] = params; + wallet = { + balance_cents: nextBalance, + tokens_used: Number(wallet.tokens_used) + Number(deltaTokens), + }; + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('INSERT INTO h5_usage_records')) { + usageRecords.push({ + request_id: params[2], + input_tokens: params[3], + output_tokens: params[4], + cost_cents: params[5], + }); + return [{ insertId: usageRecords.length }, []]; + } + if (sql.includes('INSERT INTO h5_billing_ledger')) { + ledger.push({ amount_cents: params[1], note: params[5] }); + return [{ affectedRows: 1 }, []]; + } + if (sql.includes('UPDATE h5_users SET status')) { + return [{ affectedRows: 1 }, []]; + } + throw new Error(`unexpected query: ${sql}`); + }, + }; + }; + + const userRow = { + id: userId, + username: 'tester', + slug: 'tester', + email: 'tester@example.com', + display_name: 'Tester', + role: 'user', + status: 'active', + plan_type: 'free', + workspace_root: '/tmp/tester', + balance_cents: startingBalanceCents, + tokens_used: 0, + }; + + const pool = { + async query(sql, params = []) { + if (sql.includes('FROM h5_users u') && sql.includes('WHERE u.id = ?')) { + return [[{ ...userRow, balance_cents: wallet.balance_cents, tokens_used: wallet.tokens_used }], []]; + } + throw new Error(`unexpected pool query: ${sql}`); + }, + async getConnection() { + return createConnection(); + }, + }; + + return { pool, usageRecords, ledger, getWallet: () => wallet }; +} + +test('billSessionUsage charges once under concurrent Finish replays', async () => { + const userId = 'user-concurrent-1'; + const sessionId = '20260629_test'; + const { pool, usageRecords, ledger, getWallet } = createConcurrentBillingPool({ + userId, + sessionId, + startingBalanceCents: 100_000, + }); + const auth = createUserAuth(pool, { persistSessions: false }); + const tokenState = { + accumulatedInputTokens: 4877927, + accumulatedOutputTokens: 23310, + }; + + const results = await Promise.all( + Array.from({ length: 11 }, () => auth.billSessionUsage(userId, sessionId, tokenState, null)), + ); + + const charged = results.filter((result) => result.costCents > 0); + assert.equal(charged.length, 1); + assert.equal(usageRecords.length, 1); + assert.equal(ledger.length, 1); + assert.equal(usageRecords[0].input_tokens, 4877927); + assert.equal(usageRecords[0].output_tokens, 23310); + assert.ok(getWallet().balance_cents < 100_000); +}); + +test('billSessionUsage is idempotent when request_id repeats', async () => { + const userId = 'user-concurrent-2'; + const sessionId = '20260629_req'; + const { pool, usageRecords } = createConcurrentBillingPool({ + userId, + sessionId, + startingBalanceCents: 50_000, + }); + const auth = createUserAuth(pool, { persistSessions: false }); + const tokenState = { accumulatedInputTokens: 1000, accumulatedOutputTokens: 200 }; + + const first = await auth.billSessionUsage(userId, sessionId, tokenState, 'req-abc'); + const second = await auth.billSessionUsage(userId, sessionId, tokenState, 'req-abc'); + + assert.ok(first.costCents > 0); + assert.equal(second.costCents, 0); + assert.equal(usageRecords.length, 1); +}); diff --git a/billing-subscription.mjs b/billing-subscription.mjs index 6178c75..d3ccb74 100644 --- a/billing-subscription.mjs +++ b/billing-subscription.mjs @@ -11,7 +11,7 @@ export const PLAN_CATALOG = { free: { name: '免费版', priceCents: 0, - periodTokens: 150_000, + periodTokens: 450_000, periodImages: 10, modelTier: 'basic', overageRate: 1.00, @@ -20,7 +20,7 @@ export const PLAN_CATALOG = { lite: { name: '轻量版', priceCents: 990, - periodTokens: 1_200_000, + periodTokens: 3_600_000, periodImages: 50, modelTier: 'basic', overageRate: 0.50, @@ -29,7 +29,7 @@ export const PLAN_CATALOG = { standard: { name: '标准版', priceCents: 2900, - periodTokens: 4_500_000, + periodTokens: 13_500_000, periodImages: 200, modelTier: 'standard', overageRate: 0.70, @@ -46,6 +46,12 @@ export const PLAN_CATALOG = { }, }; +const PLAN_TOKEN_ALLOWANCE_MIGRATIONS = [ + ['free', 150_000, PLAN_CATALOG.free.periodTokens], + ['lite', 1_200_000, PLAN_CATALOG.lite.periodTokens], + ['standard', 4_500_000, PLAN_CATALOG.standard.periodTokens], +]; + export function getPlanDef(planType) { return PLAN_CATALOG[planType] ?? null; } @@ -458,7 +464,7 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { for (const row of rows) { const sub = mapSubRow(row); - const plan = getPlanDef(sub.planType); + const plan = await resolvePlan(sub.planType); if (!plan || plan.priceCents === 0) continue; const balance = Number(row.balance_cents ?? 0); @@ -649,6 +655,23 @@ export async function ensurePlanCatalogSchema(pool) { ); } } + + const now = Date.now(); + for (const [planType, previousTokens, nextTokens] of PLAN_TOKEN_ALLOWANCE_MIGRATIONS) { + if (nextTokens <= previousTokens) continue; + await pool.query( + `UPDATE h5_plan_catalog + SET period_tokens = ?, updated_at = ? + WHERE plan_type = ? AND period_tokens = ?`, + [nextTokens, now, planType, previousTokens], + ); + await pool.query( + `UPDATE h5_subscriptions + SET period_tokens_limit = ?, updated_at = ? + WHERE status = 'active' AND plan_type = ? AND period_tokens_limit = ?`, + [nextTokens, now, planType, previousTokens], + ); + } } export function createPlanCatalogService(pool) { diff --git a/billing-subscription.test.mjs b/billing-subscription.test.mjs index 326690c..674ccde 100644 --- a/billing-subscription.test.mjs +++ b/billing-subscription.test.mjs @@ -3,6 +3,7 @@ import { describe, it } from 'node:test'; import { PLAN_CATALOG, createSubscriptionService, + ensurePlanCatalogSchema, getPlanDef, tokensToCallsApprox, } from './billing-subscription.mjs'; @@ -20,6 +21,12 @@ describe('PLAN_CATALOG', () => { assert.equal(PLAN_CATALOG.free.priceCents, 0); }); + it('uses expanded token allowance defaults', () => { + assert.equal(PLAN_CATALOG.free.periodTokens, 450_000); + assert.equal(PLAN_CATALOG.lite.periodTokens, 3_600_000); + assert.equal(PLAN_CATALOG.standard.periodTokens, 13_500_000); + }); + it('pro plan has unlimited tokens (0)', () => { assert.equal(PLAN_CATALOG.pro.periodTokens, 0); }); @@ -47,8 +54,31 @@ describe('tokensToCallsApprox', () => { }); it('calculates approximate calls', () => { - assert.equal(tokensToCallsApprox(150_000), 50); // free ~50 calls - assert.equal(tokensToCallsApprox(1_200_000), 400); // lite ~400 calls + assert.equal(tokensToCallsApprox(450_000), 150); // free ~150 calls + assert.equal(tokensToCallsApprox(3_600_000), 1200); // lite ~1200 calls + }); +}); + +describe('ensurePlanCatalogSchema', () => { + it('expands legacy default token allowances without overwriting custom values', async () => { + const queries = []; + const pool = { + async query(sql, params = []) { + queries.push({ sql, params }); + if (sql.includes('COUNT(*) AS cnt')) return [[{ cnt: 1 }]]; + return [{ affectedRows: 1 }]; + }, + }; + + await ensurePlanCatalogSchema(pool); + + const planUpdates = queries.filter(({ sql }) => sql.includes('UPDATE h5_plan_catalog')); + const subUpdates = queries.filter(({ sql }) => sql.includes('UPDATE h5_subscriptions')); + + assert.ok(planUpdates.some(({ params }) => params[0] === 450_000 && params[2] === 'free' && params[3] === 150_000)); + assert.ok(planUpdates.some(({ params }) => params[0] === 3_600_000 && params[2] === 'lite' && params[3] === 1_200_000)); + assert.ok(planUpdates.some(({ params }) => params[0] === 13_500_000 && params[2] === 'standard' && params[3] === 4_500_000)); + assert.equal(subUpdates.length, 3); }); }); @@ -210,4 +240,61 @@ describe('createSubscriptionService', () => { assert.match(result.message, /未知套餐/); }); }); + + describe('processAutoRenewals', () => { + it('uses DB-backed plan definitions for renewed subscriptions', async () => { + const expiredSub = makeSubRow({ + plan_type: 'lite', + auto_renew: 1, + expires_at: Date.now() - 1000, + balance_cents: 9999, + }); + const conn = { + queries: [], + async query(sql, params) { + this.queries.push({ sql, params }); + if (sql.includes('SELECT balance_cents')) return [[{ balance_cents: 9999 }]]; + return [{ affectedRows: 1 }]; + }, + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + release() {}, + }; + const pool = { + async query(sql) { + if (sql.includes('FROM h5_subscriptions s')) return [[expiredSub]]; + return [[]]; + }, + async getConnection() { return conn; }, + }; + const svc = createSubscriptionService(pool, { + getPlanAsync: async (planType) => planType === 'lite' + ? { + name: '轻量版', + priceCents: 990, + periodTokens: 9_999_000, + periodImages: 50, + modelTier: 'basic', + overageRate: 0.45, + periodDays: 30, + } + : null, + }); + + const originalLog = console.log; + console.log = () => {}; + try { + const result = await svc.processAutoRenewals(); + assert.equal(result.renewed, 1); + } finally { + console.log = originalLog; + } + + const insert = conn.queries.find(({ sql }) => sql.includes('INSERT INTO h5_subscriptions')); + assert.ok(insert); + assert.equal(insert.params[3], 9_999_000); + assert.equal(insert.params[8], 0.45); + }); + }); }); diff --git a/db.mjs b/db.mjs index c3bda54..71d18ec 100644 --- a/db.mjs +++ b/db.mjs @@ -132,6 +132,11 @@ export async function migrateSchema(pool) { if (!(await indexExists(pool, 'h5_users', 'uq_h5_users_email'))) { await pool.query(`ALTER TABLE h5_users ADD UNIQUE KEY uq_h5_users_email (email)`); } + if (!(await indexExists(pool, 'h5_usage_records', 'uniq_h5_usage_request_id'))) { + await pool.query( + `ALTER TABLE h5_usage_records ADD UNIQUE KEY uniq_h5_usage_request_id (request_id)`, + ); + } const assetForeignKeys = [ { diff --git a/imgproxy-signer.mjs b/imgproxy-signer.mjs index e8afeba..df72c7e 100644 --- a/imgproxy-signer.mjs +++ b/imgproxy-signer.mjs @@ -39,8 +39,7 @@ export function createImgproxySigner(key, salt) { break; } - const encodedPath = encodeURIComponent(`local://${storagePath}`); - const unsignedPath = `/unsafe/${width}x${height}/${quality}/${format}/${encodedPath}`; + const unsignedPath = `/rs:fit:${width}:${height}:0/q:${quality}/plain/local:///${storagePath}@${format}`; const signedPath = signPath(unsignedPath); const baseUrlNormalized = String(baseUrl).replace(/\/$/, ''); diff --git a/mindspace-agent-jobs.mjs b/mindspace-agent-jobs.mjs index 8ad0fed..3e5fcc9 100644 --- a/mindspace-agent-jobs.mjs +++ b/mindspace-agent-jobs.mjs @@ -261,7 +261,7 @@ export function createAgentJobService(pool, options = {}) { ); const category = categoryRows[0]; if (!category) throw agentJobError('输出分类不存在', 'category_not_found'); - if (!['draft', 'private', 'oa'].includes(category.category_code)) { + if (!['draft', 'oa'].includes(category.category_code)) { throw agentJobError('该分类不允许写入 Agent 输出', 'invalid_agent_job_input'); } diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index f1477a9..955bd58 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -11,9 +11,19 @@ import { } from './mindspace-thumbnails.mjs'; import { removeZoneMirror, resolveUserWorkspaceRoot } from './user-space.mjs'; import { PUBLIC_ZONE_DIR, PUBLISH_ROOT_DIR, resolvePublicBaseUrl } from './user-publish.mjs'; +import { + buildPublicImagePaths, + buildUserImagePublicUrl, + isPublicImageStorageKey, +} from './user-image-url.mjs'; import { canPreviewAsset, renderAssetPreviewHtml } from './mindspace-asset-preview.mjs'; import { createWorkspaceAssetSync } from './mindspace-workspace-sync.mjs'; import { resolveWorkspaceStoragePath, isWorkspaceStorageKey } from './workspace-storage.mjs'; +import { + DEFAULT_IMAGE_INPUT_MAX_PIXELS, + DEFAULT_IMAGE_UPLOAD_MAX_BYTES, + normalizeImageForStorage, +} from './user-image-normalize.mjs'; const ALLOWED_EXTENSIONS = new Map([ ['.txt', 'text/plain'], @@ -33,7 +43,7 @@ const ALLOWED_EXTENSIONS = new Map([ ['.html', 'text/html'], ['.htm', 'text/html'], ]); -const MAX_IMAGE_UPLOAD_BYTES = 2 * 1024 * 1024; +const MAX_IMAGE_UPLOAD_BYTES = DEFAULT_IMAGE_UPLOAD_MAX_BYTES; const PUBLIC_TEMP_IMAGE_DIR = '.tmp-images'; const PUBLIC_IMAGE_EXTENSIONS = new Map([ @@ -105,9 +115,10 @@ function validateImagePixels(buffer, mimeType) { if (!dims) return { ok: true }; const { width, height } = dims; const megapixels = (width * height) / 1_000_000; - if (megapixels > 25) { + const maxMegapixels = DEFAULT_IMAGE_INPUT_MAX_PIXELS / 1_000_000; + if (megapixels > maxMegapixels) { throw Object.assign( - new Error(`图片像素超过限制(${megapixels.toFixed(1)}MP > 25MP)`), + new Error(`图片像素超过限制(${megapixels.toFixed(1)}MP > ${maxMegapixels.toFixed(0)}MP)`), { code: 'image_pixel_exceeded' } ); } @@ -152,8 +163,41 @@ function visibilityForCategory(categoryCode) { return categoryCode === 'public' ? 'public_candidate' : 'private'; } +const UPLOADABLE_CATEGORY_CODES = ['oa', 'public']; + +function resolvePublicImagePaths(userId, assetId, mimeType, filename, uniqueSuffix = null) { + const suffix = uniqueSuffix ?? String(assetId ?? '').replace(/-/g, '').slice(0, 12); + return buildPublicImagePaths({ + userId, + assetId, + mimeType, + originalFilename: filename, + uniqueSuffix: suffix, + }); +} + function isPublicImageAsset(row) { - return row?.category_code === 'public' && String(row?.mime_type ?? '').startsWith('image/'); + const mime = String(row?.mime_type ?? ''); + if (!mime.startsWith('image/')) return false; + if (isPublicImageStorageKey(row?.storage_key)) return true; + return row?.category_code === 'public'; +} + +function publicUrlForAsset(row) { + if (!isPublicImageAsset(row)) return null; + if (row?.storage_key && isPublicImageStorageKey(row.storage_key)) { + return buildUserImagePublicUrl({ + userId: row.user_id, + storageKey: row.storage_key, + mimeType: row.mime_type, + originalFilename: row.original_filename, + }); + } + if (row?.category_code === 'public' && row?.id) { + const legacy = publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename); + return legacy; + } + return null; } function publicTempImageFilename(assetId, mimeType, fallbackFilename = '') { @@ -194,9 +238,7 @@ function assetResponse(row) { scanStatus: row.scan_status ?? 'passed', sourceType: row.source_type, hasThumbnail: Boolean(row.has_thumbnail ?? row.mime_type === 'text/html'), - publicUrl: isPublicImageAsset(row) - ? publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename) - : null, + publicUrl: publicUrlForAsset(row), sourcePageId: row.source_page_id ?? null, createdAt: asNumber(row.created_at), updatedAt: asNumber(row.updated_at), @@ -221,13 +263,19 @@ export function validateUploadRequest({ filename, sizeBytes, maxFileBytes = DEFA if (!Number.isSafeInteger(normalizedSize) || normalizedSize <= 0) { throw Object.assign(new Error('文件不能为空'), { code: 'invalid_file_size' }); } - if (normalizedSize > maxFileBytes) { - throw Object.assign(new Error('文件超过单文件大小限制'), { code: 'file_too_large' }); - } const mimeType = expectedMimeType(normalizedFilename); if (!mimeType) { throw Object.assign(new Error('暂不支持该文件类型'), { code: 'unsupported_file_type' }); } + const effectiveMaxBytes = mimeType.startsWith('image/') + ? MAX_IMAGE_UPLOAD_BYTES + : maxFileBytes; + if (normalizedSize > effectiveMaxBytes) { + throw Object.assign( + new Error(mimeType.startsWith('image/') ? '图片文件超过单文件大小限制' : '文件超过单文件大小限制'), + { code: 'file_too_large' }, + ); + } if (mimeType.startsWith('image/') && normalizedSize > MAX_IMAGE_UPLOAD_BYTES) { throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' }); } @@ -240,6 +288,7 @@ export function createAssetService(pool, options = {}) { const maxFileBytes = Number(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES); const uploadTtlMs = Number(options.uploadTtlMs ?? 30 * 60 * 1000); const idFactory = options.idFactory ?? (() => crypto.randomUUID()); + const normalizeStoredImage = options.normalizeStoredImage ?? normalizeImageForStorage; const workspaceSync = createWorkspaceAssetSync({ pool, storageRoot, @@ -258,24 +307,33 @@ export function createAssetService(pool, options = {}) { }); }; - const writePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename, sourcePath) => { + const writePublicImageMirror = async (userId, workspaceRelativePath, sourcePath) => { if (!h5Root) return null; - const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename); const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); - const target = path.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename); + const target = path.join(workspaceRoot, workspaceRelativePath); await fs.mkdir(path.dirname(target), { recursive: true }); await fs.copyFile(sourcePath, target); return target; }; - const removePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename) => { - if (!h5Root) return; - const filename = publicTempImageFilename(assetId, mimeType, fallbackFilename); + const removePublicImageMirror = async (userId, workspaceRelativePath) => { + if (!h5Root || !workspaceRelativePath) return; const workspaceRoot = resolveUserWorkspaceRoot(h5Root, { id: userId }); - const target = path.join(workspaceRoot, PUBLIC_ZONE_DIR, PUBLIC_TEMP_IMAGE_DIR, filename); + const target = path.join(workspaceRoot, workspaceRelativePath); await fs.rm(target, { force: true }); }; + const resolvePublicCategory = async (conn, userId) => { + const [rows] = await conn.query( + `SELECT id, space_id, category_code + FROM h5_space_categories + WHERE user_id = ? AND category_code = 'public' + LIMIT 1`, + [userId], + ); + return rows[0] ?? null; + }; + const absoluteStoragePath = (storageKey) => { const resolved = path.resolve(storageRoot, storageKey); if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) { @@ -326,6 +384,19 @@ export function createAssetService(pool, options = {}) { throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' }); }; + const normalizeStoredAssetImage = async ({ buffer, mimeType, filename }) => { + if (!mimeType?.startsWith('image/')) { + return { buffer, mimeType, filename }; + } + return normalizeStoredImage({ + buffer, + mimeType, + filename, + maxUploadBytes: MAX_IMAGE_UPLOAD_BYTES, + maxPixels: DEFAULT_IMAGE_INPUT_MAX_PIXELS, + }); + }; + const createUpload = async (userId, input) => { const validated = validateUploadRequest({ ...input, maxFileBytes }); const conn = await pool.getConnection(); @@ -343,12 +414,18 @@ export function createAssetService(pool, options = {}) { if (!category) { throw Object.assign(new Error('分类不存在'), { code: 'category_not_found' }); } - if (!['oa', 'private', 'public'].includes(category.category_code)) { + if (!UPLOADABLE_CATEGORY_CODES.includes(category.category_code)) { throw Object.assign(new Error('该分类不允许直接上传'), { code: 'category_not_uploadable', }); } + let targetCategory = category; + if (validated.expectedMimeType.startsWith('image/')) { + const publicCategory = await resolvePublicCategory(conn, userId); + if (publicCategory) targetCategory = publicCategory; + } + const [spaces] = await conn.query( `SELECT id, quota_bytes, used_bytes, reserved_bytes, status FROM h5_user_spaces @@ -387,8 +464,8 @@ export function createAssetService(pool, options = {}) { [ uploadId, userId, - space.id, - category.id, + targetCategory.space_id, + targetCategory.id, validated.filename, validated.sizeBytes, input.declaredMimeType || null, @@ -434,7 +511,7 @@ export function createAssetService(pool, options = {}) { if (asNumber(upload.expires_at) <= Date.now()) { throw Object.assign(new Error('上传会话已过期'), { code: 'upload_expired' }); } - if (buffer.length !== asNumber(upload.expected_size) || buffer.length > maxFileBytes) { + if (buffer.length !== asNumber(upload.expected_size)) { throw Object.assign(new Error('上传内容大小与预期不一致'), { code: 'file_size_mismatch', }); @@ -443,6 +520,15 @@ export function createAssetService(pool, options = {}) { if (!detectedMimeType) { throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' }); } + const effectiveMaxBytes = detectedMimeType.startsWith('image/') + ? MAX_IMAGE_UPLOAD_BYTES + : maxFileBytes; + if (buffer.length > effectiveMaxBytes) { + throw Object.assign( + new Error(detectedMimeType.startsWith('image/') ? '图片文件超过单文件大小限制' : '文件超过单文件大小限制'), + { code: 'file_too_large' }, + ); + } if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) { throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' }); } @@ -509,37 +595,52 @@ export function createAssetService(pool, options = {}) { const assetId = idFactory(); const versionId = idFactory(); temporaryPath = absoluteStoragePath(upload.temporary_storage_key); - const fileBuffer = await fs.readFile(temporaryPath); - const scan = runBasicFileScan(fileBuffer, { - filename: upload.filename, + const sourceBuffer = await fs.readFile(temporaryPath); + const normalizedImage = await normalizeStoredAssetImage({ + buffer: sourceBuffer, mimeType: upload.detected_mime_type, + filename: upload.filename, + }); + const storedBuffer = normalizedImage.buffer; + const storedMimeType = normalizedImage.mimeType; + const storedUploadFilename = normalizedImage.filename; + const storedSizeBytes = storedBuffer.length; + const storedChecksum = crypto.createHash('sha256').update(storedBuffer).digest('hex'); + const scan = runBasicFileScan(storedBuffer, { + filename: storedUploadFilename, + mimeType: storedMimeType, }); const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; - const shouldPublishTempImage = - upload.category_code === 'public' && - upload.detected_mime_type.startsWith('image/') && - scan.scanStatus === 'passed'; - const finalStorageKey = shouldPublishTempImage - ? publicTempImageStorageKey(userId, assetId, upload.detected_mime_type, upload.filename) - : upload.temporary_storage_key; + const isImage = storedMimeType.startsWith('image/'); + const shouldPublishImage = isImage && scan.scanStatus === 'passed'; + let finalStorageKey = upload.temporary_storage_key; + let workspaceRelativePath = null; + let storedFilename = storedUploadFilename; + if (shouldPublishImage) { + const imagePaths = resolvePublicImagePaths( + userId, + assetId, + storedMimeType, + storedUploadFilename, + ); + finalStorageKey = imagePaths.storageKey; + workspaceRelativePath = imagePaths.workspaceRelativePath; + storedFilename = imagePaths.workspaceRelativePath; + } finalPath = absoluteStoragePath(finalStorageKey); if (finalStorageKey !== upload.temporary_storage_key) { await fs.mkdir(path.dirname(finalPath), { recursive: true }); - await fs.rename(temporaryPath, finalPath); + await fs.writeFile(finalPath, storedBuffer); + } else if (isImage) { + await fs.writeFile(finalPath, storedBuffer); } - if (shouldPublishTempImage) { - publicMirror = await writePublicTempImageMirror( - userId, - assetId, - upload.detected_mime_type, - upload.filename, - finalPath, - ); + if (shouldPublishImage && workspaceRelativePath) { + publicMirror = await writePublicImageMirror(userId, workspaceRelativePath, finalPath); } const now = Date.now(); - const visibility = visibilityForCategory(upload.category_code); + const visibility = shouldPublishImage ? 'public_candidate' : visibilityForCategory(upload.category_code); await conn.query( `INSERT INTO h5_assets (id, user_id, space_id, category_id, asset_type, mime_type, original_filename, @@ -551,13 +652,13 @@ export function createAssetService(pool, options = {}) { userId, upload.space_id, upload.category_id, - assetTypeForMime(upload.detected_mime_type), - upload.detected_mime_type, - upload.filename, + assetTypeForMime(storedMimeType), + storedMimeType, + storedFilename, upload.filename, versionId, - upload.actual_size, - upload.checksum, + storedSizeBytes, + storedChecksum, scan.riskLevel, visibility, assetStatus, @@ -574,9 +675,9 @@ export function createAssetService(pool, options = {}) { versionId, assetId, finalStorageKey, - upload.actual_size, - upload.checksum, - upload.detected_mime_type, + storedSizeBytes, + storedChecksum, + storedMimeType, userId, versionScanStatus, now, @@ -588,7 +689,7 @@ export function createAssetService(pool, options = {}) { used_bytes = used_bytes + ?, updated_at = ? WHERE id = ? AND user_id = ?`, - [upload.reserved_bytes, upload.actual_size, now, upload.space_id, userId], + [upload.reserved_bytes, storedSizeBytes, now, upload.space_id, userId], ); await conn.query( `UPDATE h5_upload_sessions @@ -597,17 +698,17 @@ export function createAssetService(pool, options = {}) { [now, assetId, uploadId, userId], ); await conn.commit(); - return { + const result = { id: assetId, user_id: userId, categoryId: upload.category_id, categoryCode: upload.category_code, - assetType: assetTypeForMime(upload.detected_mime_type), - mimeType: upload.detected_mime_type, + assetType: assetTypeForMime(storedMimeType), + mimeType: storedMimeType, filename: upload.filename, displayName: upload.filename, - sizeBytes: asNumber(upload.actual_size), - checksum: upload.checksum, + sizeBytes: storedSizeBytes, + checksum: storedChecksum, riskLevel: scan.riskLevel, visibility, status: assetStatus, @@ -615,10 +716,19 @@ export function createAssetService(pool, options = {}) { sourceType: 'upload', createdAt: now, updatedAt: now, - publicUrl: shouldPublishTempImage - ? publicTempImageUrl(userId, assetId, upload.detected_mime_type, upload.filename) + publicUrl: shouldPublishImage + ? buildUserImagePublicUrl({ + userId, + storageKey: finalStorageKey, + mimeType: storedMimeType, + originalFilename: storedUploadFilename, + }) : null, }; + if (finalStorageKey !== upload.temporary_storage_key) { + await fs.rm(temporaryPath, { force: true }).catch(() => {}); + } + return result; } catch (error) { await conn.rollback(); if (finalPath && finalPath !== temporaryPath) await fs.rm(finalPath, { force: true }).catch(() => {}); @@ -672,7 +782,7 @@ export function createAssetService(pool, options = {}) { }; const listAssets = async (userId, { categoryId, categoryCode, syncWorkspace = true } = {}) => { - if (syncWorkspace && categoryCode && ['oa', 'private', 'public'].includes(categoryCode)) { + if (syncWorkspace && categoryCode && UPLOADABLE_CATEGORY_CODES.includes(categoryCode)) { await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch((error) => { console.warn( `[MindSpace] workspace sync failed (${categoryCode}):`, @@ -691,14 +801,15 @@ export function createAssetService(pool, options = {}) { params.push(categoryCode); } const [rows] = await pool.query( - `SELECT a.*, c.category_code, + `SELECT a.*, c.category_code, v.storage_key, ANY_VALUE(p.id) AS source_page_id FROM h5_assets a JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + JOIN h5_asset_versions v ON v.id = a.current_version_id LEFT JOIN h5_page_versions pv ON pv.bundle_asset_id = a.id LEFT JOIN h5_page_records p ON p.id = pv.page_id AND p.status <> 'deleted' AND p.user_id = a.user_id WHERE ${filters.join(' AND ')} - GROUP BY a.id + GROUP BY a.id, v.storage_key ORDER BY a.updated_at DESC LIMIT 100`, params, @@ -798,13 +909,8 @@ export function createAssetService(pool, options = {}) { filename: thumbName, }); } - if (mirrorCleanup.categoryCode === 'public' && mirrorCleanup.mimeType?.startsWith?.('image/')) { - await removePublicTempImageMirror( - userId, - mirrorCleanup.id, - mirrorCleanup.mimeType, - mirrorCleanup.filename, - ); + if (mirrorCleanup.mimeType?.startsWith?.('image/')) { + await removePublicImageMirror(userId, mirrorCleanup.filename); } } catch { // Best-effort workspace mirror cleanup; DB delete already committed. @@ -865,17 +971,35 @@ export function createAssetService(pool, options = {}) { if (!Buffer.isBuffer(buffer) || buffer.length === 0) { throw Object.assign(new Error('资产内容为空'), { code: 'invalid_file_size' }); } - if (buffer.length > maxFileBytes) { - throw Object.assign(new Error('文件超过单文件大小限制'), { code: 'file_too_large' }); - } const normalizedFilename = normalizeFilename(filename); const detectedMimeType = detectMimeType(buffer, normalizedFilename); if (!detectedMimeType) { throw Object.assign(new Error('无法确认文件类型'), { code: 'unsupported_file_type' }); } + const effectiveMaxBytes = detectedMimeType.startsWith('image/') + ? MAX_IMAGE_UPLOAD_BYTES + : maxFileBytes; + if (buffer.length > effectiveMaxBytes) { + throw Object.assign( + new Error(detectedMimeType.startsWith('image/') ? '图片文件超过单文件大小限制' : '文件超过单文件大小限制'), + { code: 'file_too_large' }, + ); + } if (detectedMimeType.startsWith('image/') && buffer.length > MAX_IMAGE_UPLOAD_BYTES) { throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' }); } + const normalizedImage = await normalizeStoredAssetImage({ + buffer, + mimeType: detectedMimeType, + filename: normalizedFilename, + }); + const storedBuffer = normalizedImage.buffer; + const storedMimeType = normalizedImage.mimeType; + const storedFilenameInput = normalizedImage.filename; + const storedSizeBytes = storedBuffer.length; + const storedChecksum = crypto.createHash('sha256').update(storedBuffer).digest('hex'); + const isImage = storedMimeType.startsWith('image/'); + const effectiveCategoryCode = isImage ? 'public' : categoryCode; const conn = await pool.getConnection(); let finalPath; let publicMirror = null; @@ -887,13 +1011,13 @@ export function createAssetService(pool, options = {}) { WHERE c.user_id = ? AND c.category_code = ? LIMIT 1 FOR UPDATE`, - [userId, categoryCode], + [userId, effectiveCategoryCode], ); const category = categories[0]; if (!category) { throw Object.assign(new Error('分类不存在'), { code: 'category_not_found' }); } - if (!['oa', 'private', 'public'].includes(category.category_code)) { + if (!UPLOADABLE_CATEGORY_CODES.includes(category.category_code) && category.category_code !== 'draft') { throw Object.assign(new Error('该分类不允许保存聊天资产'), { code: 'category_not_uploadable', }); @@ -912,43 +1036,44 @@ export function createAssetService(pool, options = {}) { } const available = asNumber(space.quota_bytes) - asNumber(space.used_bytes) - asNumber(space.reserved_bytes); - if (available < buffer.length) { + if (available < storedSizeBytes) { throw Object.assign(new Error('剩余空间不足'), { code: 'quota_exceeded', - details: { requiredBytes: buffer.length, availableBytes: Math.max(0, available) }, + details: { requiredBytes: storedSizeBytes, availableBytes: Math.max(0, available) }, }); } const assetId = idFactory(); const versionId = idFactory(); - const checksum = crypto.createHash('sha256').update(buffer).digest('hex'); - const scan = runBasicFileScan(buffer, { - filename: normalizedFilename, - mimeType: detectedMimeType, + const scan = runBasicFileScan(storedBuffer, { + filename: storedFilenameInput, + mimeType: storedMimeType, }); const assetStatus = scan.scanStatus === 'passed' ? 'ready' : 'quarantined'; const versionScanStatus = scan.scanStatus === 'passed' ? 'passed' : 'blocked'; - const shouldPublishTempImage = - category.category_code === 'public' && - detectedMimeType.startsWith('image/') && - scan.scanStatus === 'passed'; - const finalStorageKey = shouldPublishTempImage - ? publicTempImageStorageKey(userId, assetId, detectedMimeType, normalizedFilename) - : path.posix.join('users', userId, 'assets', assetId, 'versions', versionId); - finalPath = absoluteStoragePath(finalStorageKey); - await fs.mkdir(path.dirname(finalPath), { recursive: true }); - await fs.writeFile(finalPath, buffer, { flag: 'wx' }); - if (shouldPublishTempImage) { - publicMirror = await writePublicTempImageMirror( + const shouldPublishImage = isImage && scan.scanStatus === 'passed'; + let finalStorageKey = path.posix.join('users', userId, 'assets', assetId, 'versions', versionId); + let workspaceRelativePath = null; + let storedFilename = storedFilenameInput; + if (shouldPublishImage) { + const imagePaths = resolvePublicImagePaths( userId, assetId, - detectedMimeType, - normalizedFilename, - finalPath, + storedMimeType, + storedFilenameInput, ); + finalStorageKey = imagePaths.storageKey; + workspaceRelativePath = imagePaths.workspaceRelativePath; + storedFilename = imagePaths.workspaceRelativePath; + } + finalPath = absoluteStoragePath(finalStorageKey); + await fs.mkdir(path.dirname(finalPath), { recursive: true }); + await fs.writeFile(finalPath, storedBuffer, { flag: 'wx' }); + if (shouldPublishImage && workspaceRelativePath) { + publicMirror = await writePublicImageMirror(userId, workspaceRelativePath, finalPath); } const now = Date.now(); - const visibility = visibilityForCategory(category.category_code); + const visibility = shouldPublishImage ? 'public_candidate' : visibilityForCategory(category.category_code); await conn.query( `INSERT INTO h5_assets (id, user_id, space_id, category_id, asset_type, mime_type, original_filename, @@ -960,13 +1085,13 @@ export function createAssetService(pool, options = {}) { userId, category.space_id, category.id, - assetTypeForMime(detectedMimeType), - detectedMimeType, - normalizedFilename, - displayName || normalizedFilename, + assetTypeForMime(storedMimeType), + storedMimeType, + storedFilename, + displayName || path.basename(storedFilename), versionId, - buffer.length, - checksum, + storedSizeBytes, + storedChecksum, scan.riskLevel, visibility, assetStatus, @@ -984,9 +1109,9 @@ export function createAssetService(pool, options = {}) { versionId, assetId, finalStorageKey, - buffer.length, - checksum, - detectedMimeType, + storedSizeBytes, + storedChecksum, + storedMimeType, userId, versionScanStatus, now, @@ -995,11 +1120,11 @@ export function createAssetService(pool, options = {}) { await conn.query( `UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ? WHERE id = ? AND user_id = ?`, - [buffer.length, now, category.space_id, userId], + [storedSizeBytes, now, category.space_id, userId], ); await conn.commit(); - if (detectedMimeType === 'text/html') { - scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), buffer.toString('utf8'), { + if (storedMimeType === 'text/html') { + scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), storedBuffer.toString('utf8'), { title: displayName || normalizedFilename, subtitle: category.category_code.toUpperCase(), contentStorageKey: finalStorageKey, @@ -1010,12 +1135,13 @@ export function createAssetService(pool, options = {}) { user_id: userId, category_id: category.id, category_code: category.category_code, - asset_type: assetTypeForMime(detectedMimeType), - mime_type: detectedMimeType, - original_filename: normalizedFilename, - display_name: displayName || normalizedFilename, - size_bytes: buffer.length, - checksum, + asset_type: assetTypeForMime(storedMimeType), + mime_type: storedMimeType, + original_filename: storedFilename, + display_name: displayName || path.basename(storedFilename), + storage_key: finalStorageKey, + size_bytes: storedSizeBytes, + checksum: storedChecksum, risk_level: scan.riskLevel, visibility, status: assetStatus, @@ -1023,7 +1149,7 @@ export function createAssetService(pool, options = {}) { source_type: sourceType, created_at: now, updated_at: now, - has_thumbnail: detectedMimeType === 'text/html', + has_thumbnail: storedMimeType === 'text/html', }); } catch (error) { await conn.rollback(); diff --git a/mindspace-assets.test.mjs b/mindspace-assets.test.mjs index a63efc3..c76e2f0 100644 --- a/mindspace-assets.test.mjs +++ b/mindspace-assets.test.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import sharp from 'sharp'; import { createAssetService, validateUploadRequest } from './mindspace-assets.mjs'; import { DEFAULT_MAX_FILE_BYTES } from './mindspace.mjs'; @@ -184,6 +185,23 @@ test('validateUploadRequest allows 5MB uploads by default', () => { ); }); +test('validateUploadRequest keeps image uploads below the image-specific limit', () => { + assert.doesNotThrow(() => + validateUploadRequest({ + filename: 'cover.jpg', + sizeBytes: 4 * 1024 * 1024, + }), + ); + assert.throws( + () => + validateUploadRequest({ + filename: 'cover.jpg', + sizeBytes: 4 * 1024 * 1024 + 1, + }), + (error) => error.code === 'file_too_large', + ); +}); + test('completeUpload marks safe files ready and blocks script payloads', async () => { const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); const state = { @@ -251,10 +269,16 @@ test('completeUpload publishes public images to the public temp image library', h5Root, idFactory: () => `id-${++nextId}`, }); - const png = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', - 'base64', - ); + const png = await sharp({ + create: { + width: 24, + height: 24, + channels: 4, + background: { r: 10, g: 160, b: 220, alpha: 0.8 }, + }, + }) + .png() + .toBuffer(); const upload = await service.createUpload('user-1', { categoryId: 'cat-public', @@ -265,16 +289,130 @@ test('completeUpload publishes public images to the public temp image library', const asset = await service.completeUpload('user-1', upload.id); assert.equal(asset.status, 'ready'); - assert.match(asset.publicUrl, /^https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/\.tmp-images\/id-2\.png$/); - assert.equal(state.versions[0].storage_key, 'users/user-1/public/.tmp-images/id-2.png'); + assert.match(asset.publicUrl, /^http:\/\/localhost:20081\/unsafe\//); + assert.match(state.versions[0].storage_key, /^users\/user-1\/images\/\d{4}-\d{2}-\d{2}\/look-id2\.png$/); await assert.doesNotReject(() => - fs.stat(path.join(storageRoot, 'users/user-1/public/.tmp-images/id-2.png')), + fs.stat(path.join(storageRoot, state.versions[0].storage_key)), ); await assert.doesNotReject(() => - fs.stat(path.join(h5Root, 'MindSpace/user-1/public/.tmp-images/id-2.png')), + fs.stat(path.join(h5Root, 'MindSpace/user-1', state.assets[0].original_filename)), ); }); +test('completeUpload normalizes large images into a bounded master asset', async () => { + const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); + const state = { + categories: [ + { id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' }, + ], + spaces: [ + { + id: 'space-1', + user_id: 'user-1', + quota_bytes: 60 * 1024 * 1024, + used_bytes: 0, + reserved_bytes: 0, + status: 'active', + }, + ], + uploads: [], + assets: [], + versions: [], + }; + let nextId = 0; + const service = createAssetService(createMockPool(state), { + storageRoot, + idFactory: () => `id-${++nextId}`, + }); + const original = await sharp({ + create: { + width: 4200, + height: 2800, + channels: 3, + background: { r: 220, g: 120, b: 60 }, + }, + }) + .jpeg({ quality: 98 }) + .toBuffer(); + + const upload = await service.createUpload('user-1', { + categoryId: 'cat-public', + filename: 'camera-roll.jpg', + sizeBytes: original.length, + }); + await service.writeUploadContent('user-1', upload.id, original); + const asset = await service.completeUpload('user-1', upload.id); + const storedPath = path.join(storageRoot, state.versions[0].storage_key); + const storedBuffer = await fs.readFile(storedPath); + const metadata = await sharp(storedBuffer).metadata(); + + assert.equal(asset.status, 'ready'); + assert.equal(asset.mimeType, 'image/jpeg'); + assert.ok(asset.sizeBytes <= original.length); + assert.ok((metadata.width ?? 0) <= 2560); + assert.ok((metadata.height ?? 0) <= 2560); +}); + +test('completeUpload gives same-name public images unique storage keys', async () => { + const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-assets-')); + const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-h5-')); + const state = { + categories: [ + { id: 'cat-public', user_id: 'user-1', space_id: 'space-1', category_code: 'public' }, + ], + spaces: [ + { + id: 'space-1', + user_id: 'user-1', + quota_bytes: 20 * 1024 * 1024, + used_bytes: 0, + reserved_bytes: 0, + status: 'active', + }, + ], + uploads: [], + assets: [], + versions: [], + }; + let nextId = 0; + const service = createAssetService(createMockPool(state), { + storageRoot, + h5Root, + idFactory: () => `id-${++nextId}`, + }); + const image = await sharp({ + create: { + width: 16, + height: 16, + channels: 3, + background: { r: 120, g: 120, b: 240 }, + }, + }) + .jpeg() + .toBuffer(); + + const first = await service.createUpload('user-1', { + categoryId: 'cat-public', + filename: 'e20.jpg', + sizeBytes: image.length, + }); + await service.writeUploadContent('user-1', first.id, image); + await service.completeUpload('user-1', first.id); + + const second = await service.createUpload('user-1', { + categoryId: 'cat-public', + filename: 'e20.jpg', + sizeBytes: image.length, + }); + await service.writeUploadContent('user-1', second.id, image); + await service.completeUpload('user-1', second.id); + + assert.equal(state.versions.length, 2); + assert.notEqual(state.versions[0].storage_key, state.versions[1].storage_key); + assert.match(state.versions[0].storage_key, /\/e20-id2\.jpg$/); + assert.match(state.versions[1].storage_key, /\/e20-id5\.jpg$/); +}); + test('createUpload rejects another users category id', async () => { const state = { categories: [ diff --git a/mindspace-chat-context.mjs b/mindspace-chat-context.mjs index 623be9c..ab29ef3 100644 --- a/mindspace-chat-context.mjs +++ b/mindspace-chat-context.mjs @@ -155,7 +155,12 @@ export function buildMindSpaceChatContext(input) { } if (view === 'home' && pages.length) { - context.recentPages = pages.slice(0, 8).map((item) => ({ + const recentPages = [...pages].sort((left, right) => { + const createdDiff = (right.createdAt ?? 0) - (left.createdAt ?? 0); + if (createdDiff !== 0) return createdDiff; + return (right.updatedAt ?? 0) - (left.updatedAt ?? 0); + }); + context.recentPages = recentPages.slice(0, 8).map((item) => ({ id: item.id, title: item.title, status: item.status, diff --git a/mindspace-flags.mjs b/mindspace-flags.mjs index f8dd10f..6083333 100644 --- a/mindspace-flags.mjs +++ b/mindspace-flags.mjs @@ -4,7 +4,7 @@ export function mindspaceFlags(env = process.env) { mindspace_enabled: enabled, mindspace_upload_enabled: enabled && env.MINDSPACE_UPLOAD_ENABLED !== 'false', mindspace_publish_enabled: enabled && env.MINDSPACE_PUBLISH_ENABLED === 'true', - mindspace_private_enabled: enabled && env.MINDSPACE_PRIVATE_ENABLED !== 'false', + mindspace_private_enabled: false, mindspace_agent_jobs_enabled: enabled && env.MINDSPACE_AGENT_JOBS_ENABLED === 'true', }; } diff --git a/mindspace-og-tags.mjs b/mindspace-og-tags.mjs index e7688af..0e6677c 100644 --- a/mindspace-og-tags.mjs +++ b/mindspace-og-tags.mjs @@ -36,6 +36,19 @@ function resolveImageUrl(image, { origin, pageDirUrl }) { return `${pageDirUrl}${trimmed}`; } +function rawMetaContent(html, attr, name) { + const pattern = new RegExp( + `]+${attr}=["']${name}["'][^>]+content=["']([^"']*)["']|]+content=["']([^"']*)["'][^>]+${attr}=["']${name}["']`, + 'i', + ); + const match = String(html).match(pattern); + return match?.[1] || match?.[2] || ''; +} + +function escapeScriptString(value) { + return JSON.stringify(String(value ?? '')).replace(/ + (function () { + var ua = navigator.userAgent || ''; + if (!/MicroMessenger|WindowsWechat/i.test(ua)) return; + var pageUrl = ${escapeScriptString(String(pageUrl).split('#')[0])}; + var endpoint = ${escapeScriptString(signatureEndpoint)}; + var shareData = { + title: ${escapeScriptString(title)}, + desc: ${escapeScriptString(description)}, + link: pageUrl, + imgUrl: ${escapeScriptString(imageUrl)} + }; + function applyShare(wx) { + if (!wx) return; + var onReady = function () { + if (typeof wx.updateAppMessageShareData === 'function') wx.updateAppMessageShareData(shareData); + if (typeof wx.updateTimelineShareData === 'function') wx.updateTimelineShareData(shareData); + if (typeof wx.onMenuShareAppMessage === 'function') wx.onMenuShareAppMessage(shareData); + if (typeof wx.onMenuShareTimeline === 'function') wx.onMenuShareTimeline(shareData); + }; + fetch(endpoint + '?url=' + encodeURIComponent(pageUrl)) + .then(function (res) { return res.ok ? res.json() : Promise.reject(new Error('signature')); }) + .then(function (payload) { + wx.config({ + debug: false, + appId: payload.appId, + timestamp: payload.timestamp, + nonceStr: payload.nonceStr, + signature: payload.signature, + jsApiList: payload.jsApiList || [ + 'updateAppMessageShareData', + 'updateTimelineShareData', + 'onMenuShareAppMessage', + 'onMenuShareTimeline' + ] + }); + wx.ready(onReady); + }) + .catch(function () {}); + } + function boot() { + if (window.wx) { + applyShare(window.wx); + return; + } + var script = document.createElement('script'); + script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; + script.async = true; + script.onload = function () { applyShare(window.wx); }; + document.head.appendChild(script); + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot, { once: true }); + } else { + boot(); + } + })(); + `; + + if (/<\/head>/i.test(source)) { + return source.replace(/<\/head>/i, `${script}\n`); + } + if (/]*>/i.test(source)) { + return source.replace(/(]*>)/i, `$1${script}`); + } + return `${script}\n${source}`; +} diff --git a/mindspace-og-tags.test.mjs b/mindspace-og-tags.test.mjs index 528cb4e..23720d6 100644 --- a/mindspace-og-tags.test.mjs +++ b/mindspace-og-tags.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { injectOgTags } from './mindspace-og-tags.mjs'; +import { injectOgTags, injectWechatShareBridge } from './mindspace-og-tags.mjs'; const ctx = { origin: 'https://m.tkmind.cn', @@ -15,7 +15,7 @@ test('injects og/twitter tags with absolute image from a relative cover', () => const out = injectOgTags(html, ctx); assert.match(out, //); assert.match(out, //); - assert.match(out, //); + assert.match(out, //); assert.match(out, //); // injected before assert.ok(out.indexOf('og:image') < out.indexOf('')); @@ -27,14 +27,14 @@ test('falls back to the thumbnail png when the page has no cover of its own', () ...ctx, fallbackImageUrl: 'https://m.tkmind.cn/MindSpace/john/public/space.thumbnail.png', }); - assert.match(out, //); + assert.match(out, //); assert.match(out, //); }); test('prefers the page cover over the thumbnail fallback', () => { const html = `X`; const out = injectOgTags(html, { ...ctx, fallbackImageUrl: 'https://m.tkmind.cn/x.thumbnail.png' }); - assert.match(out, /og:image" content="https:\/\/g2\.tkmind\.cn\/MindSpace\/john\/public\/assets\/hero\.jpg"/); + assert.match(out, /og:image" content="https:\/\/m\.tkmind\.cn\/MindSpace\/john\/public\/assets\/hero\.jpg"/); assert.doesNotMatch(out, /thumbnail\.png/); }); @@ -60,7 +60,7 @@ test('passes through a full https cover url unchanged', () => { test('resolves a root-absolute cover against the origin', () => { const html = `X`; const out = injectOgTags(html, ctx); - assert.match(out, //); + assert.match(out, //); }); test('escapes quotes and ampersands in title to keep the meta tag well-formed', () => { @@ -68,3 +68,26 @@ test('escapes quotes and ampersands in title to keep the meta tag well-formed', const out = injectOgTags(html, ctx); assert.match(out, //); }); + +test('injectWechatShareBridge adds WeChat share bootstrap with og-derived metadata', () => { + const html = `页面标题` + + `` + + `` + + ``; + const out = injectWechatShareBridge(html, { + pageUrl: 'https://m.tkmind.cn/u/john/pages/demo', + }); + assert.match(out, /data-tkmind-wechat-share="1"/); + assert.match(out, /fetch\(endpoint \+ '\?url='/); + assert.match(out, /updateAppMessageShareData/); + assert.match(out, /https:\/\/g2\.tkmind\.cn\/hero\.png/); + assert.match(out, /分享标题/); + assert.match(out, /分享描述/); +}); + +test('injectWechatShareBridge is idempotent', () => { + const html = `X`; + const first = injectWechatShareBridge(html, { pageUrl: 'https://m.tkmind.cn/u/john/pages/demo' }); + const second = injectWechatShareBridge(first, { pageUrl: 'https://m.tkmind.cn/u/john/pages/demo' }); + assert.equal((second.match(/data-tkmind-wechat-share="1"/g) ?? []).length, 1); +}); diff --git a/mindspace-page-sync.mjs b/mindspace-page-sync.mjs new file mode 100644 index 0000000..6ee96f0 --- /dev/null +++ b/mindspace-page-sync.mjs @@ -0,0 +1,261 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const PUBLIC_HTML_SKIP_DIRS = new Set([ + '.tmp-images', + 'assets', + 'images', + 'shared', + 'node_modules', +]); + +function normalizePublicHtmlPath(relativePath) { + const parts = String(relativePath ?? '') + .replace(/^\/+/, '') + .split('/') + .filter((part) => part && part !== '.' && part !== '..'); + if (parts.length === 0) return ''; + if (parts[0].toLowerCase() === 'public') return parts.join('/'); + if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`; + return parts.join('/'); +} + +function titleFromRelativePath(relativePath) { + const basename = path.basename(String(relativePath ?? ''), '.html'); + return basename.replace(/[-_]+/g, ' ').trim() || '生成的页面'; +} + +function extractHtmlTitle(content) { + const match = String(content).match(/]*>([^<]+)<\/title>/i); + return match?.[1]?.trim() ?? ''; +} + +function extractHtmlSummary(content) { + const match = String(content).match(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i); + return match?.[1]?.trim().slice(0, 1000) ?? ''; +} + +async function loadIndexedWorkspacePages(pool, userId) { + const [rows] = await pool.query( + `SELECT p.id, p.updated_at, p.source_asset_id, pv.source_snapshot_json + 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'`, + [userId], + ); + const byPath = new Map(); + for (const row of rows) { + let snapshot = {}; + try { + snapshot = JSON.parse(row.source_snapshot_json ?? '{}'); + } catch { + snapshot = {}; + } + const relativePath = normalizePublicHtmlPath(snapshot.relative_path); + if (relativePath) { + byPath.set(relativePath, { + id: row.id, + updatedAt: Number(row.updated_at ?? 0), + }); + } + if (row.source_asset_id && relativePath) { + byPath.set(`asset:${row.source_asset_id}`, { id: row.id, updatedAt: Number(row.updated_at ?? 0) }); + } + } + return byPath; +} + +async function listPublishPublicHtmlFiles(publishDir) { + const publicDir = path.join(publishDir, 'public'); + const files = []; + + async function walk(absDir, relWithinPublic) { + let entries; + try { + entries = await fs.readdir(absDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.name.startsWith('.') || PUBLIC_HTML_SKIP_DIRS.has(entry.name)) continue; + const absPath = path.join(absDir, entry.name); + const relPath = relWithinPublic ? `${relWithinPublic}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + await walk(absPath, relPath); + continue; + } + if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.html')) continue; + const stat = await fs.stat(absPath); + files.push({ + relativePath: normalizePublicHtmlPath(`public/${relPath}`), + absolutePath: absPath, + mtimeMs: stat.mtimeMs, + }); + } + } + + await walk(publicDir, ''); + return files; +} + +async function upsertWorkspaceHtmlPage({ + pageService, + userId, + indexedPages, + relativePath, + content, + assetId = null, + sourceMtimeMs = null, +}) { + const title = extractHtmlTitle(content) || titleFromRelativePath(relativePath); + const summary = extractHtmlSummary(content); + const existing = + indexedPages.get(relativePath) ?? + (assetId ? indexedPages.get(`asset:${assetId}`) : null); + + const pageInput = { + title, + summary, + content, + contentFormat: 'html', + pageType: 'html', + templateId: 'editorial', + categoryCode: 'draft', + }; + const snapshot = { + ...(assetId ? { source_asset_id: assetId } : {}), + source_category: 'public', + content_mode: 'static_html', + relative_path: relativePath, + auto_synced: true, + }; + + if (existing) { + if (sourceMtimeMs != null && existing.updatedAt >= sourceMtimeMs) { + return 'skipped'; + } + await pageService.updatePage(userId, existing.id, { + ...pageInput, + changeNote: '同步工作区页面更新', + }); + indexedPages.set(relativePath, { id: existing.id, updatedAt: Date.now() }); + return 'updated'; + } + + const page = await pageService.createFromChat(userId, pageInput, { + ...(assetId ? { assetId } : {}), + snapshot, + createdAt: + sourceMtimeMs != null && Number.isFinite(Number(sourceMtimeMs)) + ? Math.floor(Number(sourceMtimeMs)) + : undefined, + }); + indexedPages.set(relativePath, { id: page.id, updatedAt: Date.now() }); + if (assetId) indexedPages.set(`asset:${assetId}`, { id: page.id, updatedAt: Date.now() }); + return 'created'; +} + +export async function syncGeneratedPagesFromPublicAssets({ + pool, + pageService, + assetService, + userId, + publishDir = null, + syncWorkspaceAssets = null, +} = {}) { + if (!pool || !pageService || !userId) { + return { created: 0, skipped: 0, updated: 0 }; + } + + if (typeof syncWorkspaceAssets === 'function') { + await syncWorkspaceAssets(userId, { categoryCode: 'public' }).catch(() => {}); + } + + const indexedPages = await loadIndexedWorkspacePages(pool, userId); + let created = 0; + let updated = 0; + let skipped = 0; + + if (publishDir) { + const htmlFiles = await listPublishPublicHtmlFiles(publishDir); + for (const file of htmlFiles) { + try { + const content = await fs.readFile(file.absolutePath, 'utf8'); + if (!content.trim()) { + skipped += 1; + continue; + } + const result = await upsertWorkspaceHtmlPage({ + pageService, + userId, + indexedPages, + relativePath: file.relativePath, + content, + sourceMtimeMs: file.mtimeMs, + }); + if (result === 'created') created += 1; + else if (result === 'updated') updated += 1; + else skipped += 1; + } catch (error) { + console.warn( + `[MindSpace] page sync failed for ${file.relativePath}:`, + error?.message ?? error, + ); + skipped += 1; + } + } + } + + if (!assetService) { + return { created, updated, skipped }; + } + + const [rows] = await pool.query( + `SELECT a.id, a.display_name, a.original_filename, a.updated_at + FROM h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + WHERE a.user_id = ? + AND c.category_code = 'public' + AND a.mime_type = 'text/html' + AND a.status = 'ready' + ORDER BY a.updated_at DESC + LIMIT 200`, + [userId], + ); + + for (const asset of rows) { + const relativePath = normalizePublicHtmlPath( + String(asset.original_filename ?? '').includes('/') + ? asset.original_filename + : `public/${asset.original_filename}`, + ); + if (indexedPages.has(relativePath)) { + skipped += 1; + continue; + } + try { + const { path: assetPath } = await assetService.readAsset(userId, asset.id); + const content = await fs.readFile(assetPath, 'utf8'); + const result = await upsertWorkspaceHtmlPage({ + pageService, + userId, + indexedPages, + relativePath, + content, + assetId: asset.id, + sourceMtimeMs: Number(asset.updated_at ?? 0), + }); + if (result === 'created') created += 1; + else if (result === 'updated') updated += 1; + else skipped += 1; + } catch (error) { + console.warn( + `[MindSpace] asset page sync failed for ${asset.original_filename}:`, + error?.message ?? error, + ); + skipped += 1; + } + } + + return { created, updated, skipped }; +} diff --git a/mindspace-page-sync.test.mjs b/mindspace-page-sync.test.mjs new file mode 100644 index 0000000..b947444 --- /dev/null +++ b/mindspace-page-sync.test.mjs @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs'; + +test('syncGeneratedPagesFromPublicAssets registers html files from publish public dir', async () => { + const publishDir = await fs.mkdtemp(path.join(os.tmpdir(), 'page-sync-publish-')); + 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 pool = { + async query(sql) { + if (sql.includes('FROM h5_page_records p')) return [[]]; + if (sql.includes('FROM h5_assets a')) return [[]]; + return [[]]; + }, + }; + const pageService = { + async createFromChat(userId, input, source) { + created.push({ userId, input, source }); + return { id: 'page-new' }; + }, + async updatePage() { + throw new Error('should not update'); + }, + }; + + const result = await syncGeneratedPagesFromPublicAssets({ + pool, + pageService, + assetService: null, + userId: 'user-1', + publishDir, + }); + + assert.equal(result.created, 1); + assert.equal(created.length, 1); + assert.equal(created[0].input.title, 'Demo Page'); + assert.equal(created[0].source.snapshot.relative_path, 'public/demo.html'); +}); + +test('syncGeneratedPagesFromPublicAssets uses file mtime as createdAt', async () => { + const publishDir = await fs.mkdtemp(path.join(os.tmpdir(), 'page-sync-mtime-')); + const publicDir = path.join(publishDir, 'public'); + await fs.mkdir(publicDir, { recursive: true }); + const htmlPath = path.join(publicDir, 'timed.html'); + await fs.writeFile( + htmlPath, + 'TimedHi', + 'utf8', + ); + const mtimeMs = Date.parse('2026-06-20T10:00:00.000Z'); + await fs.utimes(htmlPath, mtimeMs / 1000, mtimeMs / 1000); + + const created = []; + const pool = { + async query(sql) { + if (sql.includes('FROM h5_page_records p')) return [[]]; + if (sql.includes('FROM h5_assets a')) return [[]]; + return [[]]; + }, + }; + const pageService = { + async createFromChat(userId, input, source) { + created.push({ userId, input, source }); + return { id: 'page-new' }; + }, + async updatePage() { + throw new Error('should not update'); + }, + }; + + await syncGeneratedPagesFromPublicAssets({ + pool, + pageService, + assetService: null, + userId: 'user-1', + publishDir, + }); + + assert.equal(created.length, 1); + assert.equal(created[0].source.createdAt, mtimeMs); +}); + +test('syncGeneratedPagesFromPublicAssets creates pages for unlinked public html assets', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'page-sync-')); + const htmlPath = path.join(tempDir, 'demo.html'); + await fs.writeFile(htmlPath, 'DemoHi', 'utf8'); + + const created = []; + const pool = { + async query(sql) { + if (sql.includes('FROM h5_page_records p')) return [[]]; + if (sql.includes('FROM h5_assets a')) { + return [[{ id: 'asset-1', display_name: 'demo.html', original_filename: 'demo.html', updated_at: 100 }]]; + } + return [[]]; + }, + }; + const pageService = { + async createFromChat(userId, input, source) { + created.push({ userId, input, source }); + return { id: 'page-new' }; + }, + async updatePage() { + throw new Error('should not update'); + }, + }; + const assetService = { + async readAsset() { + return { path: htmlPath }; + }, + }; + + const result = await syncGeneratedPagesFromPublicAssets({ + pool, + pageService, + assetService, + userId: 'user-1', + }); + assert.equal(result.created, 1); + assert.equal(created[0].source.assetId, 'asset-1'); +}); diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index 76d30f0..f787d06 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -363,7 +363,10 @@ export function createPageService(pool, options = {}) { normalized.content, ); writtenPath = stored.target; - const now = Date.now(); + const now = + source.createdAt != null && Number.isFinite(Number(source.createdAt)) + ? Math.floor(Number(source.createdAt)) + : Date.now(); const contentAssetType = normalized.contentFormat === 'html' ? 'html' : 'markdown'; const contentMimeType = @@ -567,7 +570,7 @@ export function createPageService(pool, options = {}) { 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.updated_at DESC LIMIT 100`, + ORDER BY p.created_at DESC, p.updated_at DESC LIMIT 100`, params, ); return rows.map(pageResponse); @@ -1146,6 +1149,7 @@ export function createPageService(pool, options = {}) { messageId: source.messageId, assetId: source.assetId ?? null, snapshot: source.snapshot, + createdAt: source.createdAt, }), createFromAgent: (userId, input, source) => createVersion(userId, input, { diff --git a/mindspace-public-finish-sync.mjs b/mindspace-public-finish-sync.mjs new file mode 100644 index 0000000..56be954 --- /dev/null +++ b/mindspace-public-finish-sync.mjs @@ -0,0 +1,248 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { extractStaticPageLinks } from './mindspace-chat-save.mjs'; + +const PUBLIC_HTML_PATH_PATTERN = /(?:^|[^a-z0-9_./-])(public\/[a-z0-9][a-z0-9._/-]{0,255}\.html)\b/i; +const PUBLIC_DOCX_HREF_PATTERN = /href=["']([^"'#?\s]+\.docx)["']/gi; + +function normalizePublicDocxHref(href) { + const clean = String(href ?? '') + .split('?')[0] + .split('#')[0] + .replace(/^\.\//, '') + .trim(); + if (!clean || clean.includes('://') || clean.startsWith('data:')) return null; + if (clean.startsWith('../oa/')) return clean.slice(3); + if (clean.startsWith('oa/')) return clean; + if (!clean.includes('/')) return `public/${clean}`; + return clean; +} + +function extractPublicDocxReferencesFromHtml(publishDir) { + const root = path.resolve(String(publishDir ?? '')); + const publicDir = path.join(root, 'public'); + if (!fs.existsSync(publicDir) || !fs.statSync(publicDir).isDirectory()) return []; + + const refs = new Set(); + for (const file of fs.readdirSync(publicDir)) { + if (!file.toLowerCase().endsWith('.html')) continue; + const content = fs.readFileSync(path.join(publicDir, file), 'utf8'); + for (const match of content.matchAll(PUBLIC_DOCX_HREF_PATTERN)) { + const relativePath = normalizePublicDocxHref(match[1]); + if (relativePath) refs.add(relativePath); + } + } + return [...refs]; +} + +function findLatestCompleteOaDocx(publishDir, { minSize = 8000 } = {}) { + const oaDir = path.join(path.resolve(String(publishDir ?? '')), 'oa'); + if (!fs.existsSync(oaDir) || !fs.statSync(oaDir).isDirectory()) return null; + + const candidates = fs + .readdirSync(oaDir) + .filter((name) => name.toLowerCase().endsWith('.docx')) + .map((name) => { + const absolutePath = path.join(oaDir, name); + const stat = fs.statSync(absolutePath); + return { name, absolutePath, size: stat.size, mtimeMs: stat.mtimeMs }; + }) + .filter((item) => item.size >= minSize) + .sort((a, b) => b.mtimeMs - a.mtimeMs || b.size - a.size); + + return candidates[0] ?? null; +} + +export function syncPublicDocxDownloads({ publishDir, minCompleteSize = 8000 } = {}) { + const root = path.resolve(String(publishDir ?? '')); + if (!root) return { synced: [], skipped: [] }; + + const source = findLatestCompleteOaDocx(root, { minSize: minCompleteSize }); + if (!source) return { synced: [], skipped: [] }; + + const synced = []; + const skipped = []; + for (const relativePath of extractPublicDocxReferencesFromHtml(root)) { + const destination = path.resolve(root, relativePath); + if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) { + skipped.push(relativePath); + continue; + } + let shouldCopy = !fs.existsSync(destination) || !fs.statSync(destination).isFile(); + if (!shouldCopy) { + try { + shouldCopy = fs.statSync(destination).size < source.size; + } catch { + shouldCopy = true; + } + } + if (!shouldCopy) { + skipped.push(relativePath); + continue; + } + try { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(source.absolutePath, destination); + synced.push(relativePath); + } catch { + skipped.push(relativePath); + } + } + return { synced, skipped, source: source.name }; +} + +function messageText(message) { + const textParts = Array.isArray(message?.content) + ? message.content + .filter((item) => item?.type === 'text' && typeof item.text === 'string') + .map((item) => item.text.trim()) + .filter(Boolean) + : []; + const displayText = + typeof message?.metadata?.displayText === 'string' ? message.metadata.displayText.trim() : ''; + return [...textParts, displayText].filter(Boolean).join('\n'); +} + +export function normalizePublicHtmlRelativePath(relativePath) { + const parts = String(relativePath ?? '') + .replace(/^\/+/, '') + .split('/') + .filter((part) => part && part !== '.' && part !== '..'); + if (parts.length === 0) return ''; + if (parts[0].toLowerCase() === 'public') return parts.join('/'); + if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`; + return parts.join('/'); +} + +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 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; +} + +export function extractPublicHtmlWriteArtifacts(messages = []) { + const artifacts = new Map(); + for (const message of messages) { + 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; + 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; + if (content == null) continue; + artifacts.set(relativePath, { relativePath, content }); + } + } + return [...artifacts.values()]; +} + +const PUBLIC_HTML_STUB_MARKERS = ['临时补出', '服务号兜底', '服务号自动补出简版页面']; + +export function isStubPublicHtmlContent(content) { + const value = String(content ?? ''); + return PUBLIC_HTML_STUB_MARKERS.some((marker) => value.includes(marker)); +} + +function shouldReplaceExistingPublicHtml(destination, nextContent) { + if (!fs.existsSync(destination) || !fs.statSync(destination).isFile()) return true; + try { + const existing = fs.readFileSync(destination, 'utf8'); + if (isStubPublicHtmlContent(existing)) return true; + return typeof nextContent === 'string' && nextContent.length > 0 && existing !== nextContent; + } catch { + return Boolean(nextContent); + } +} + +export function materializeMissingPublicHtmlWrites({ messages, publishDir }) { + const root = path.resolve(String(publishDir ?? '')); + if (!root) return { materialized: [], skipped: [] }; + + const materialized = []; + const skipped = []; + for (const artifact of extractPublicHtmlWriteArtifacts(messages)) { + const destination = path.resolve(root, artifact.relativePath); + if (destination !== root && !destination.startsWith(`${root}${path.sep}`)) { + skipped.push(artifact.relativePath); + continue; + } + if (fs.existsSync(destination) && fs.statSync(destination).isFile()) { + if (!shouldReplaceExistingPublicHtml(destination, artifact.content)) { + skipped.push(artifact.relativePath); + continue; + } + } + try { + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, artifact.content, 'utf8'); + materialized.push(artifact.relativePath); + } catch { + skipped.push(artifact.relativePath); + } + } + return { materialized, skipped }; +} + +export function hasRecentOwnPublicHtmlReference(messages, currentUser, { recentCount = 80 } = {}) { + 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) { + return true; + } + const text = messageText(message); + if (!text) continue; + if ( + extractStaticPageLinks(text, { + userId: currentUser.id, + username: currentUser.username ?? null, + }).length > 0 + ) { + return true; + } + if (PUBLIC_HTML_PATH_PATTERN.test(text)) { + return true; + } + } + return false; +} + +export async function syncPublicHtmlAfterFinish({ + messages, + currentUser, + publishDir, + syncWorkspaceAssets, +} = {}) { + if (!hasRecentOwnPublicHtmlReference(messages, currentUser)) { + return { materialized: [], skipped: [], synced: false }; + } + + const { materialized, skipped } = materializeMissingPublicHtmlWrites({ messages, publishDir }); + const docxSync = syncPublicDocxDownloads({ publishDir }); + let synced = false; + if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) { + await syncWorkspaceAssets(currentUser.id, { categoryCode: 'public' }); + synced = true; + } + return { materialized, skipped, synced, docxSync }; +} diff --git a/mindspace-public-finish-sync.test.mjs b/mindspace-public-finish-sync.test.mjs new file mode 100644 index 0000000..e9aa3dd --- /dev/null +++ b/mindspace-public-finish-sync.test.mjs @@ -0,0 +1,326 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { hasRecentOwnPublicHtmlReference, isStubPublicHtmlContent, materializeMissingPublicHtmlWrites, syncPublicDocxDownloads } from './mindspace-public-finish-sync.mjs'; + +const CURRENT_USER = { id: 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e', username: 'john' }; + +test('detects a recent own public html link', () => { + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'text', + text: '[Hello](https://m.tkmind.cn/MindSpace/a6fb1e97-2b0f-447b-b138-4561d8e5c53e/public/hello.html)', + }, + ], + metadata: { userVisible: true }, + }, + ]; + assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER), true); +}); + +test('detects a recent public html tool write hint without a full url', () => { + const messages = [ + { + role: 'user', + content: [ + { + type: 'toolResponse', + toolResult: { + value: { + content: [{ type: 'text', text: '已写入 public/guizhou-guide.html(862 行)' }], + }, + }, + }, + ], + metadata: { userVisible: true, displayText: '已写入 public/guizhou-guide.html(862 行)' }, + }, + ]; + assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER), true); +}); + +test('detects a public html tool request even when the message has no text', () => { + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'sandbox-fs__write_file', + arguments: { + path: 'public/winter-melon-soup-recipe.html', + content: 'Winter melon soup', + }, + }, + }, + }, + ], + metadata: { userVisible: true }, + }, + ]; + assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER), true); +}); + +test('detects public html references beyond the old eight message window', () => { + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'sandbox-fs__write_file', + arguments: { + path: 'public/long-flow.html', + content: 'Long flow', + }, + }, + }, + }, + ], + }, + ...Array.from({ length: 12 }, (_, index) => ({ + role: index % 2 === 0 ? 'assistant' : 'user', + content: [{ type: 'text', text: `validation step ${index + 1}` }], + })), + ]; + assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER), true); +}); + +test('ignores other users public html links', () => { + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'text', + text: '[Hello](https://m.tkmind.cn/MindSpace/other-user/public/hello.html)', + }, + ], + metadata: { userVisible: true }, + }, + ]; + assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER), false); +}); + +test('materializeMissingPublicHtmlWrites writes html from sandbox write_file tool calls', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-')); + try { + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'sandbox-fs__write_file', + arguments: { + path: 'public/summer-essay.html', + content: 'Summer', + }, + }, + }, + }, + ], + metadata: { userVisible: true }, + }, + ]; + const result = materializeMissingPublicHtmlWrites({ messages, publishDir }); + assert.deepEqual(result.materialized, ['public/summer-essay.html']); + assert.equal( + fs.readFileSync(path.join(publishDir, 'public/summer-essay.html'), 'utf8'), + 'Summer', + ); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('materializeMissingPublicHtmlWrites writes html from developer write tool calls', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-')); + try { + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'write', + arguments: { + path: 'public/guizhou-guide.html', + content: 'Guizhou', + }, + }, + }, + }, + ], + }, + ]; + const result = materializeMissingPublicHtmlWrites({ messages, publishDir }); + assert.deepEqual(result.materialized, ['public/guizhou-guide.html']); + assert.equal( + fs.readFileSync(path.join(publishDir, 'public/guizhou-guide.html'), 'utf8'), + 'Guizhou', + ); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('materializeMissingPublicHtmlWrites replaces stub placeholder html', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-')); + try { + const stubPath = path.join(publishDir, 'public/guide.html'); + fs.mkdirSync(path.dirname(stubPath), { recursive: true }); + fs.writeFileSync(stubPath, '根据你的要求临时补出的简版页面', 'utf8'); + assert.equal(isStubPublicHtmlContent(fs.readFileSync(stubPath, 'utf8')), true); + + const messages = [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'write_file', + arguments: { + path: 'public/guide.html', + content: 'Real GuideFull page', + }, + }, + }, + }, + ], + }, + ]; + const result = materializeMissingPublicHtmlWrites({ messages, publishDir }); + assert.deepEqual(result.materialized, ['public/guide.html']); + assert.match(fs.readFileSync(stubPath, 'utf8'), /Real Guide/); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('syncPublicDocxDownloads copies newer complete oa docx into public download targets', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-docx-sync-')); + try { + const oaDir = path.join(publishDir, 'oa'); + const publicDir = path.join(publishDir, 'public'); + fs.mkdirSync(oaDir, { recursive: true }); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(oaDir, 'latest-plan.docx'), 'x'.repeat(9000)); + fs.writeFileSync(path.join(publicDir, 'download.html'), 'download'); + fs.writeFileSync(path.join(publicDir, 'old-plan.docx'), 'stub'); + + const result = syncPublicDocxDownloads({ publishDir, minCompleteSize: 8000 }); + assert.deepEqual(result.synced, ['public/old-plan.docx']); + assert.equal(result.source, 'latest-plan.docx'); + assert.equal(fs.readFileSync(path.join(publicDir, 'old-plan.docx'), 'utf8').length, 9000); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('syncPublicDocxDownloads skips public docx that is already up to date', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-docx-sync-')); + try { + const oaDir = path.join(publishDir, 'oa'); + const publicDir = path.join(publishDir, 'public'); + fs.mkdirSync(oaDir, { recursive: true }); + fs.mkdirSync(publicDir, { recursive: true }); + fs.writeFileSync(path.join(oaDir, 'latest-plan.docx'), 'x'.repeat(9000)); + fs.writeFileSync(path.join(publicDir, 'download.html'), 'download'); + fs.writeFileSync(path.join(publicDir, 'old-plan.docx'), 'x'.repeat(9000)); + + const result = syncPublicDocxDownloads({ publishDir, minCompleteSize: 8000 }); + assert.deepEqual(result.synced, []); + assert.deepEqual(result.skipped, ['public/old-plan.docx']); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('materializeMissingPublicHtmlWrites replaces existing real html when content changed', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-')); + try { + const htmlPath = path.join(publishDir, 'public/guide.html'); + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync(htmlPath, 'Existing', 'utf8'); + const result = materializeMissingPublicHtmlWrites({ + messages: [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'write_file', + arguments: { + path: 'public/guide.html', + content: 'Replacement', + }, + }, + }, + }, + ], + }, + ], + publishDir, + }); + assert.deepEqual(result.materialized, ['public/guide.html']); + assert.deepEqual(result.skipped, []); + assert.match(fs.readFileSync(htmlPath, 'utf8'), /Replacement/); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); + +test('materializeMissingPublicHtmlWrites skips existing real html when content is unchanged', () => { + const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-')); + try { + const htmlPath = path.join(publishDir, 'public/guide.html'); + const html = 'Existing'; + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync(htmlPath, html, 'utf8'); + const result = materializeMissingPublicHtmlWrites({ + messages: [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'write_file', + arguments: { + path: 'public/guide.html', + content: html, + }, + }, + }, + }, + ], + }, + ], + publishDir, + }); + assert.deepEqual(result.materialized, []); + assert.deepEqual(result.skipped, ['public/guide.html']); + assert.match(fs.readFileSync(htmlPath, 'utf8'), /Existing/); + } finally { + fs.rmSync(publishDir, { recursive: true, force: true }); + } +}); diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs index 61d7500..e29ac0c 100644 --- a/mindspace-publications.mjs +++ b/mindspace-publications.mjs @@ -5,7 +5,7 @@ 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 { resolvePublicBaseUrl } from './user-publish.mjs'; +import { buildPublicUrl, resolvePublicBaseUrl } from './user-publish.mjs'; import { createImgproxySigner } from './imgproxy-signer.mjs'; const SCANNER_VERSION = 'mindspace-content-v1'; @@ -160,6 +160,40 @@ function buildPublicationThumbnailFallback(ownerSlug, urlSlug) { return `/u/${encodeURIComponent(ownerSlug)}/pages/${encodeURIComponent(urlSlug)}.thumbnail.png`; } +const PUBLIC_IMAGE_STORAGE_KEY_PATTERN = /^users\/([^/]+)\/images\/(\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)$/i; + +function storageKeyToPublicStandardImageUrl(storageKey, publicBaseUrl) { + const match = String(storageKey ?? '').match(PUBLIC_IMAGE_STORAGE_KEY_PATTERN); + if (!match) return null; + return buildPublicUrl(publicBaseUrl, match[1], `public/images/${match[2]}`); +} + +function replaceImgproxyStandardImageReferences(html, publicBaseUrl) { + const source = String(html ?? ''); + if (!source) return source; + return source.replace( + /(?:https?:\/\/[^/"'<>?\s,)]+)?\/[^"'<>?\s,)]*plain\/local:\/\/\/(users\/[^"'<>?\s,)]*?\/images\/\d{4}-\d{2}-\d{2}\/[^"'<>?\s,@)]+)(?:@[a-z0-9]+)?(?:\?[^"'<>),\s]*)?/gi, + (value, storageKey) => storageKeyToPublicStandardImageUrl(storageKey, publicBaseUrl) ?? value, + ); +} + +function workspacePublicAssetRelativeUrl(htmlRelativePath, assetRelativePath) { + const htmlPath = String(htmlRelativePath ?? '').replace(/^\/+/, '') || 'public/index.html'; + const assetPath = String(assetRelativePath ?? '').replace(/^\/+/, ''); + const htmlDir = path.posix.dirname(htmlPath); + const relativePath = path.posix.relative(htmlDir === '.' ? '' : htmlDir, assetPath).replace(/\\/g, '/'); + return relativePath || path.posix.basename(assetPath); +} + +export function rewriteWorkspacePublicAssetReferences(html, htmlRelativePath) { + const source = String(html ?? ''); + if (!source) return source; + return source.replace( + /public\/((?:images|\.tmp-images)\/[^"'<>@\s)]+(?:\?[^"'<>)\s]*)?)/gi, + (value, assetRelativePath) => workspacePublicAssetRelativeUrl(htmlRelativePath, `public/${assetRelativePath}`), + ); +} + async function localizePrivateImageReferences({ pool, userId, @@ -210,10 +244,15 @@ async function prepareHtmlPublishContent({ html, ownerSlug, urlSlug, + htmlRelativePath = `public/${urlSlug}.html`, absoluteStoragePath, imgproxySigner = null, }) { let publishContent = (await localizeGoogleFontsCss(html)).html; + publishContent = replaceImgproxyStandardImageReferences( + publishContent, + resolvePublicBaseUrl(), + ); publishContent = await localizePrivateImageReferences({ pool, userId, @@ -221,6 +260,7 @@ async function prepareHtmlPublishContent({ absoluteStoragePath, imgproxySigner, }); + publishContent = rewriteWorkspacePublicAssetReferences(publishContent, htmlRelativePath); return replacePrivateResourceReferences( publishContent, buildPublicationThumbnailFallback(ownerSlug, urlSlug), @@ -959,6 +999,9 @@ export const publicationInternals = { referrerHost, publicHomepageResponse, buildPublicationThumbnailFallback, + replaceImgproxyStandardImageReferences, + workspacePublicAssetRelativeUrl, + rewriteWorkspacePublicAssetReferences, localizePrivateImageReferences, prepareHtmlPublishContent, scanContent, // re-exported from mindspace-content-scan.mjs diff --git a/mindspace-publications.test.mjs b/mindspace-publications.test.mjs index 9d97d56..df01b52 100644 --- a/mindspace-publications.test.mjs +++ b/mindspace-publications.test.mjs @@ -195,3 +195,78 @@ test('prepareHtmlPublishContent inlines owned private image assets before scanni await fs.rm(root, { recursive: true, force: true }); } }); + +test('prepareHtmlPublishContent rewrites imgproxy local image urls to public standard images', async () => { + const prepared = await publicationInternals.prepareHtmlPublishContent({ + pool: { + async query() { + return [[]]; + }, + }, + userId: 'user-1', + html: '', + ownerSlug: 'john', + urlSlug: 'story', + absoluteStoragePath: () => { + throw new Error('should not read private storage'); + }, + }); + + assert.match(prepared, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/images\/2026-06-29\/hero\.jpg/); + assert.doesNotMatch(prepared, /plain\/local:\/\//); +}); + +test('prepareHtmlPublishContent rewrites imgproxy urls in srcset and css url contexts', async () => { + const prepared = await publicationInternals.prepareHtmlPublishContent({ + pool: { + async query() { + return [[]]; + }, + }, + userId: 'user-1', + html: [ + '', + "", + '
', + ].join(''), + ownerSlug: 'john', + urlSlug: 'story', + absoluteStoragePath: () => { + throw new Error('should not read private storage'); + }, + }); + + assert.match(prepared, /\/MindSpace\/user-1\/images\/2026-06-29\/thumb\.jpg 1x/); + assert.match(prepared, /\/MindSpace\/user-1\/images\/2026-06-29\/hero\.jpg 2x/); + assert.match(prepared, /url\(https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/images\/2026-06-29\/bg\.jpg\)/); + assert.doesNotMatch(prepared, /plain\/local:\/\//); +}); + +test('prepareHtmlPublishContent rewrites workspace public asset paths relative to html output', async () => { + const prepared = await publicationInternals.prepareHtmlPublishContent({ + pool: { + async query() { + return [[]]; + }, + }, + userId: 'user-1', + html: [ + '', + ``, + `
`, + ``, + ].join(''), + ownerSlug: 'john', + urlSlug: 'shared-story', + htmlRelativePath: 'public/shared/shared-story.html', + absoluteStoragePath: () => { + throw new Error('should not read private storage'); + }, + }); + + assert.match(prepared, /"cover":"\.\.\/images\/2026-06-29\/hero\.jpg"/); + assert.match(prepared, /url\('\.\.\/images\/2026-06-29\/bg\.jpg'\)/); + assert.match(prepared, /src="\.\.\/\.tmp-images\/card\.png"/); + assert.doesNotMatch(prepared, /public\/images\//); + assert.doesNotMatch(prepared, /public\/\.tmp-images\//); +}); diff --git a/mindspace.mjs b/mindspace.mjs index ef13928..d156960 100644 --- a/mindspace.mjs +++ b/mindspace.mjs @@ -13,21 +13,13 @@ export const SYSTEM_CATEGORIES = Object.freeze([ publishPolicy: 'derivative_only', sortOrder: 10, }, - { - code: 'private', - name: '私人区', - visibilityPolicy: 'private', - aiAccessPolicy: 'explicit_asset_grant', - publishPolicy: 'desensitized_copy_only', - sortOrder: 20, - }, { code: 'public', name: '公开区', visibilityPolicy: 'public_candidate', aiAccessPolicy: 'selected_assets', publishPolicy: 'security_scan_required', - sortOrder: 30, + sortOrder: 20, }, { code: 'draft', diff --git a/mindspace.test.mjs b/mindspace.test.mjs index 9f963e9..3bbee28 100644 --- a/mindspace.test.mjs +++ b/mindspace.test.mjs @@ -33,7 +33,7 @@ test('initializeDefaultSpace creates one space and all system categories', async queries .filter(({ sql }) => sql.includes('INSERT INTO h5_space_categories')) .map(({ params }) => params[3]), - ['oa', 'private', 'public', 'draft', 'archive'], + ['oa', 'public', 'draft', 'archive'], ); }); diff --git a/package.json b/package.json index 87e15a2..29d75a4 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "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 user-publish.test.mjs user-memory-profile.test.mjs skills-registry.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-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", + "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 user-publish.test.mjs user-memory-profile.test.mjs skills-registry.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", + "verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs", "test:mindspace-e2e": "node scripts/mindspace-e2e.mjs", "test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs", "test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs", @@ -65,6 +66,7 @@ "react-dom": "^19.0.0", "react-router-dom": "^7.13.1", "redis": "^4.7.1", + "sharp": "^0.35.2", "undici": "^6.26.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15761c6..a5cd72f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,15 +20,24 @@ importers: express: specifier: ^4.21.2 version: 4.22.2 + framer-motion: + specifier: ^12.42.0 + version: 12.42.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) http-proxy-middleware: specifier: ^3.0.3 version: 3.0.6 jsonrepair: specifier: ^3.14.0 version: 3.14.0 + lucide-react: + specifier: ^1.21.0 + version: 1.22.0(react@19.2.7) mysql2: specifier: ^3.22.5 version: 3.22.5(@types/node@25.9.3) + pg: + specifier: ^8.22.0 + version: 8.22.0 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -44,6 +53,9 @@ importers: redis: specifier: ^4.7.1 version: 4.7.1 + sharp: + specifier: ^0.35.2 + version: 0.35.2 undici: specifier: ^6.26.0 version: 6.26.0 @@ -317,6 +329,168 @@ packages: cpu: [x64] os: [win32] + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -853,6 +1027,10 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} @@ -946,6 +1124,20 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@12.42.0: + resolution: {integrity: sha512-wp7EJnfWaaEScVygKv3e20udoRz+LbtxScsuTkakAxfXmt+ReC6WyPW2nINRAGvd+hG9odwcjBLyOTPjH5pBRA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@0.5.2: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} @@ -1078,6 +1270,11 @@ packages: resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lucide-react@1.22.0: + resolution: {integrity: sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -1110,6 +1307,12 @@ packages: engines: {node: '>=4'} hasBin: true + motion-dom@12.42.0: + resolution: {integrity: sha512-M63h4n8R+quJdNhBwuLlgxM+OLYa9+I/T2pzDRboB9fLXRdbou+Gw7Zury+SkpaCyACP1JHSjHgZ1EgTkBr30w==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -1170,6 +1373,40 @@ packages: path-to-regexp@0.1.13: resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1189,6 +1426,22 @@ packages: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1271,6 +1524,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -1288,6 +1546,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -1308,6 +1570,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sql-escaper@1.3.3: resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} @@ -1436,6 +1702,10 @@ packages: utf-8-validate: optional: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -1661,6 +1931,112 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2076,6 +2452,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@2.1.2: {} + dijkstrajs@1.0.3: {} dunder-proto@1.0.1: @@ -2206,6 +2584,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@12.42.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + motion-dom: 12.42.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + fresh@0.5.2: {} fsevents@2.3.3: @@ -2335,6 +2722,10 @@ snapshots: lru.min@1.1.4: {} + lucide-react@1.22.0(react@19.2.7): + dependencies: + react: 19.2.7 + math-intrinsics@1.1.0: {} media-typer@0.3.0: {} @@ -2356,6 +2747,12 @@ snapshots: mime@1.6.0: {} + motion-dom@12.42.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -2404,6 +2801,41 @@ snapshots: path-to-regexp@0.1.13: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -2418,6 +2850,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -2519,6 +2961,8 @@ snapshots: semver@6.3.1: {} + semver@7.8.5: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -2552,6 +2996,38 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -2582,6 +3058,8 @@ snapshots: source-map-js@1.2.1: {} + split2@4.2.0: {} + sql-escaper@1.3.3: {} statuses@2.0.2: {} @@ -2607,8 +3085,7 @@ snapshots: toidentifier@1.0.1: {} - tslib@2.8.1: - optional: true + tslib@2.8.1: {} type-is@1.6.18: dependencies: @@ -2657,6 +3134,8 @@ snapshots: ws@8.21.0: {} + xtend@4.0.2: {} + y18n@4.0.3: {} yallist@3.1.1: {} diff --git a/schema.sql b/schema.sql index 20460a4..9b885a0 100644 --- a/schema.sql +++ b/schema.sql @@ -373,6 +373,7 @@ CREATE TABLE IF NOT EXISTS h5_usage_records ( cost_cents BIGINT NOT NULL, balance_after_cents BIGINT NOT NULL, created_at BIGINT NOT NULL, + UNIQUE KEY uniq_h5_usage_request_id (request_id), KEY idx_h5_usage_user_time (user_id, created_at), CONSTRAINT fk_h5_usage_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index a5ad9fe..4566d1b 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -16,6 +16,8 @@ const skipBuild = process.argv.includes('--skip-build'); const skipNodeModules = process.argv.includes('--skip-node-modules'); const externalPackages = [ + '@img/sharp-darwin-arm64', + '@img/sharp-libvips-darwin-arm64', '@node-rs/argon2', '@resvg/resvg-js', 'debug', @@ -26,6 +28,7 @@ const externalPackages = [ 'mysql2/promise', 'qrcode', 'redis', + 'sharp', 'undici', ]; diff --git a/scripts/imgproxy-compat-proxy.mjs b/scripts/imgproxy-compat-proxy.mjs new file mode 100644 index 0000000..4de867a --- /dev/null +++ b/scripts/imgproxy-compat-proxy.mjs @@ -0,0 +1,56 @@ +import http from 'node:http'; +import { request as httpRequest } from 'node:http'; + +const listenHost = process.env.IMGPROXY_COMPAT_HOST || '10.10.0.2'; +const listenPort = Number(process.env.IMGPROXY_COMPAT_PORT || 20081); +const upstream = new URL(process.env.IMGPROXY_UPSTREAM || 'http://127.0.0.1:20082'); + +function convertPath(url) { + if (url === '/health') return '/health'; + const pathname = url.split('?')[0] || '/'; + const match = pathname.match(/^(?:\/[^/]+)?\/unsafe\/(\d+)x(\d+)\/(\d+)\/([a-z0-9]+)\/(.+)$/i); + if (!match) return pathname; + const [, width, height, quality, format, encodedSource] = match; + let source; + source = encodedSource; + for (let i = 0; i < 3; i += 1) { + try { + const decoded = decodeURIComponent(source); + if (decoded === source) break; + source = decoded; + } catch { + break; + } + } + if (source.startsWith('local://') && !source.startsWith('local:///')) { + source = `local:///${source.slice('local://'.length)}`; + } + return `/unsafe/rs:fit:${width}:${height}/q:${quality}/plain/${source}@${format}`; +} + +const server = http.createServer((req, res) => { + const upstreamPath = convertPath(req.url || '/'); + const options = { + hostname: upstream.hostname, + port: upstream.port || 80, + method: req.method, + path: upstreamPath, + headers: { + ...req.headers, + host: upstream.host, + }, + }; + const proxy = httpRequest(options, (upstreamRes) => { + res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers); + upstreamRes.pipe(res); + }); + proxy.on('error', (err) => { + res.writeHead(502, { 'content-type': 'text/plain' }); + res.end(`imgproxy compat proxy error: ${err.message}`); + }); + req.pipe(proxy); +}); + +server.listen(listenPort, listenHost, () => { + console.log(`imgproxy compat proxy listening on ${listenHost}:${listenPort}, upstream ${upstream.href}`); +}); diff --git a/scripts/release-portal-runtime-prod.sh b/scripts/release-portal-runtime-prod.sh index 647ee4e..364e587 100755 --- a/scripts/release-portal-runtime-prod.sh +++ b/scripts/release-portal-runtime-prod.sh @@ -121,6 +121,7 @@ if [[ "${SKIP_TESTS}" -ne 1 ]]; then ( cd "${ROOT}" npm test -- --test-name-pattern='publish|space|billing' >/dev/null + node --test mindspace-public-finish-sync.test.mjs >/dev/null ) fi @@ -153,6 +154,12 @@ verify_runtime_artifact() { verify_runtime_artifact +say "验证公开 HTML 完成同步守卫" +( + cd "${ROOT}" + npm run verify:public-finish-sync-runtime +) + verify_remote_goosed_dependency() { say "检查 103 goosed 依赖" ssh -o BatchMode=yes "${HOST}" 'bash -s' <<'REMOTE' diff --git a/scripts/verify-public-finish-sync-runtime.mjs b/scripts/verify-public-finish-sync-runtime.mjs new file mode 100644 index 0000000..97b9ce5 --- /dev/null +++ b/scripts/verify-public-finish-sync-runtime.mjs @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + hasRecentOwnPublicHtmlReference, + materializeMissingPublicHtmlWrites, +} from '../mindspace-public-finish-sync.mjs'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); +const runtimeServer = path.join(root, '.runtime', 'portal', 'server.mjs'); +const currentUser = { id: 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e', username: 'john' }; + +const toolRequestOnlyMessages = [ + { + role: 'assistant', + content: [ + { + type: 'toolRequest', + toolCall: { + value: { + name: 'write', + arguments: { + path: 'public/guarded-release.html', + content: 'Guarded release', + }, + }, + }, + }, + ], + }, + ...Array.from({ length: 12 }, (_, index) => ({ + role: index % 2 === 0 ? 'assistant' : 'user', + content: [{ type: 'text', text: `validation step ${index + 1}` }], + })), +]; + +assert.equal( + hasRecentOwnPublicHtmlReference(toolRequestOnlyMessages, currentUser), + true, + 'finish sync must detect textless write tool requests beyond the old eight-message window', +); + +const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-release-guard-')); +try { + const result = materializeMissingPublicHtmlWrites({ + messages: toolRequestOnlyMessages, + publishDir, + }); + assert.deepEqual(result.materialized, ['public/guarded-release.html']); + assert.equal( + fs.readFileSync(path.join(publishDir, 'public/guarded-release.html'), 'utf8'), + 'Guarded release', + ); +} finally { + fs.rmSync(publishDir, { recursive: true, force: true }); +} + +if (!fs.existsSync(runtimeServer)) { + throw new Error(`runtime server bundle is missing: ${runtimeServer}`); +} + +const runtimeSource = fs.readFileSync(runtimeServer, 'utf8'); +const requiredRuntimeSnippets = [ + 'normalizedName === "write"', + 'normalizedName.endsWith("__write")', + 'recentCount = 80', + 'if (extractPublicHtmlWriteArtifacts([message]).length > 0) {', +]; + +for (const snippet of requiredRuntimeSnippets) { + assert.ok( + runtimeSource.includes(snippet), + `runtime server bundle is missing public finish sync guard: ${snippet}`, + ); +} + +console.log('public finish sync runtime guard ok'); diff --git a/server.mjs b/server.mjs index 4448cc6..1989e70 100644 --- a/server.mjs +++ b/server.mjs @@ -13,7 +13,7 @@ import { sessionCookie, } from './auth.mjs'; import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs'; -import { createTkmindProxy } from './tkmind-proxy.mjs'; +import { createTkmindProxy, sanitizeSessionConversationPublicHtmlLinks } from './tkmind-proxy.mjs'; import { clearUserSessionCookie, createUserAuth, @@ -37,7 +37,7 @@ import { createPageService, pageInternals, inlinePrivateAssetsInHtml } from './m import { createPageLiveEditService } from './mindspace-page-live-edit.mjs'; import { createPageEditSessionService } from './mindspace-page-edit-session.mjs'; import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs'; -import { createPublicationService } from './mindspace-publications.mjs'; +import { createPublicationService, rewriteWorkspacePublicAssetReferences } from './mindspace-publications.mjs'; import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs'; import { createPlazaEventService } from './plaza-events.mjs'; import { createPlazaRecommendService } from './plaza-recommend.mjs'; @@ -80,7 +80,7 @@ import { import { syncPublicHtmlAfterFinish } from './mindspace-public-finish-sync.mjs'; import { syncGeneratedPagesFromPublicAssets } from './mindspace-page-sync.mjs'; import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; -import { injectOgTags } from './mindspace-og-tags.mjs'; +import { injectOgTags, injectWechatShareBridge } from './mindspace-og-tags.mjs'; import { ensureThumbnailPng, rasterizeThumbnailSvgToPng, @@ -97,6 +97,7 @@ import { } from './wechat-pay.mjs'; import { createWechatOAuthService, isWechatUserAgent, loadWechatOAuthConfig } from './wechat-oauth.mjs'; import { createWechatMpService, loadWechatMpConfig } from './wechat-mp.mjs'; +import { validateWechatShareSignatureUrl } from './wechat-share.mjs'; import { createScheduleService } from './schedule-service.mjs'; import { createFeedbackService } from './user-feedback.mjs'; import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs'; @@ -790,15 +791,11 @@ app.get('/auth/wechat/js-sdk-signature', async (req, res) => { return res.status(400).json({ message: '缺少 url' }); } try { - const publicHost = WECHAT_MP_CONFIG.publicBaseUrl - ? new URL(WECHAT_MP_CONFIG.publicBaseUrl).host - : null; - const target = new URL(pageUrl); - const requestHost = req.get('x-forwarded-host') || req.get('host') || ''; - if (publicHost && target.host !== publicHost && target.host !== requestHost) { - return res.status(400).json({ message: 'url 不属于当前 H5 域名' }); - } - const payload = await wechatMpService.createJsSdkSignature(pageUrl); + const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, { + publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl, + requestHost: req.get('x-forwarded-host') || req.get('host') || '', + }); + const payload = await wechatMpService.createJsSdkSignature(normalizedUrl); return res.json(payload); } catch (err) { const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败'; @@ -807,6 +804,29 @@ app.get('/auth/wechat/js-sdk-signature', async (req, res) => { } }); +app.get('/auth/wechat/public-js-sdk-signature', async (req, res) => { + await userAuthReady; + if (!wechatMpService?.enabled) { + return res.status(503).json({ message: '微信 JS-SDK 未启用' }); + } + const pageUrl = String(req.query?.url ?? '').split('#')[0]; + if (!pageUrl) { + return res.status(400).json({ message: '缺少 url' }); + } + try { + const normalizedUrl = validateWechatShareSignatureUrl(pageUrl, { + publicBaseUrl: WECHAT_MP_CONFIG.publicBaseUrl, + requestHost: req.get('x-forwarded-host') || req.get('host') || '', + }); + const payload = await wechatMpService.createJsSdkSignature(normalizedUrl); + return res.json(payload); + } catch (err) { + const message = err instanceof Error ? err.message : '微信 JS-SDK 签名失败'; + console.warn('WeChat public JS-SDK signature failed:', message); + return res.status(502).json({ message }); + } +}); + app.get('/auth/wechat/status', async (req, res) => { await userAuthReady; if (!userAuth || !wechatOAuthService?.enabled) { @@ -2884,11 +2904,13 @@ api.post('/mindspace/v1/pages/quick-share-from-chat', async (req, res) => { const basename = bundle.resolvedHtml.filename.replace(/\.html$/i, ''); const filename = `${basename}-${crypto.randomUUID().slice(0, 8)}.html`; + const sharedRelativePath = `${PUBLIC_ZONE_DIR}/shared/${filename}`; + const sharedHtml = rewriteWorkspacePublicAssetReferences(localizedHtml, sharedRelativePath); const destPath = path.join(sharedDir, filename); - await fsPromises.writeFile(destPath, localizedHtml, 'utf8'); + await fsPromises.writeFile(destPath, sharedHtml, 'utf8'); const publishKey = req.currentUser.id; - const publicUrl = `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/${PUBLIC_ZONE_DIR}/shared/${encodeURIComponent(filename)}`; + const publicUrl = `${resolvePublicBaseUrl()}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/${sharedRelativePath.split('/').map((part) => encodeURIComponent(part)).join('/')}`; return res.status(201).json({ data: { publicUrl, filename } }); } catch (error) { @@ -3899,11 +3921,15 @@ api.get('/sessions/:sessionId', async (req, res, next) => { const mcMatch = hintMc == null || snapshot.meta.synced_msg_count === hintMc; const uaMatch = hintUa == null || snapshot.meta.source_updated_at === hintUa; if (mcMatch && uaMatch) { + const sanitizedMessages = sanitizeSessionConversationPublicHtmlLinks( + snapshot.messages, + req.currentUser, + ); // Cache hit — reconstruct a Goose-compatible session response. const cachedGooseSession = { ...snapshot.session, // Embed only userVisible messages so getSession callers still work. - conversation: snapshot.messages, + conversation: sanitizedMessages, }; return res.json(cachedGooseSession); } @@ -3926,6 +3952,12 @@ api.get('/sessions/:sessionId', async (req, res, next) => { return res.status(upstream.status).send(text); } const gooseSession = await upstream.json(); + if (Array.isArray(gooseSession.conversation)) { + gooseSession.conversation = sanitizeSessionConversationPublicHtmlLinks( + gooseSession.conversation, + req.currentUser, + ); + } // Write-through: persist snapshot async, don't block the response. if (sessionSnapshotService?.isEnabled()) { const messages = (gooseSession.conversation ?? []) @@ -4105,20 +4137,74 @@ api.use( app.use('/api', api); -function publishedPageCsp(html, { embed = false, raw = false } = {}) { +function scriptSrcDirective({ inline = false, urls = [], hashes = [] } = {}) { + const parts = []; + if (inline) parts.push("'unsafe-inline'"); + for (const hash of hashes) parts.push(`'sha256-${hash}'`); + for (const url of urls) parts.push(url); + return parts.length ? `script-src ${parts.join(' ')}` : "script-src 'none'"; +} + +function publishedPageCsp(html, { embed = false, raw = false, wechatShare = false, scriptHashes = [] } = {}) { const isFullHtml = /^\s*]/i.test(html); if (embed && isFullHtml) { return publishedPageCspForEmbed(true); } + if (wechatShare && isFullHtml) { + return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline' https://res.wx.qq.com"; + } if (raw && isFullHtml) { - return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'"; + return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'"; } if (isFullHtml) { - return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'none'"; + return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({ hashes: scriptHashes })}`; } return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'"; } +const 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('复制失败');}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('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});})();`; +const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto + .createHash('sha256') + .update(PUBLIC_FILE_SHARE_SCRIPT) + .digest('base64'); + +function injectPublicFileShareButton(html) { + const source = String(html ?? ''); + if (!source || source.includes('data-mindspace-public-share')) { + return { html: source, scriptHashes: [] }; + } + const markup = ` + +
+`; + if (/<\/body>/i.test(source)) { + return { + html: source.replace(/<\/body>/i, `${markup}`), + scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH], + }; + } + return { + html: `${source}${markup}`, + scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH], + }; +} + +function extractOgImageUrl(html) { + return ( + String(html ?? '').match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] || + String(html ?? '').match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)?.[1] || + '' + ); +} + function appendQueryParam(url, key, value) { const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`; @@ -4504,36 +4590,60 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) { function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) { let html = result.html; + const origin = resolveRequestOrigin(req); + const pageUrl = req.originalUrl ? new URL(req.originalUrl, origin || 'http://localhost').toString().split('#')[0] : ''; + const pageDirUrl = pageUrl ? `${pageUrl.slice(0, pageUrl.lastIndexOf('/') + 1)}` : ''; + const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || ''); if (embed) { html = preparePublicationHtmlForEmbed(html); allowPlazaEmbedFrame(res); } else if (raw) { html = stripPublicationHtmlCspMeta(html); } + if (!embed) { + try { + html = injectOgTags(html, { origin, pageUrl, pageDirUrl }); + if (wechatShare) html = injectWechatShareBridge(html, { pageUrl }); + } catch { + // Never block public page delivery on share metadata injection. + } + } const isFullHtml = /^\s*]/i.test(html); const canWrapWithShell = !embed && !raw && isFullHtml && result.publication?.accessMode !== 'password'; if (canWrapWithShell) { const title = detectPublishedPageTitle(html); const rawUrl = appendQueryParam(req.originalUrl || req.url || '', 'view', 'raw'); + let shellHtml = publishedPageShellHtml({ + iframeUrl: rawUrl, + shareUrl: pageUrl, + title, + }); + try { + shellHtml = injectOgTags(shellHtml, { + origin, + pageUrl, + pageDirUrl, + fallbackImageUrl: extractOgImageUrl(html), + }); + if (wechatShare) shellHtml = injectWechatShareBridge(shellHtml, { pageUrl }); + } catch { + // Keep the share shell usable even if metadata injection fails. + } res.set('Content-Type', 'text/html; charset=utf-8'); res.set( 'Content-Security-Policy', - "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", + wechatShare + ? "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net https://res.wx.qq.com; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'" + : "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'", ); res.set( 'Cache-Control', result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store', ); - return res.send( - publishedPageShellHtml({ - iframeUrl: rawUrl, - shareUrl: req.originalUrl ? new URL(req.originalUrl, resolveRequestOrigin(req) || 'http://localhost').toString() : '', - title, - }), - ); + return res.send(shellHtml); } res.set('Content-Type', 'text/html; charset=utf-8'); - res.set('Content-Security-Policy', publishedPageCsp(html, { embed, raw })); + res.set('Content-Security-Policy', publishedPageCsp(html, { embed, raw, wechatShare })); res.set( 'Cache-Control', result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store', @@ -4840,8 +4950,22 @@ function sendPublishFile(req, res, filePath) { } try { html = injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl }); + const wechatShare = !embed && isWechatUserAgent(req.get('user-agent') || ''); + if (wechatShare) { + html = injectWechatShareBridge(html, { pageUrl }); + } + const shareInjection = !embed + ? injectPublicFileShareButton(html) + : { html, scriptHashes: [] }; + html = shareInjection.html; + res.set('Content-Security-Policy', publishedPageCsp(html, { + embed, + wechatShare, + scriptHashes: shareInjection.scriptHashes, + })); } catch { // On any parse failure, fall back to the original HTML — never break page delivery. + res.set('Content-Security-Policy', publishedPageCsp(html, { embed })); } res.set('Content-Type', 'text/html; charset=utf-8'); res.send(html); diff --git a/skills/docx-generate/generate_docx.py b/skills/docx-generate/generate_docx.py index 20c1945..ff28077 100644 --- a/skills/docx-generate/generate_docx.py +++ b/skills/docx-generate/generate_docx.py @@ -35,8 +35,22 @@ DOCUMENT_RELS = f""" """ +def sanitize_text(text: str) -> str: + """Strip emoji and XML-illegal control chars (Word/iOS rejects these).""" + cleaned = str(text or "") + cleaned = cleaned.encode("utf-8", "ignore").decode("utf-8") + cleaned = "".join( + ch + for ch in cleaned + if not (0xD800 <= ord(ch) <= 0xDFFF) + and (ord(ch) == 0x9 or ord(ch) == 0xA or ord(ch) == 0xD or ord(ch) >= 0x20) + and not (0x1F000 <= ord(ch) <= 0x1FAFF or 0x2600 <= ord(ch) <= 0x27BF) + ) + return cleaned.strip() + + def xml_text(text: str) -> str: - return escape(str(text or "")) + return escape(sanitize_text(text)) def run(text: str, bold: bool = False) -> str: @@ -53,11 +67,17 @@ def paragraph(parts: str, style: str | None = None) -> str: def heading(text: str) -> str: - return paragraph(run(text, bold=True)) + cleaned = sanitize_text(text) + if not cleaned: + return "" + return paragraph(run(cleaned, bold=True)) def body_paragraph(text: str) -> str: - return paragraph(run(text)) + cleaned = sanitize_text(text) + if not cleaned: + return "" + return paragraph(run(cleaned)) def is_ooxml_fragment(text: str) -> bool: @@ -108,17 +128,22 @@ def table_block(headers: list[str], rows: list[list[str]]) -> str: def build_document_xml(title: str, sections: list[dict]) -> str: body: list[str] = [] if title: - body.append(heading(title)) - body.append(body_paragraph("")) + block = heading(title) + if block: + body.append(block) for section in sections: heading_text = section.get("heading") or section.get("title") if heading_text: - body.append(heading(str(heading_text))) + block = heading(str(heading_text)) + if block: + body.append(block) for para in section.get("paragraphs") or []: text = str(para).strip() if text and not is_ooxml_fragment(text): - body.append(body_paragraph(text)) + block = body_paragraph(text) + if block: + body.append(block) table = section.get("table") if isinstance(table, dict): block = table_block( @@ -127,7 +152,6 @@ def build_document_xml(title: str, sections: list[dict]) -> str: ) if block: body.append(block) - body.append(body_paragraph("")) sect_pr = ( f'' diff --git a/skills/static-page-publish/SKILL.md b/skills/static-page-publish/SKILL.md index f316b3f..4612f47 100644 --- a/skills/static-page-publish/SKILL.md +++ b/skills/static-page-publish/SKILL.md @@ -17,9 +17,10 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 1. 只在**当前用户工作区**(会话 `working_dir`)内读写与搜索,从 `.` 开始 2. 查找 CSV/文档时只用相对路径(如 `oa/report.csv`),**禁止**去上级目录、MindSpace 根目录、其它用户目录或主机路径搜索 3. 读 CSV/列目录:用工作区内的 `shell`(`ls oa/`、`cat file.csv`)或 `tree`;**禁止**用公网 URL 代替 -4. 公网链接**仅**用于让用户浏览器打开已发布的 HTML,不能用来列目录或读数据文件 -5. 静态文件保存即可访问,**无需重启** -6. 页面需提供 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏) +4. **禁止**用 `shell` / `cat` / `heredoc` / `echo` / `cp` 写入 `public/*.html`;HTML 必须用 `write_file` / `edit_file`(shell 在容器内执行,公网链接会 404) +5. 公网链接**仅**用于让用户浏览器打开已发布的 HTML,不能用来列目录或读数据文件 +6. 静态文件保存即可访问,**无需重启** +7. 页面需提供 **Word/PDF 等二进制下载** 时:文件单独落盘(如 `public/方案.docx`),链接用相对路径;**禁止**在 HTML 内用 `data:...;base64,...` 嵌入 docx(易截断损坏) 详细约束以工作区内的 `.goosehints` 与 `.agents/skills/static-page-publish/SKILL.md` 为准。 diff --git a/src/api/client.ts b/src/api/client.ts index 13b22e9..2e9f52b 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -600,7 +600,7 @@ export async function listMindSpaceAssets( export async function uploadMindSpaceAsset( categoryId: string, file: File, - options: { maxImageBytes?: number } = {}, + options: { maxImageBytes?: number; onProgress?: (progress: number) => void } = {}, ): Promise { const maxImageBytes = options.maxImageBytes ?? CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES; if (file.type.startsWith('image/') && file.size > maxImageBytes) { @@ -621,20 +621,7 @@ export async function uploadMindSpaceAsset( }); try { - const contentResponse = await fetch(created.data.uploadUrl, { - method: 'PUT', - headers: { 'Content-Type': 'application/octet-stream' }, - body: file, - }); - if (!contentResponse.ok) { - const body = (await contentResponse.json().catch(() => null)) as { - error?: { message?: string }; - } | null; - throw new ApiError( - contentResponse.status, - body?.error?.message ?? '文件内容上传失败', - ); - } + await uploadFileContent(created.data.uploadUrl, file, options.onProgress); const completed = await apiFetch<{ data: MindSpaceAsset }>( `/mindspace/v1/uploads/${created.data.id}/complete`, { method: 'POST', body: JSON.stringify({}) }, @@ -648,6 +635,40 @@ export async function uploadMindSpaceAsset( } } +function uploadFileContent( + url: string, + file: File, + onProgress?: (progress: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('PUT', url); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + xhr.upload.onprogress = (event) => { + if (!event.lengthComputable || !onProgress) return; + onProgress(Math.min(0.99, Math.max(0, event.loaded / event.total))); + }; + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + onProgress?.(1); + resolve(); + return; + } + let message = '文件内容上传失败'; + try { + const body = JSON.parse(xhr.responseText || '{}') as { error?: { message?: string } }; + message = body?.error?.message ?? message; + } catch { + // Keep the generic upload error when the response is not JSON. + } + reject(new ApiError(xhr.status, message)); + }; + xhr.onerror = () => reject(new ApiError(0, '文件内容上传失败')); + xhr.onabort = () => reject(new ApiError(0, '文件上传已取消')); + xhr.send(file); + }); +} + export async function deleteMindSpaceAsset(assetId: string): Promise { await apiFetch(`/mindspace/v1/assets/${assetId}`, { method: 'DELETE' }); } diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 4f32a80..e9c6280 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1,7 +1,12 @@ -import { ChangeEvent, useEffect, useRef, useState, type ClipboardEvent } from 'react'; +import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react'; import { useNetworkStatus } from '../hooks/useNetworkStatus'; import { openAvatarPicker } from '../utils/userAvatar'; import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills'; +import { + CHAT_IMAGE_UPLOAD_MAX_COUNT, + CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, + CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES, +} from '../utils/imageUpload'; import { AvatarPicker } from './AvatarPicker'; import { ChatSkillPicker } from './ChatSkillPicker'; import { DesignSkillPanel } from './DesignSkillPanel'; @@ -11,7 +16,6 @@ import { VoiceInputButton } from './VoiceInputButton'; import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types'; import type { MindSpaceSaveCategory } from '../types'; -const MAX_PENDING_IMAGES = 6; const CHAT_PLACEHOLDER_PROMPTS = [ '帮我写一篇温柔一点的小短文', '讲个轻松的笑话,让我换换脑子', @@ -47,11 +51,19 @@ const CHAT_PLACEHOLDER_PROMPTS = [ type PendingChatImage = { id: string; - url: string; + file: File; previewUrl: string; - uploading: boolean; + sizeBytes: number; + uploadedUrl: string | null; + uploadProgress: number | null; + uploadStatus: 'queued' | 'uploading' | 'uploaded' | 'error'; }; +function formatUploadMegabytes(bytes: number) { + const megabytes = bytes / (1024 * 1024); + return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(1)}MB`; +} + function FileIcon() { return (
+ {historyLoadingMore + ? `正在加载更早消息… 已加载 ${messages.length}${historyTotal > 0 ? ` / ${historyTotal}` : ''}` + : historyHasMore + ? `向上滚动加载更早消息${historyTotal > 0 ? ` · 已加载 ${messages.length} / ${historyTotal}` : ''}` + : `历史消息已全部加载${historyTotal > 0 ? ` · ${messages.length} / ${historyTotal}` : ''}`} +
+ )} )} -
{pendingTool && ( @@ -420,14 +614,29 @@ export function ChatPanel({ {pendingImages.length > 0 && (
{pendingImages.map((item, index) => ( -
- +
+ {`上传图片 + {item.uploadStatus === 'uploading' && ( +
+
+ {item.uploadProgress ?? 0}% +
+ )} + {item.uploadStatus === 'uploaded' && ( +
+ 已完成 +
+ )}
- +
+ + +
{!loading && total > 0 ? ( diff --git a/src/components/FeedbackDetailView.tsx b/src/components/FeedbackDetailView.tsx index 83a56da..07bc47e 100644 --- a/src/components/FeedbackDetailView.tsx +++ b/src/components/FeedbackDetailView.tsx @@ -51,21 +51,18 @@ export function FeedbackDetailView({ }, [feedbackId]); return ( -
+
navigate('/feedback/list')} onLogout={onLogout} /> -
-
- {loading ?

加载中…

: null} - {error ?

{error}

: null} - - {!loading && !error && item ? ( - <> -
+
+
+
+
+ {!loading && !error && item ? (
{FEEDBACK_TYPE_LABELS[item.type]} @@ -75,61 +72,92 @@ export function FeedbackDetailView({ {isMine ? 我的 : null}
-

{item.title}

-

- 提交者:{item.submitterDisplayName ?? '用户'} - - {formatFeedbackTime(item.createdAt)} - - 编号 {item.id.slice(0, 8)} -

-
- -
-

详细描述

-

{item.description || '(无描述)'}

-
- - {isMine && item.contact ? ( -
-

联系方式

-

{item.contact}

-
+ ) : loading ? ( +

加载中…

) : null} +
+
+ + +
+
- {item.context?.pagePath ? ( -
-

来源页面

-

- {item.context.pagePath} +

+ {error ?

{error}

: null} + + {!loading && !error && item ? ( + <> +
+

{item.title}

+

+ 提交者:{item.submitterDisplayName ?? '用户'} + + {formatFeedbackTime(item.createdAt)} + + 编号 {item.id.slice(0, 8)}

- ) : null} - {(item.images?.length ?? 0) > 0 ? (
-

截图

-
- {item.images?.map((image, index) => ( - - {`${item.title} - - ))} -
+

详细描述

+

{item.description || '(无描述)'}

- ) : null} - - ) : null} + + {isMine && item.contact ? ( +
+

联系方式

+

{item.contact}

+
+ ) : null} + + {item.context?.pagePath ? ( +
+

来源页面

+

+ {item.context.pagePath} +

+
+ ) : null} + + {(item.images?.length ?? 0) > 0 ? ( +
+

截图

+
+ {item.images?.map((image, index) => ( + + {`${item.title} + + ))} +
+
+ ) : null} + + ) : null} +
diff --git a/src/components/FeedbackSubmitView.tsx b/src/components/FeedbackSubmitView.tsx index 2b5cb7c..a78f771 100644 --- a/src/components/FeedbackSubmitView.tsx +++ b/src/components/FeedbackSubmitView.tsx @@ -229,54 +229,71 @@ export function FeedbackSubmitView({ }; return ( -
+
navigate(-1)} onLogout={onLogout} /> -
-
- {submittedId ? ( -
-

提交成功

-

感谢你的反馈

-

- 我们已收到你的{selectedType.label},编号 {submittedId.slice(0, 8)}。 - 你可以在「用户反馈」中查看进度。 -

-
- - - +
+ {submittedId ? ( +
+
+
+

提交成功

+

感谢你的反馈

+

+ 我们已收到你的{selectedType.label},编号 {submittedId.slice(0, 8)}。 + 你可以在「用户反馈」中查看进度。 +

-
- ) : ( -
void handleSubmit(event)}> -
+ +
+ +
+ + + +
+
+ ) : ( + void handleSubmit(event)} + > +
+

帮助我们改进 TKMind

-
-

提交 Bug 或需求

- -
-

+

提交 Bug 或需求

+

你可以用文字描述、上传截图,或点击麦克风口述问题。我们会自动附带当前页面与设备信息,便于定位问题。

+ +
+
反馈类型
@@ -331,7 +348,7 @@ export function FeedbackSubmitView({ id="feedback-description" ref={descriptionRef} className="input feedback-description-input" - rows={6} + rows={5} value={description} disabled={voiceDisabled} readOnly={voiceRecording} @@ -412,18 +429,18 @@ export function FeedbackSubmitView({

{error ?

{error}

: null} +
-
- - -
- - )} -
+
+ + +
+ + )}
); diff --git a/src/components/HistorySidebar.tsx b/src/components/HistorySidebar.tsx index 1d680e4..569f9c0 100644 --- a/src/components/HistorySidebar.tsx +++ b/src/components/HistorySidebar.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { AvatarPicker } from './AvatarPicker'; -import { appConfig } from '../config'; -import type { Session } from '../types'; +import type { SessionSummary } from '../types'; import { getSessionListLabel, groupSessionsByDate } from '../utils/sessions'; function formatSessionTime(iso?: string): string { @@ -37,13 +36,18 @@ function CloseIcon() { type HistorySidebarProps = { open: boolean; - sessions: Session[]; + sessions: SessionSummary[]; activeSessionId?: string; loading: boolean; + loadingMore: boolean; + hasMore: boolean; + searchQuery: string; onClose: () => void; onSelect: (sessionId: string) => void; onNew: () => void; onDelete: (sessionId: string) => void; + onLoadMore: () => void; + onSearchChange: (query: string) => void; }; export function HistorySidebar({ @@ -51,18 +55,21 @@ export function HistorySidebar({ sessions, activeSessionId, loading, + loadingMore, + hasMore, + searchQuery, onClose, onSelect, onNew, onDelete, + onLoadMore, + onSearchChange, }: HistorySidebarProps) { const bodyRef = useRef(null); - const [visibleCount, setVisibleCount] = useState(appConfig.sessionPageSize); const [pendingDeleteId, setPendingDeleteId] = useState(null); useEffect(() => { if (open) { - setVisibleCount(appConfig.sessionPageSize); setPendingDeleteId(null); } }, [open, sessions.length]); @@ -71,10 +78,10 @@ export function HistorySidebar({ const el = bodyRef.current; if (!el) return; const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80; - if (nearBottom && visibleCount < sessions.length) { - setVisibleCount((count) => Math.min(count + appConfig.sessionPageSize, sessions.length)); + if (nearBottom && hasMore && !loadingMore && !loading) { + onLoadMore(); } - }, [visibleCount, sessions.length]); + }, [hasMore, loading, loadingMore, onLoadMore]); const handleDeleteClick = useCallback((sessionId: string) => { setPendingDeleteId(sessionId); @@ -94,9 +101,7 @@ export function HistorySidebar({ if (!open) return null; - const visibleSessions = sessions.slice(0, visibleCount); - const sessionGroups = groupSessionsByDate(visibleSessions); - const hasMore = visibleCount < sessions.length; + const sessionGroups = groupSessionsByDate(sessions); return ( <> @@ -114,11 +119,21 @@ export function HistorySidebar({ + {loading && sessions.length === 0 ? (
加载中…
) : sessions.length === 0 ? ( -
暂无历史对话
+
{searchQuery ? '没有找到相关历史标题' : '暂无历史对话'}
) : ( <> {sessionGroups.map((group) => ( @@ -175,7 +190,8 @@ export function HistorySidebar({ ))} - {hasMore &&
向下滚动加载更多…
} + {loadingMore &&
加载更多历史…
} + {!loadingMore && hasMore &&
向下滚动加载更多…
} )}
diff --git a/src/components/MessageList.tsx b/src/components/MessageList.tsx index 5479868..42ef1bb 100644 --- a/src/components/MessageList.tsx +++ b/src/components/MessageList.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useUserAvatar } from '../hooks/useUserAvatar'; import type { Message } from '../types'; import { getDisplayText, getImageUrls, getRenderableImageUrls, getThinking } from '../utils/message'; @@ -8,7 +8,6 @@ import { filterText } from '../utils/wordFilter'; import { formatChatTime, shouldShowTimestamp } from '../utils/time'; import { TKMindAvatar } from './TKMindAvatar'; import { UserAvatar } from './UserAvatar'; -import { ChatSharePreviewModal } from './ChatSharePreviewModal'; import { ChatWelcomePanel } from './ChatWelcomePanel'; function CopyIcon() { @@ -133,6 +132,33 @@ function CopyButton({ text }: { text: string }) { ); } +function PublicLinkCopyButton({ url }: { url: string }) { + const [copied, setCopied] = useState(false); + const copyTimer = useRef | null>(null); + + const copy = async () => { + try { + await navigator.clipboard.writeText(url); + setCopied(true); + if (copyTimer.current) window.clearTimeout(copyTimer.current); + copyTimer.current = window.setTimeout(() => setCopied(false), 1800); + } catch { + setCopied(false); + } + }; + + return ( + + ); +} + function TimeDivider({ timestamp }: { timestamp: number }) { return (
@@ -184,7 +210,6 @@ function MessageRow({ compact?: boolean; }) { const [actionsOpen, setActionsOpen] = useState(false); - const [previewOpen, setPreviewOpen] = useState(false); const rawText = getDisplayText(message); const text = filterText(rawText); const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername }); @@ -275,14 +300,8 @@ function MessageRow({ > {saveActions.kind === 'page' ? '保存页面' : '保存为文章'} - {saveActions.previewUrl && sessionId && message.id && ( - + {saveActions.previewUrl && ( + )} )} @@ -301,14 +320,8 @@ function MessageRow({ > {saveActions.kind === 'page' ? '保存页面' : '保存为文章'} - {saveActions.previewUrl && sessionId && message.id && ( - + {saveActions.previewUrl && ( + )} )} @@ -324,14 +337,6 @@ function MessageRow({ title={onAvatarClick ? '点击更换头像' : undefined} /> )} - {previewOpen && sessionId && message.id && ( - setPreviewOpen(false)} - onSave={onSaveAsPage ? () => onSaveAsPage(message) : undefined} - /> - )}
); } @@ -363,8 +368,13 @@ export function MessageList({ {messages.map((message, index) => { const prev = index > 0 ? messages[index - 1] : undefined; const showTime = shouldShowTimestamp(message.created, prev?.created); + const anchorId = message.id ?? `msg-${index}-${message.role}-${message.created}`; return ( -
+
{showTime && } (null); const [saveNotice, setSaveNotice] = useState(null); const [duplicateResolved, setDuplicateResolved] = useState<'replace' | 'new' | null>(null); - const [privateAcknowledged, setPrivateAcknowledged] = useState(false); const [thumbnailVersion, setThumbnailVersion] = useState(0); const [previewScrollLock, setPreviewScrollLock] = useState(false); const titleRef = useRef(''); @@ -142,15 +136,7 @@ export function PageSaveDialog({ return () => window.removeEventListener('keydown', onKeyDown); }, [onClose, saving]); - useEffect(() => { - setPrivateAcknowledged(false); - }, [categoryCode, analysis?.privacyScan?.findings.length]); - const isStaticHtml = analysis?.contentMode === 'static_html'; - const privacyFindings = analysis?.privacyScan.findings ?? []; - const privateBlocked = categoryCode === 'private' && analysis?.privacyScan.allowed === false; - const privateNeedsAck = - categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed; const existingPage = analysis?.existingPage ?? null; const showDuplicatePrompt = existingPage !== null && duplicateResolved === null && !saveNotice; @@ -168,10 +154,6 @@ export function PageSaveDialog({ templateId: isStaticHtml ? 'static-html' : templateId, categoryCode, selectedLinkIndex, - acknowledgedFindingIds: - categoryCode === 'private' && privateNeedsAck && privateAcknowledged - ? privacyFindings.map((finding) => finding.id) - : undefined, replacePageId, }); if (result.kind === 'page') { @@ -308,61 +290,22 @@ export function PageSaveDialog({
保存到 - {CATEGORY_OPTIONS.map((option) => { - const disabledPrivate = - option.code === 'private' && analysis.privacyScan.allowed === false; - return ( -
- - {categoryCode === 'private' && ( -
- 私人区安全说明 -

私人区默认不可公开。若后续要发布,系统会要求脱敏副本和二次确认。

- {privacyFindings.length > 0 && ( -
    - {privacyFindings.map((finding) => ( -
  • - {finding.blocking ? '阻断' : '提醒'} · {finding.label ?? finding.type}( - {finding.occurrenceCount} 处){finding.sampleMasked} -
  • - ))} -
- )} - {privateNeedsAck && ( - - )} -
- )}
{!compact && ( @@ -408,14 +351,7 @@ export function PageSaveDialog({ duplicateResolved === 'replace' && existingPage ? existingPage.id : undefined, ) } - disabled={ - saving || - analysisLoading || - !title.trim() || - Boolean(analysisError) || - privateBlocked || - (privateNeedsAck && !privateAcknowledged) - } + disabled={saving || analysisLoading || !title.trim() || Boolean(analysisError)} > {saving ? '正在保存…' diff --git a/src/config.ts b/src/config.ts index 1aa1bbd..e7fb7e8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ -const pageSize = Number(import.meta.env.VITE_SESSION_PAGE_SIZE ?? 25); +const pageSize = Number(import.meta.env.VITE_SESSION_PAGE_SIZE ?? 20); +const messagePageSize = Number(import.meta.env.VITE_SESSION_MESSAGE_PAGE_SIZE ?? 20); export const appConfig = { workingDir: import.meta.env.VITE_TKMIND_WORKING_DIR ?? '', @@ -7,7 +8,9 @@ export const appConfig = { memoryQuery: import.meta.env.VITE_TKMIND_MEMORY_QUERY ?? 'project architecture conventions current goals decisions risks and recent work', - sessionPageSize: Number.isFinite(pageSize) && pageSize > 0 ? pageSize : 25, + sessionPageSize: Number.isFinite(pageSize) && pageSize > 0 ? pageSize : 20, + sessionMessagePageSize: + Number.isFinite(messagePageSize) && messagePageSize > 0 ? messagePageSize : 20, sessionMaxCount: 100, } as const; diff --git a/src/hooks/usePageEditSubChat.ts b/src/hooks/usePageEditSubChat.ts index d1a6480..7471fa3 100644 --- a/src/hooks/usePageEditSubChat.ts +++ b/src/hooks/usePageEditSubChat.ts @@ -17,9 +17,6 @@ import { buildContextPrefix } from '../utils/mindspaceChatContext'; import { buildUserAddressPrefix } from '../utils/userAddress'; import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, - CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES, - CHAT_IMAGE_MAX_SIDE, - compressImageForUpload, } from '../utils/imageUpload'; import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards'; import { @@ -94,9 +91,8 @@ export function usePageEditSubChat({ const space = await getMindSpace(); const category = - space.categories.find((item) => item.code === 'private') ?? - space.categories.find((item) => item.code === 'oa') ?? space.categories.find((item) => item.code === 'public') ?? + space.categories.find((item) => item.code === 'oa') ?? space.categories[0]; if (!category) { throw new Error('当前空间暂无可用的图片上传分类'); @@ -105,18 +101,23 @@ export function usePageEditSubChat({ return category.id; }, []); - const uploadChatImage = useCallback(async (file: File): Promise => { + const uploadChatImage = useCallback(async ( + file: File, + onProgress?: (progress: number) => void, + ): Promise => { if (!file.type.startsWith('image/')) { throw new Error('只支持图片文件'); } - const compressed = await compressImageForUpload(file, { - maxInputBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, - maxOutputBytes: CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES, - maxDimension: CHAT_IMAGE_MAX_SIDE, - }); const categoryId = await resolveChatImageUploadCategoryId(); - const asset = await uploadMindSpaceAsset(categoryId, compressed); - return buildAbsoluteAssetImageUrl({ id: asset.id, updatedAt: asset.updatedAt }); + const asset = await uploadMindSpaceAsset(categoryId, file, { + maxImageBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, + onProgress, + }); + return buildAbsoluteAssetImageUrl({ + id: asset.id, + updatedAt: asset.updatedAt, + publicUrl: asset.publicUrl, + }); }, [resolveChatImageUploadCategoryId]); const processEvent = useCallback((event: SessionEvent, requestId: string) => { diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 2163eb3..dbe4591 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -10,10 +10,10 @@ import { getMe, listNotifications, markNotificationRead, - getSession, listSessions, loadSessionDetail, readConfig, + rememberUserMemory, rememberProjectContext, uploadMindSpaceAsset, resumeSession, @@ -21,6 +21,7 @@ import { startSession, subscribeNotificationEvents, subscribeSessionEvents, + syncUserMemory, updateProvider, } from '../api/client'; import { appConfig } from '../config'; @@ -36,6 +37,7 @@ import type { MindSpaceChatContext, PortalUser, Session, + SessionSummary, SessionEvent, ToolConfirmation, UserNotification, @@ -45,9 +47,6 @@ import { buildUserAddressPrefix } from '../utils/userAddress'; import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs'; import { CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, - CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES, - CHAT_IMAGE_MAX_SIDE, - compressImageForUpload, } from '../utils/imageUpload'; import { buildAbsoluteAssetImageUrl } from '../utils/mindspaceCards'; import { @@ -61,9 +60,10 @@ import { pushMessage, } from '../utils/message'; import { - mergeSessionLists, + appendSessionLists, prependUnique, shouldShowNewChatTitle, + toSessionSummary, touchSession, } from '../utils/sessions'; @@ -72,6 +72,21 @@ const REPLY_RECOVERY_SYNC_DELAYS_MS = [1500, 5000, 12000]; export { INSUFFICIENT_BALANCE_NOTICE }; +function mergeMessagePages(older: Message[], current: Message[]): Message[] { + if (older.length === 0) return current; + if (current.length === 0) return older; + + const seen = new Set(); + const merged: Message[] = []; + for (const message of [...older, ...current]) { + const key = message.id ?? `${message.role}:${message.created}:${merged.length}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(message); + } + return merged; +} + function isAmbiguousReplySubmitError(err: unknown) { if (!(err instanceof ApiError)) return true; return err.status === 0 || err.status === 409 || err.status >= 500; @@ -84,15 +99,23 @@ export function useTKMindChat( grantedSkills?: string[], ) { const canUseProjectMemory = Boolean(capabilities?.context_memory); + const canUseLongTermMemory = Boolean(capabilities?.memory_store); const [session, setSession] = useState(null); - const [sessions, setSessions] = useState([]); + const [sessions, setSessions] = useState([]); const [sessionsLoading, setSessionsLoading] = useState(false); + const [sessionsLoadingMore, setSessionsLoadingMore] = useState(false); + const [sessionsHasMore, setSessionsHasMore] = useState(false); + const [sessionSearchQuery, setSessionSearchQueryState] = useState(''); const [messages, setMessages] = useState([]); + const [messageHistoryLoadingMore, setMessageHistoryLoadingMore] = useState(false); + const [messageHistoryHasMore, setMessageHistoryHasMore] = useState(false); + const [messageHistoryTotal, setMessageHistoryTotal] = useState(0); const [chatState, setChatState] = useState('loading'); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [pendingTool, setPendingTool] = useState(null); const [memoryLoading, setMemoryLoading] = useState(false); + const [userMemoryLoading, setUserMemoryLoading] = useState(false); const [rechargePrompt, setRechargePrompt] = useState(false); const [rechargeForced, setRechargeForced] = useState(false); const [subscribePrompt, setSubscribePrompt] = useState(false); @@ -104,7 +127,12 @@ export function useTKMindChat( const connectTokenRef = useRef(0); const messagesRef = useRef([]); const sessionRef = useRef(null); - const sessionsRef = useRef([]); + const sessionsRef = useRef([]); + const sessionsOffsetRef = useRef(0); + const sessionSearchQueryRef = useRef(''); + const messageHistoryLoadedCountRef = useRef(0); + const messageHistoryTotalRef = useRef(0); + const messageHistoryHasMoreRef = useRef(false); const rememberedContextRef = useRef(null); const rememberInFlightRef = useRef(false); const fallbackRetriedRef = useRef(new Set()); @@ -187,6 +215,10 @@ export function useTKMindChat( messagesRef.current = messages; }, [messages]); + useEffect(() => { + messageHistoryHasMoreRef.current = messageHistoryHasMore; + }, [messageHistoryHasMore]); + useEffect(() => { sessionRef.current = session; }, [session]); @@ -195,6 +227,10 @@ export function useTKMindChat( sessionsRef.current = sessions; }, [sessions]); + useEffect(() => { + sessionSearchQueryRef.current = sessionSearchQuery; + }, [sessionSearchQuery]); + useEffect(() => { if (!user?.id) return; let cancelled = false; @@ -269,6 +305,27 @@ export function useTKMindChat( } }, [canUseProjectMemory]); + const formatUserMemoryNotice = useCallback((result: { + analyzed: number; + memories: number; + totalMemories: number; + syncedToSession: boolean; + }) => { + const fragments = []; + if (result.memories > 0) { + fragments.push(`新增 ${result.memories} 条长期记忆`); + } else if (result.analyzed > 0) { + fragments.push('已分析最近对话,暂无新的长期记忆'); + } else { + fragments.push('没有新的用户对话可提炼'); + } + fragments.push(`当前累计 ${result.totalMemories} 条`); + if (result.syncedToSession) { + fragments.push('已同步到当前会话'); + } + return fragments.join(','); + }, []); + const rememberRecentContext = useCallback( async ({ silent = false, @@ -345,28 +402,80 @@ export function useTKMindChat( [canUseProjectMemory, loadProjectMemory], ); - const refreshSessions = useCallback(async () => { - setSessionsLoading(true); + const refreshSessions = useCallback( + async (options?: { preserveExisting?: boolean; query?: string }): Promise => { + setSessionsLoading(true); + try { + const { items, page } = await listSessions({ + limit: appConfig.sessionPageSize, + offset: 0, + query: options?.query ?? sessionSearchQueryRef.current, + }); + const merged = options?.preserveExisting ? appendSessionLists(sessionsRef.current, items) : items; + const nextOffset = Number(page.offset ?? 0) + items.length; + const total = Math.max(Number(page.total ?? merged.length), merged.length); + sessionsOffsetRef.current = options?.preserveExisting + ? Math.max(sessionsOffsetRef.current, nextOffset) + : nextOffset; + setSessionsHasMore(merged.length < total); + if (options?.query != null) { + sessionSearchQueryRef.current = options.query; + setSessionSearchQueryState(options.query); + } + setSessions(merged); + return items; + } catch (err) { + if (err instanceof ApiError && err.status === 0) { + setError('网络不可用,无法刷新历史列表'); + } + return []; + } finally { + setSessionsLoading(false); + } + }, + [], + ); + + const loadMoreSessions = useCallback(async () => { + if (sessionsLoading || sessionsLoadingMore || !sessionsHasMore) return; + setSessionsLoadingMore(true); try { - const items = await listSessions(); - setSessions((prev) => mergeSessionLists(prev, items)); + const { items, page } = await listSessions({ + limit: appConfig.sessionPageSize, + offset: sessionsOffsetRef.current, + query: sessionSearchQueryRef.current, + }); + const merged = appendSessionLists(sessionsRef.current, items); + const nextOffset = Number(page.offset ?? sessionsOffsetRef.current) + items.length; + const total = Math.max(Number(page.total ?? merged.length), merged.length); + sessionsOffsetRef.current = Math.max(sessionsOffsetRef.current, nextOffset); + setSessionsHasMore(merged.length < total); + setSessions(merged); } catch (err) { if (err instanceof ApiError && err.status === 0) { - setError('网络不可用,无法刷新历史列表'); + setError('网络不可用,无法继续加载历史列表'); } } finally { - setSessionsLoading(false); + setSessionsLoadingMore(false); } - }, []); + }, [sessionsHasMore, sessionsLoading, sessionsLoadingMore]); + + const setSessionSearchQuery = useCallback((query: string) => { + const nextQuery = query.trim(); + sessionSearchQueryRef.current = nextQuery; + setSessionSearchQueryState(nextQuery); + sessionsOffsetRef.current = 0; + setSessionsHasMore(false); + void refreshSessions({ query: nextQuery }); + }, [refreshSessions]); const resolveChatImageUploadCategoryId = useCallback(async () => { if (chatImageCategoryIdRef.current) return chatImageCategoryIdRef.current; const space = await getMindSpace(); const category = - space.categories.find((item) => item.code === 'private') ?? - space.categories.find((item) => item.code === 'oa') ?? space.categories.find((item) => item.code === 'public') ?? + space.categories.find((item) => item.code === 'oa') ?? space.categories[0]; if (!category) { throw new Error('当前空间暂无可用的图片上传分类'); @@ -376,32 +485,48 @@ export function useTKMindChat( return category.id; }, []); - const uploadChatImage = useCallback(async (file: File): Promise => { + const uploadChatImage = useCallback(async ( + file: File, + onProgress?: (progress: number) => void, + ): Promise => { if (!file.type.startsWith('image/')) { throw new Error('只支持图片文件'); } - const compressed = await compressImageForUpload(file, { - maxInputBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, - maxOutputBytes: CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES, - maxDimension: CHAT_IMAGE_MAX_SIDE, - }); const categoryId = await resolveChatImageUploadCategoryId(); - const asset = await uploadMindSpaceAsset(categoryId, compressed); + const asset = await uploadMindSpaceAsset(categoryId, file, { + maxImageBytes: CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES, + onProgress, + }); return buildAbsoluteAssetImageUrl({ id: asset.id, updatedAt: asset.updatedAt, + publicUrl: asset.publicUrl, }); }, [resolveChatImageUploadCategoryId]); const syncSessionMessages = useCallback(async (sessionId: string) => { try { - const detail = await loadSessionDetail(sessionId); - setSessions((prev) => prependUnique(prev, detail.session)); + const currentLoadedCount = Math.max( + messageHistoryLoadedCountRef.current, + messagesRef.current.length, + appConfig.sessionMessagePageSize, + ); + const detail = await loadSessionDetail(sessionId, undefined, { + before: 0, + limit: currentLoadedCount, + }); + setSessions((prev) => prependUnique(prev, toSessionSummary(detail.session))); if (sessionRef.current?.id !== sessionId) return; messagesRef.current = detail.messages; + messageHistoryLoadedCountRef.current = detail.messages.length; + messageHistoryTotalRef.current = Math.max(Number(detail.page.total ?? detail.messages.length), detail.messages.length); + setMessageHistoryHasMore(detail.messages.length < messageHistoryTotalRef.current); + setMessageHistoryTotal(messageHistoryTotalRef.current); setMessages(detail.messages); setSession((current) => (current?.id === sessionId ? detail.session : current)); - } catch { + } catch (err) { + console.warn('Session final sync failed:', err); + setNotice('对话已完成,但最终内容同步失败;当前显示可能仍是临时流式结果,请稍后刷新。'); // Keep the optimistic streamed state if the follow-up sync fails. } }, []); @@ -468,6 +593,7 @@ export function useTKMindChat( setMessages((prev) => { const next = pushMessage(prev, event.message); messagesRef.current = next; + messageHistoryLoadedCountRef.current = next.length; return next; }); const confirmation = getToolConfirmation(event.message); @@ -480,10 +606,15 @@ export function useTKMindChat( return; } case 'UpdateConversation': { - const snapshot = normalizeConversationMessages( + let snapshot = normalizeConversationMessages( event.conversation.filter((m) => m.metadata?.userVisible), ); + const loadedCount = Math.max(messageHistoryLoadedCountRef.current, messagesRef.current.length); + if (messageHistoryHasMoreRef.current && loadedCount > 0 && snapshot.length > loadedCount) { + snapshot = snapshot.slice(-loadedCount); + } messagesRef.current = mergeConversationSnapshot(messagesRef.current, snapshot); + messageHistoryLoadedCountRef.current = messagesRef.current.length; setMessages(messagesRef.current); return; } @@ -589,6 +720,12 @@ export function useTKMindChat( setPendingTool(null); activeRequestId.current = null; messagesRef.current = []; + messageHistoryLoadedCountRef.current = 0; + messageHistoryTotalRef.current = 0; + messageHistoryHasMoreRef.current = false; + setMessageHistoryHasMore(false); + setMessageHistoryTotal(0); + setMessageHistoryLoadingMore(false); setMessages([]); setChatState('connecting'); }, []); @@ -619,15 +756,29 @@ export function useTKMindChat( const hints = knownSession ? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at } : undefined; - const { session: detail, messages: history } = await loadSessionDetail(sessionId, hints); + const { session: detail, messages: history, page } = await loadSessionDetail( + sessionId, + hints, + { + before: 0, + limit: appConfig.sessionMessagePageSize, + }, + ); if (token !== connectTokenRef.current) return; writeStoredSessionId(userRef.current?.id, sessionId); setSession({ ...detail, ...(resumed ?? {}), id: sessionId }); messagesRef.current = history; + messageHistoryLoadedCountRef.current = history.length; + messageHistoryTotalRef.current = Math.max(Number(page.total ?? history.length), history.length); + messageHistoryHasMoreRef.current = history.length < messageHistoryTotalRef.current; + setMessageHistoryHasMore(messageHistoryHasMoreRef.current); + setMessageHistoryTotal(messageHistoryTotalRef.current); setMessages(history); setChatState('idle'); - setSessions((prev) => prependUnique(prev, { ...detail, ...resumed, id: sessionId })); + setSessions((prev) => + prependUnique(prev, toSessionSummary({ ...detail, ...(resumed ?? {}), id: sessionId })), + ); subscribeToSession(sessionId); }, @@ -646,6 +797,43 @@ export function useTKMindChat( } }, []); + const loadOlderMessages = useCallback(async () => { + const sessionId = sessionRef.current?.id; + if (!sessionId || messageHistoryLoadingMore || !messageHistoryHasMoreRef.current) return; + + const knownSession = sessionsRef.current.find((item) => item.id === sessionId); + const hints = knownSession + ? { messageCount: knownSession.message_count, updatedAt: knownSession.updated_at } + : undefined; + + setMessageHistoryLoadingMore(true); + try { + const { messages: olderMessages, page } = await loadSessionDetail( + sessionId, + hints, + { + before: messageHistoryLoadedCountRef.current, + limit: appConfig.sessionMessagePageSize, + }, + ); + if (sessionRef.current?.id !== sessionId) return; + const merged = mergeMessagePages(olderMessages, messagesRef.current); + messagesRef.current = merged; + messageHistoryLoadedCountRef.current = merged.length; + messageHistoryTotalRef.current = Math.max(Number(page.total ?? merged.length), merged.length); + messageHistoryHasMoreRef.current = merged.length < messageHistoryTotalRef.current; + setMessageHistoryHasMore(messageHistoryHasMoreRef.current); + setMessageHistoryTotal(messageHistoryTotalRef.current); + setMessages(merged); + } catch (err) { + if (err instanceof ApiError && err.status === 0) { + setError('网络不可用,无法继续加载更早消息'); + } + } finally { + setMessageHistoryLoadingMore(false); + } + }, [messageHistoryLoadingMore]); + const retryConnect = useCallback(async () => { const sessionId = session?.id ?? readStoredSessionId(userRef.current?.id); if (!sessionId) return; @@ -669,45 +857,54 @@ export function useTKMindChat( setChatState('loading'); setError(null); + const summaries = await refreshSessions(); + const previousSessionId = readStoredSessionId(userRef.current?.id); let staleSession = false; let restorableSessionId: string | null = null; if (previousSessionId) { - try { - const previousSession = await getSession(previousSessionId); - if (shouldShowNewChatTitle(previousSession)) { - try { - await deleteChatSession(previousSessionId); - } catch { - // Best-effort cleanup: a failed delete should not block the next fresh chat. - } - clearStoredSessionId(userRef.current?.id); - } else { - restorableSessionId = previousSessionId; - } - } catch (err) { - if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) { - clearStoredSessionId(userRef.current?.id); - staleSession = true; - } else { - throw err; + const previousSession = summaries.find((item) => item.id === previousSessionId) ?? null; + if (previousSession && shouldShowNewChatTitle(previousSession)) { + try { + await deleteChatSession(previousSessionId); + } catch { + // Best-effort cleanup: a failed delete should not block the next fresh chat. } + clearStoredSessionId(userRef.current?.id); + setSessions((prev) => prev.filter((item) => item.id !== previousSessionId)); + } else { + restorableSessionId = previousSessionId; } } - if (staleSession) { - setNotice('上次会话已失效,已为你新建对话'); - } - if (cancelled) return; if (restorableSessionId) { - await connectSessionRef.current(restorableSessionId, { showLoading: false }); + try { + await connectSessionRef.current(restorableSessionId, { showLoading: false }); + } catch (err) { + if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) { + clearStoredSessionId(userRef.current?.id); + staleSession = true; + setSession(null); + messageHistoryLoadedCountRef.current = 0; + messageHistoryTotalRef.current = 0; + messageHistoryHasMoreRef.current = false; + setMessageHistoryHasMore(false); + setMessageHistoryTotal(0); + setMessages([]); + setChatState('idle'); + } else { + throw err; + } + } if (cancelled) return; } else { setChatState('idle'); } - void refreshSessions(); + if (staleSession) { + setNotice('上次会话已失效,已为你新建对话'); + } } catch (err) { if (!cancelled) { if (err instanceof ApiError && err.status === 402) { @@ -732,7 +929,7 @@ export function useTKMindChat( useEffect(() => { const handleVisibility = () => { if (!document.hidden && session?.id) { - void refreshSessions(); + void refreshSessions({ preserveExisting: true }); } }; document.addEventListener('visibilitychange', handleVisibility); @@ -747,7 +944,11 @@ export function useTKMindChat( const token = connectTokenRef.current; const target = sessions.find((item) => item.id === sessionId); if (target) { - setSession(target); + setSession({ + ...target, + working_dir: userRef.current?.workspaceRoot ?? '', + conversation: null, + }); writeStoredSessionId(userRef.current?.id, sessionId); } @@ -794,6 +995,7 @@ export function useTKMindChat( const requestId = crypto.randomUUID(); activeRequestId.current = requestId; messagesRef.current = [...messagesRef.current, userMessage]; + messageHistoryLoadedCountRef.current = messagesRef.current.length; setMessages(messagesRef.current); setChatState('streaming'); setError(null); @@ -807,7 +1009,7 @@ export function useTKMindChat( activeSessionId = created.id; writeStoredSessionId(userRef.current?.id, created.id); setSession(created); - setSessions((prev) => prependUnique(prev, created)); + setSessions((prev) => prependUnique(prev, toSessionSummary(created))); subscribeToSession(created.id); void ensureProvider(created.id); void loadProjectMemory(created.id, false); @@ -894,6 +1096,12 @@ export function useTKMindChat( clearStoredSessionId(userRef.current?.id); activeRequestId.current = null; messagesRef.current = []; + messageHistoryLoadedCountRef.current = 0; + messageHistoryTotalRef.current = 0; + messageHistoryHasMoreRef.current = false; + setMessageHistoryHasMore(false); + setMessageHistoryTotal(0); + setMessageHistoryLoadingMore(false); setMessages([]); setSession(null); setError(null); @@ -906,13 +1114,53 @@ export function useTKMindChat( await loadProjectMemory(session.id, true); }, [chatState, loadProjectMemory, session]); + const rememberCurrentUserMemory = useCallback(async () => { + if (!session || !canUseLongTermMemory) return; + if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return; + setUserMemoryLoading(true); + try { + const result = await rememberUserMemory(session.id); + setNotice(formatUserMemoryNotice(result)); + } catch (err) { + setNotice(`保存长期记忆失败:${err instanceof Error ? err.message : String(err)}`); + } finally { + setUserMemoryLoading(false); + } + }, [canUseLongTermMemory, chatState, formatUserMemoryNotice, session]); + + const refreshUserMemory = useCallback(async () => { + if (!session || !canUseLongTermMemory) return; + if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return; + setUserMemoryLoading(true); + try { + const result = await syncUserMemory(session.id); + setNotice( + result.syncedToSession + ? `长期记忆已同步(累计 ${result.totalMemories} 条)` + : '长期记忆已刷新,但当前会话尚未同步', + ); + } catch (err) { + setNotice(`刷新长期记忆失败:${err instanceof Error ? err.message : String(err)}`); + } finally { + setUserMemoryLoading(false); + } + }, [canUseLongTermMemory, chatState, session]); + const rememberCurrentContext = useCallback(async () => { if (chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting') return; await rememberRecentContext({ silent: false }); }, [chatState, rememberRecentContext]); const onSidebarOpen = useCallback(() => { - void refreshSessions(); + if (sessionSearchQueryRef.current) { + void refreshSessions({ query: sessionSearchQueryRef.current }); + return; + } + if (sessionsRef.current.length === 0) { + void refreshSessions(); + return; + } + void refreshSessions({ preserveExisting: true }); }, [refreshSessions]); const deleteSession = useCallback( @@ -950,21 +1198,35 @@ export function useTKMindChat( session, sessions, sessionsLoading, + sessionsLoadingMore, + sessionsHasMore, + sessionSearchQuery, messages, + messageHistoryLoadingMore, + messageHistoryHasMore, + messageHistoryTotal, chatState, error, notice, pendingTool, memoryLoading, + userMemoryLoading, + canUseProjectMemory, + canUseLongTermMemory, submit, stop, approveTool, newSession, rememberCurrentContext, refreshProjectMemory, + rememberCurrentUserMemory, + refreshUserMemory, switchSession, deleteSession, refreshSessions, + loadMoreSessions, + setSessionSearchQuery, + loadOlderMessages, retryConnect, dismissNotice, onSidebarOpen, diff --git a/src/index.css b/src/index.css index b6f6eea..8c11f3e 100644 --- a/src/index.css +++ b/src/index.css @@ -1153,7 +1153,8 @@ body, gap: 6px; } -.msg-save-page { +.msg-save-page, +.msg-public-share-link { padding: 7px 10px; border: 1px solid var(--color-border); border-radius: 999px; @@ -1168,12 +1169,16 @@ body, } .msg-bubble-wrap:hover .msg-save-page, -.msg-bubble-wrap:focus-within .msg-save-page { +.msg-bubble-wrap:focus-within .msg-save-page, +.msg-bubble-wrap:hover .msg-public-share-link, +.msg-bubble-wrap:focus-within .msg-public-share-link { opacity: 1; pointer-events: auto; } -.msg-save-page:hover { +.msg-save-page:hover, +.msg-public-share-link:hover, +.msg-public-share-link.is-copied { color: #eeb04e; border-color: rgba(238, 176, 78, 0.6); } @@ -1185,7 +1190,8 @@ body, @media (hover: none) { .msg-copy, - .msg-save-page { + .msg-save-page, + .msg-public-share-link { opacity: 0.55; pointer-events: auto; } @@ -1640,19 +1646,11 @@ body, cursor: not-allowed; } -.msg-open-preview { - padding: 4px 10px; - border-radius: 999px; +.msg-public-share-link { color: #2f6f57; background: rgba(47, 111, 87, 0.1); - font-size: 12px; text-decoration: none; border: none; - cursor: pointer; - font-family: inherit; -} -.msg-open-preview:hover { - background: rgba(47, 111, 87, 0.18); } .msg-bubble-wrap-compact { @@ -1715,7 +1713,8 @@ body, } .msg-actions-compact .msg-copy, -.msg-actions-compact .msg-save-page { +.msg-actions-compact .msg-save-page, +.msg-actions-compact .msg-public-share-link { opacity: 1; pointer-events: auto; } @@ -1816,19 +1815,18 @@ body, .msg-image-link { display: block; + width: 72px; + height: 72px; border-radius: 8px; overflow: hidden; border: 1px solid var(--color-border); background: rgba(0, 0, 0, 0.03); - max-width: 168px; } .msg-image-thumb { display: block; - width: auto; - height: auto; - max-width: 168px; - max-height: 168px; + width: 100%; + height: 100%; object-fit: cover; } @@ -2032,6 +2030,44 @@ body, background: rgba(0, 0, 0, 0.04); } +.chat-image-upload-progress, +.chat-image-upload-complete { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 8px; + color: #fff; + background: rgba(0, 0, 0, 0.45); + font-size: 12px; + font-weight: 700; + pointer-events: none; +} + +.chat-image-upload-progress-fill { + position: absolute; + left: 0; + bottom: 0; + height: 4px; + background: #5fd28c; + transition: width 0.18s ease; +} + +.chat-image-upload-progress span { + position: relative; + z-index: 1; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.38); +} + +.chat-image-upload-complete { + inset: auto 4px 4px 4px; + min-height: 18px; + background: rgba(24, 33, 29, 0.72); + font-size: 11px; +} + .chat-image-attachment-remove { position: absolute; right: 4px; @@ -2046,6 +2082,11 @@ body, cursor: pointer; } +.chat-image-attachment-remove:disabled { + cursor: default; + opacity: 0.4; +} + .chat-image-upload-trigger { position: absolute; left: 10px; @@ -9687,7 +9728,7 @@ body, } .feedback-submit-meta code, -.feedback-submit-success code { +.feedback-submit-success-card code { padding: 1px 6px; border-radius: 6px; background: rgba(24, 33, 29, 0.06); @@ -9718,7 +9759,32 @@ body, font: inherit; } -.feedback-submit-success { +.feedback-board-card .feedback-submit-desc { + margin-top: 6px; +} + +.feedback-submit-form-card .feedback-board-scroll, +.feedback-submit-success-card .feedback-board-scroll, +.feedback-detail-card.feedback-board-card .feedback-board-scroll { + margin-top: 0; +} + +.feedback-board-card .feedback-submit-actions { + flex-shrink: 0; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(24, 33, 29, 0.08); +} + +.feedback-submit-form-card .feedback-submit-fieldset { + margin-top: 0; +} + +.feedback-submit-form-card .feedback-submit-meta { + margin-bottom: 0; +} + +.feedback-submit-success-card { text-align: left; } @@ -9783,6 +9849,13 @@ body, min-width: 0; } +.feedback-board-toolbar-actions { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 8px; +} + .feedback-board-desc { margin-top: 6px; font-size: 13px; @@ -9796,18 +9869,19 @@ body, .feedback-panel-close { flex-shrink: 0; - min-width: 72px; + min-width: auto; min-height: 40px; - padding: 10px 18px; + padding: 10px 14px; border: 1px solid rgba(24, 33, 29, 0.16); border-radius: 999px; color: #18211d; background: rgba(255, 255, 255, 0.72); cursor: pointer; font: inherit; - font-size: 14px; + font-size: 13px; font-weight: 600; line-height: 1.2; + white-space: nowrap; box-shadow: 0 2px 10px rgba(24, 33, 29, 0.06); } @@ -9917,7 +9991,7 @@ body, } .feedback-detail-card h1 { - margin: 10px 0 0; + margin: 0; font-family: Georgia, 'Times New Roman', serif; font-size: clamp(24px, 4vw, 32px); font-weight: 500; @@ -10005,13 +10079,22 @@ body, gap: 10px; } - .feedback-board-card { + .feedback-board-card, + .feedback-detail-card.feedback-board-card, + .feedback-submit-form-card.feedback-board-card, + .feedback-submit-success-card.feedback-board-card { max-height: min(78dvh, 620px); } .feedback-panel-close { - min-width: 64px; min-height: 38px; - padding: 9px 16px; + padding: 9px 12px; + font-size: 12px; + } + + .feedback-board-toolbar-actions { + flex-wrap: wrap; + justify-content: flex-end; + max-width: 58%; } } diff --git a/src/utils/imageUpload.ts b/src/utils/imageUpload.ts index 901398b..973b7f0 100644 --- a/src/utils/imageUpload.ts +++ b/src/utils/imageUpload.ts @@ -1,5 +1,8 @@ -export const CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES = 8 * 1024 * 1024; +export const CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES = 4 * 1024 * 1024; export const CHAT_IMAGE_UPLOAD_MAX_OUTPUT_BYTES = 1.5 * 1024 * 1024; +export const MINDSPACE_IMAGE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; +export const CHAT_IMAGE_UPLOAD_MAX_COUNT = 10; +export const CHAT_IMAGE_UPLOAD_MAX_TOTAL_BYTES = 20 * 1024 * 1024; export const CHAT_IMAGE_MAX_SIDE = 1920; const MAX_PIXELS = 2_500_000; const ACCEPTED_IMAGE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']); diff --git a/src/utils/message.ts b/src/utils/message.ts index 05c42e5..fe1656d 100644 --- a/src/utils/message.ts +++ b/src/utils/message.ts @@ -3,6 +3,9 @@ import { mergeMessageContent } from '../../message-stream.mjs'; import { stripUserAddressPrefix } from './userAddress'; const IMAGE_URL_LINE_RE = /^\[图片\d+]: (.+)$/; +const IMAGE_URL_LINES_RE = /\n*\[图片\d+]: [^\n]+/g; +const USER_IDENTITY_BLOCK_RE = /^\[用户身份\][\s\S]*?(?:\n{2,}|$)/; +const TKMIND_VISION_NOTE_RE = /\n*【TKMind 图片分析结果[\s\S]*$/; function normalizeImageUrl(url: string): string { const value = String(url ?? '').trim(); @@ -32,6 +35,14 @@ function formatImageUrlsForAgentText(urls: string[]): string { .join('\n'); } +function stripImageUrlLines(text: string): string { + return String(text ?? '') + .replace(USER_IDENTITY_BLOCK_RE, '') + .replace(TKMIND_VISION_NOTE_RE, '') + .replace(IMAGE_URL_LINES_RE, '') + .trim(); +} + function parseImageUrlsFromText(text: string): string[] { const urls: string[] = []; for (const line of text.split('\n')) { @@ -71,12 +82,12 @@ export function normalizeUserMessageForApi(message: Message): Message { const imageUrls = getImageUrls(message); const displayText = message.metadata.displayText ?? - message.content - .filter((item): item is Extract => item.type === 'text') - .map((item) => item.text) - .join('\n') - .replace(/\n*\[图片\d+]: [^\n]+/g, '') - .trim(); + stripImageUrlLines( + message.content + .filter((item): item is Extract => item.type === 'text') + .map((item) => item.text) + .join('\n'), + ); const textOnly = message.content .filter((item): item is Extract => item.type === 'text') @@ -89,7 +100,7 @@ export function normalizeUserMessageForApi(message: Message): Message { } const imageText = formatImageUrlsForAgentText(imageUrls); - const agentText = [textOnly.replace(/\n*\[图片\d+]: [^\n]+/g, '').trim(), imageText] + const agentText = [stripImageUrlLines(textOnly), imageText] .filter(Boolean) .join('\n\n'); @@ -108,7 +119,7 @@ export function buildUserMessage( text: string, options?: { agentText?: string; displayText?: string; imageUrls?: string[]; previewImageUrls?: string[] }, ): Message { - const displayText = options?.displayText ?? text; + const displayText = stripImageUrlLines(options?.displayText ?? text); const imageUrls = (options?.imageUrls ?? []).filter((value) => typeof value === 'string' && value.trim()); const previewImageUrls = (options?.previewImageUrls ?? []).filter( (value) => typeof value === 'string' && value.trim(), @@ -160,12 +171,12 @@ export function mergeConversationSnapshot(current: Message[], incoming: Message[ export function getDisplayText(message: Message): string { if ('displayText' in message.metadata) { - return message.metadata.displayText ?? ''; + return stripImageUrlLines(message.metadata.displayText ?? ''); } const systemText = getSystemNotificationText(message); if (systemText) return systemText; const visible = getVisibleText(message); - return message.role === 'user' ? stripUserAddressPrefix(visible) : visible; + return message.role === 'user' ? stripImageUrlLines(stripUserAddressPrefix(visible)) : visible; } export function pushMessage(messages: Message[], incoming: Message): Message[] { @@ -187,7 +198,7 @@ export function getVisibleText(message: Message): string { .filter((c): c is Extract => c.type === 'text') .map((c) => c.text) .join(''); - return text.replace(/\n*\[图片\d+]: [^\n]+/g, '').trim(); + return stripImageUrlLines(text); } export function getImageUrls(message: Message): string[] { diff --git a/src/utils/sessions.ts b/src/utils/sessions.ts index 6bac967..c340233 100644 --- a/src/utils/sessions.ts +++ b/src/utils/sessions.ts @@ -1,5 +1,4 @@ -import { appConfig } from '../config'; -import type { Session } from '../types'; +import type { Session, SessionSummary } from '../types'; export const DEFAULT_CHAT_TITLE = 'New Chat'; @@ -12,28 +11,42 @@ function isDefaultSessionName(name: string): boolean { return !trimmed || trimmed === DEFAULT_CHAT_TITLE || trimmed === '新对话' || isGeneratedSessionName(trimmed); } -export function shouldShowNewChatTitle(session: Session): boolean { +export function shouldShowNewChatTitle(session: SessionSummary): boolean { if (session.recipe) return false; return !session.user_set_name && session.message_count === 0 && isDefaultSessionName(session.name); } -export function sortAndTrim(sessions: Session[]): Session[] { +export function sortAndTrim(sessions: T[]): T[] { return [...sessions] .sort((a, b) => { const aTime = new Date(a.updated_at ?? a.created_at ?? 0).getTime(); const bTime = new Date(b.updated_at ?? b.created_at ?? 0).getTime(); return bTime - aTime; - }) - .slice(0, appConfig.sessionMaxCount); + }); } -export function hasMeaningfulSessionTitle(session: Session): boolean { +export function hasMeaningfulSessionTitle(session: SessionSummary): boolean { if (session.user_set_name) return Boolean(session.name.trim()); if (session.recipe?.title?.trim()) return true; return !isDefaultSessionName(session.name); } -export function mergeSessionRecord(existing: Session | undefined, incoming: Session): Session { +export function toSessionSummary(session: Session | SessionSummary): SessionSummary { + return { + id: session.id, + name: session.name, + message_count: session.message_count, + created_at: session.created_at, + updated_at: session.updated_at, + user_set_name: session.user_set_name, + recipe: session.recipe ?? null, + }; +} + +export function mergeSessionRecord( + existing: SessionSummary | undefined, + incoming: SessionSummary, +): SessionSummary { if (!existing) return incoming; const keepExistingTitle = @@ -50,19 +63,33 @@ export function mergeSessionRecord(existing: Session | undefined, incoming: Sess }; } -export function mergeSessionLists(prev: Session[], incoming: Session[]): Session[] { +export function mergeSessionLists(prev: SessionSummary[], incoming: SessionSummary[]): SessionSummary[] { const previousById = new Map(prev.map((item) => [item.id, item])); return sortAndTrim(incoming.map((item) => mergeSessionRecord(previousById.get(item.id), item))); } -export function prependUnique(prev: Session[], session: Session): Session[] { +export function appendSessionLists(prev: SessionSummary[], incoming: SessionSummary[]): SessionSummary[] { + const previousById = new Map(prev.map((item) => [item.id, item])); + const mergedIncoming = incoming.map((item) => mergeSessionRecord(previousById.get(item.id), item)); + const incomingIds = new Set(mergedIncoming.map((item) => item.id)); + return sortAndTrim([ + ...prev.filter((item) => !incomingIds.has(item.id)), + ...mergedIncoming, + ]); +} + +export function prependUnique(prev: SessionSummary[], session: SessionSummary): SessionSummary[] { return sortAndTrim([ mergeSessionRecord(prev.find((s) => s.id === session.id), session), ...prev.filter((s) => s.id !== session.id), ]); } -export function touchSession(sessions: Session[], sessionId: string, messageDelta = 0): Session[] { +export function touchSession( + sessions: SessionSummary[], + sessionId: string, + messageDelta = 0, +): SessionSummary[] { const now = new Date().toISOString(); return sortAndTrim( sessions.map((s) => @@ -77,7 +104,7 @@ export function touchSession(sessions: Session[], sessionId: string, messageDelt ); } -export function getSessionDisplayName(session: Session): string { +export function getSessionDisplayName(session: SessionSummary): string { if (session.user_set_name) return session.name; if (session.recipe?.title) return session.recipe.title; if (shouldShowNewChatTitle(session)) return DEFAULT_CHAT_TITLE; @@ -92,7 +119,7 @@ function sameLocalDay(a: Date, b: Date): boolean { ); } -export function getSessionDateKey(session: Session): string { +export function getSessionDateKey(session: SessionSummary): string { const iso = session.updated_at ?? session.created_at; if (!iso) return ''; const date = new Date(iso); @@ -100,7 +127,7 @@ export function getSessionDateKey(session: Session): string { return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; } -export function formatSessionDateLabel(session: Session): string { +export function formatSessionDateLabel(session: SessionSummary): string { const iso = session.updated_at ?? session.created_at; if (!iso) return ''; const date = new Date(iso); @@ -123,10 +150,10 @@ export function formatSessionDateLabel(session: Session): string { export type SessionDateGroup = { dateKey: string; label: string; - sessions: Session[]; + sessions: SessionSummary[]; }; -export function groupSessionsByDate(sessions: Session[]): SessionDateGroup[] { +export function groupSessionsByDate(sessions: SessionSummary[]): SessionDateGroup[] { const groups: SessionDateGroup[] = []; for (const session of sessions) { const dateKey = getSessionDateKey(session); @@ -143,7 +170,7 @@ export function groupSessionsByDate(sessions: Session[]): SessionDateGroup[] { const DEFAULT_SESSION_NAMES = new Set([DEFAULT_CHAT_TITLE, '新对话']); -export function getSessionListLabel(session: Session): string { +export function getSessionListLabel(session: SessionSummary): string { const displayName = getSessionDisplayName(session).trim(); if (displayName && !DEFAULT_SESSION_NAMES.has(displayName) && !isGeneratedSessionName(displayName)) { return displayName; diff --git a/sse-billing.mjs b/sse-billing.mjs index e7862dd..e9fa1ac 100644 --- a/sse-billing.mjs +++ b/sse-billing.mjs @@ -21,8 +21,28 @@ function parseSseChunk(chunk) { return events; } +function finishBillingKey(event) { + const requestId = event.request_id ?? event.chat_request_id; + if (requestId) return String(requestId); + const state = event.token_state ?? {}; + const input = + state.accumulatedInputTokens ?? state.accumulated_input_tokens ?? state.inputTokens ?? 0; + const output = + state.accumulatedOutputTokens ?? state.accumulated_output_tokens ?? state.outputTokens ?? 0; + return `${input}:${output}`; +} + export function createSseBillingTransform({ onFinish }) { let buffer = ''; + const billedFinishKeys = new Set(); + + const billFinishOnce = (event) => { + if (event?.type !== 'Finish' || !event.token_state) return; + const key = finishBillingKey(event); + if (billedFinishKeys.has(key)) return; + billedFinishKeys.add(key); + void onFinish(event).catch(() => {}); + }; return new Transform({ transform(chunk, _encoding, callback) { @@ -38,10 +58,7 @@ export function createSseBillingTransform({ onFinish }) { } if (!dataLine) continue; try { - const event = JSON.parse(dataLine); - if (event?.type === 'Finish' && event.token_state) { - void onFinish(event).catch(() => {}); - } + billFinishOnce(JSON.parse(dataLine)); } catch { // ignore } @@ -52,9 +69,7 @@ export function createSseBillingTransform({ onFinish }) { flush(callback) { if (buffer.trim()) { for (const event of parseSseChunk(`${buffer}\n\n`)) { - if (event?.type === 'Finish' && event.token_state) { - void onFinish(event).catch(() => {}); - } + billFinishOnce(event); } } callback(); diff --git a/sse-billing.test.mjs b/sse-billing.test.mjs new file mode 100644 index 0000000..71586d4 --- /dev/null +++ b/sse-billing.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import { createSseBillingTransform } from './sse-billing.mjs'; + +function collectFinishEvents(payload) { + return new Promise((resolve, reject) => { + const finishes = []; + const transform = createSseBillingTransform({ + onFinish: async (event) => { + finishes.push(event); + }, + }); + const chunks = []; + transform.on('data', (chunk) => chunks.push(chunk)); + transform.on('error', reject); + transform.on('finish', () => resolve({ finishes, output: Buffer.concat(chunks).toString('utf8') })); + Readable.from([payload]).pipe(transform); + }); +} + +test('createSseBillingTransform bills each Finish only once per stream', async () => { + const frame = + 'data: {"type":"Finish","request_id":"req-1","token_state":{"accumulatedInputTokens":100,"accumulatedOutputTokens":20}}\n\n'; + const { finishes, output } = await collectFinishEvents(frame); + assert.equal(finishes.length, 1); + assert.equal(output, frame); +}); + +test('createSseBillingTransform dedupes Finish replayed in flush buffer', async () => { + const partial = 'data: {"type":"Finish","request_id":"req-2","token_state":{"accumulatedInputTokens":50,"accumulatedOutputTokens":5}}'; + const { finishes } = await collectFinishEvents(partial); + assert.equal(finishes.length, 1); +}); diff --git a/tkmind-proxy-vision.test.mjs b/tkmind-proxy-vision.test.mjs index 5cf0e19..0e48984 100644 --- a/tkmind-proxy-vision.test.mjs +++ b/tkmind-proxy-vision.test.mjs @@ -59,6 +59,10 @@ test('buildVisionPayload marks one billable image analysis when vision succeeds' assert.equal(result?.billableImageCount, 1); assert.match(result?.userMessage?.content?.[0]?.text ?? '', /Qwen VL 图片描述/); + assert.equal( + result?.userMessage?.metadata?.displayText, + '根据图片生成页面\n\n[图片1]: /api/mindspace/v1/assets/asset-1/download?inline=1', + ); }); test('buildVisionPayload does not mark billable usage when vision analysis fails', async () => { diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index 0ebf587..a1039f9 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -1,9 +1,16 @@ -import { Readable } from 'node:stream'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Readable, Transform } from 'node:stream'; import { Agent, fetch as undiciFetch } from 'undici'; import { appendBalanceEvent, createSseBillingTransform } from './sse-billing.mjs'; import { developerToolsFromPolicy } from './capabilities.mjs'; import { evaluateProxyRequest, isNativeH5ApiPath } from './policies.mjs'; -import { buildSandboxSessionConstraints } from './user-publish.mjs'; +import { + buildPublicUrl, + buildSandboxSessionConstraints, + PUBLISH_ROOT_DIR, + resolvePublicBaseUrl, +} from './user-publish.mjs'; import { buildTaskRoutingAgentText } from './user-memory-profile.mjs'; import { reconcileAgentSession } from './session-reconcile.mjs'; import { createImgproxySigner } from './imgproxy-signer.mjs'; @@ -31,6 +38,30 @@ function sanitizeUserFacingProxyMessage(message, fallback = '后端连接失败 .replace(/\bgoose\b/gi, '后端'); } +const PUBLIC_IMAGE_STORAGE_KEY_PATTERN = /^users\/([^/]+)\/images\/(\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)$/i; + +function extractPublicImageStorageKey(rawUrl) { + const value = String(rawUrl ?? '').trim(); + if (!value) return null; + const directMatch = value.match(/(^|\/)(users\/[^?#"'<>@\s]+\/images\/\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)/i); + if (directMatch?.[2]) return directMatch[2]; + const plainIndex = value.indexOf('plain/local:///'); + if (plainIndex >= 0) { + const tail = value.slice(plainIndex + 'plain/local:///'.length); + const storageKey = tail.replace(/@[a-z0-9]+(?:[?#].*)?$/i, '').replace(/[?#].*$/, ''); + return storageKey || null; + } + return null; +} + +function buildPublicStandardImageUrl(rawUrl, userId) { + const storageKey = extractPublicImageStorageKey(rawUrl); + if (!storageKey) return null; + const match = storageKey.match(PUBLIC_IMAGE_STORAGE_KEY_PATTERN); + if (!match || match[1] !== String(userId ?? '')) return null; + return buildPublicUrl(resolvePublicBaseUrl(), userId, `public/images/${match[2]}`); +} + async function apiFetch(target, apiSecret, pathname, init = {}) { const url = new URL(pathname, target); const headers = { @@ -89,6 +120,192 @@ function firstUserText(message) { } const IMAGE_URL_LINE_RE = /^\[图片\d+]:\s*(\S.+)$/; +const PUBLIC_HTML_LINK_PATTERN = + /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi; +const PUBLIC_HTML_MARKDOWN_LINK_PATTERN = + /\[([^\]\n]*)\]\((https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html))\)/gi; +const USER_IDENTITY_BLOCK_PATTERN = /^\[用户身份\][\s\S]*?(?:\n{2,}|$)/; +const IMAGE_URL_LINES_PATTERN = /\n*\[图片\d+]: [^\n]+/g; +const TKMIND_VISION_NOTE_PATTERN = /\n*【TKMind 图片分析结果[\s\S]*$/; + +function decodePathSegment(segment) { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} + +function encodeUrlPath(relativePath) { + return String(relativePath ?? '') + .split('/') + .filter(Boolean) + .map((part) => encodeURIComponent(part)) + .join('/'); +} + +function normalizeStaticHtmlRelativePath(relativePath) { + const parts = String(relativePath ?? '') + .replace(/^\/+/, '') + .split('/') + .filter((part) => part && part !== '.' && part !== '..'); + if (parts.length === 0) return ''; + if (parts[0].toLowerCase() === 'public') return ['public', ...parts.slice(1)].join('/'); + if (parts.length === 1 && parts[0].toLowerCase().endsWith('.html')) return `public/${parts[0]}`; + return parts.join('/'); +} + +function canonicalizeStaticPageUrl(publicUrl, originalRelativePath, canonicalRelativePath) { + const originalClean = String(originalRelativePath ?? '').replace(/^\/+/, ''); + if (!canonicalRelativePath || canonicalRelativePath === originalClean) return publicUrl; + const suffix = encodeUrlPath(originalClean).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return String(publicUrl).replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath)); +} + +function buildMissingPublicHtmlNotice(filename) { + return `(页面生成未完成,已阻止显示失效链接:${filename || '页面'}。)`; +} + +function publicHtmlExistsForUser(owner, relativePath, currentUser) { + const normalizedOwner = String(owner ?? '').trim().toLowerCase(); + const normalizedUserId = String(currentUser?.id ?? '').trim().toLowerCase(); + const normalizedUsername = String(currentUser?.username ?? '').trim().toLowerCase(); + if (!normalizedOwner) return false; + if (normalizedOwner !== normalizedUserId && normalizedOwner !== normalizedUsername) return true; + const normalizedRelativePath = normalizeStaticHtmlRelativePath(relativePath); + if (!normalizedRelativePath || !normalizedRelativePath.toLowerCase().endsWith('.html')) return false; + const root = path.resolve(process.cwd(), PUBLISH_ROOT_DIR, normalizedOwner); + const target = path.resolve(root, normalizedRelativePath); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false; + return fs.existsSync(target) && fs.statSync(target).isFile(); +} + +function sanitizeOwnPublicHtmlUrl(publicUrl, owner, rawRelativePath, currentUser) { + const relativePath = decodePathSegment(rawRelativePath); + const canonicalRelativePath = normalizeStaticHtmlRelativePath(relativePath); + if (!publicHtmlExistsForUser(owner, canonicalRelativePath, currentUser)) { + return { + ok: false, + notice: buildMissingPublicHtmlNotice(path.posix.basename(canonicalRelativePath || relativePath)), + }; + } + return { + ok: true, + url: canonicalizeStaticPageUrl(publicUrl, relativePath, canonicalRelativePath), + }; +} + +export function sanitizePublicHtmlLinksInText(text, currentUser) { + let next = String(text ?? '').replace( + PUBLIC_HTML_MARKDOWN_LINK_PATTERN, + (match, label, url, owner, rawRelativePath) => { + const result = sanitizeOwnPublicHtmlUrl(url, owner, rawRelativePath, currentUser); + if (!result.ok) return result.notice; + return label ? `[${label}](${result.url})` : result.url; + }, + ); + return next.replace(PUBLIC_HTML_LINK_PATTERN, (match, owner, rawRelativePath) => { + const result = sanitizeOwnPublicHtmlUrl(match, owner, rawRelativePath, currentUser); + return result.ok ? result.url : result.notice; + }); +} + +export function sanitizeUserVisibleMessageText(text, currentUser) { + return sanitizePublicHtmlLinksInText( + String(text ?? '') + .replace(USER_IDENTITY_BLOCK_PATTERN, '') + .replace(TKMIND_VISION_NOTE_PATTERN, '') + .replace(IMAGE_URL_LINES_PATTERN, '') + .trim(), + currentUser, + ); +} + +export function sanitizeSessionMessagePublicHtmlLinks(message, currentUser) { + if (!message || !Array.isArray(message.content)) return message; + let changed = false; + const imageUrls = extractImageUrlsFromMessage(message); + const content = message.content.map((item) => { + if (item?.type !== 'text' || typeof item.text !== 'string') return item; + const nextText = sanitizeUserVisibleMessageText(item.text, currentUser); + if (nextText === item.text) return item; + changed = true; + return { ...item, text: nextText }; + }); + const currentDisplayText = + typeof message.metadata?.displayText === 'string' ? message.metadata.displayText : null; + const nextDisplayText = + currentDisplayText == null ? null : sanitizeUserVisibleMessageText(currentDisplayText, currentUser); + const metadataChanged = + (currentDisplayText != null && nextDisplayText !== currentDisplayText) || + (!Array.isArray(message.metadata?.imageUrls) && imageUrls.length > 0); + if (!changed && !metadataChanged) return message; + return { + ...message, + content, + metadata: { + ...(message.metadata ?? {}), + ...(nextDisplayText != null ? { displayText: nextDisplayText } : {}), + ...(!Array.isArray(message.metadata?.imageUrls) && imageUrls.length > 0 + ? { imageUrls } + : {}), + }, + }; +} + +export function sanitizeSessionConversationPublicHtmlLinks(conversation, currentUser) { + if (!Array.isArray(conversation)) return conversation; + return conversation.map((message) => sanitizeSessionMessagePublicHtmlLinks(message, currentUser)); +} + +function createSessionEventSanitizer(currentUser) { + let buffer = ''; + const flushChunk = (controller, chunk) => { + if (!chunk) return; + const block = String(chunk); + if (!block.includes('data: ')) { + controller.push(block); + return; + } + const lines = block.split('\n'); + const sanitizedLines = lines.map((line) => { + if (!line.startsWith('data: ')) return line; + const raw = line.slice(6); + let event; + try { + event = JSON.parse(raw); + } catch { + return line; + } + if (event?.type === 'Message' && event.message) { + event.message = sanitizeSessionMessagePublicHtmlLinks(event.message, currentUser); + } else if (event?.type === 'UpdateConversation' && Array.isArray(event.conversation)) { + event.conversation = sanitizeSessionConversationPublicHtmlLinks(event.conversation, currentUser); + } + return `data: ${JSON.stringify(event)}`; + }); + controller.push(sanitizedLines.join('\n')); + }; + + return new Transform({ + transform(chunk, _encoding, callback) { + buffer += chunk.toString('utf8'); + let boundary = buffer.indexOf('\n\n'); + while (boundary >= 0) { + const block = buffer.slice(0, boundary + 2); + buffer = buffer.slice(boundary + 2); + flushChunk(this, block); + boundary = buffer.indexOf('\n\n'); + } + callback(); + }, + flush(callback) { + flushChunk(this, buffer); + buffer = ''; + callback(); + }, + }); +} export function extractImageUrlsFromMessage(userMessage) { const urls = []; @@ -161,41 +378,49 @@ export async function buildVisionPayload({ llmProviderService, imgproxySigner = null, }) { - if (!localFetchAsset || !llmProviderService || !userId) return null; + void imgproxySigner; + if (!llmProviderService || !userId) return null; const rawImageUrls = extractImageUrlsFromMessage(userMessage); if (rawImageUrls.length === 0) return null; + const originalDisplayText = + typeof userMessage?.metadata?.displayText === 'string' && + userMessage.metadata.displayText.trim() + ? userMessage.metadata.displayText + : Array.isArray(userMessage?.content) + ? userMessage.content + .filter((item) => item?.type === 'text' && typeof item.text === 'string') + .map((item) => item.text) + .join('\n') + .trim() + : ''; const imageItems = []; for (const rawUrl of rawImageUrls) { try { const match = rawUrl.match(/\/mindspace\/v1\/assets\/([^/?#]+)(?:\/download)?/); - if (!match) continue; - const assetId = decodeURIComponent(match[1]); - let buffer; let mimeType = 'image/jpeg'; - - if (imgproxySigner) { - try { - const visionUrl = imgproxySigner.buildUrl( - process.env.IMGPROXY_BASE_URL || 'https://img.tkmind.cn', - `users/${userId}/assets/${assetId}`, - 'vision' - ); - const visionResponse = await undiciFetch(visionUrl); - if (visionResponse.ok) { - buffer = Buffer.from(await visionResponse.arrayBuffer()); - mimeType = visionResponse.headers.get('content-type') || 'image/jpeg'; - } else { - throw new Error(`Vision fetch failed: ${visionResponse.status}`); - } - } catch (visionErr) { - console.warn(`Vision fetch fallback for asset ${assetId}:`, visionErr instanceof Error ? visionErr.message : visionErr); - const asset = await localFetchAsset(userId, assetId); - buffer = asset.buffer; - mimeType = asset.mimeType; + const fetchableUrl = (() => { + if (/^https?:\/\//i.test(rawUrl)) return rawUrl; + if (rawUrl.startsWith('/')) { + const baseUrl = process.env.H5_PUBLIC_BASE_URL || 'http://127.0.0.1:8081'; + return new URL(rawUrl, baseUrl).toString(); } - } else { + return rawUrl; + })(); + try { + const response = await undiciFetch(fetchableUrl); + if (!response.ok) { + throw new Error(`Vision fetch failed: ${response.status}`); + } + buffer = Buffer.from(await response.arrayBuffer()); + mimeType = response.headers.get('content-type') || 'image/jpeg'; + } catch (fetchErr) { + if (!match || !localFetchAsset) { + throw fetchErr; + } + const assetId = decodeURIComponent(match[1]); + console.warn(`Vision fetch fallback for asset ${assetId}:`, fetchErr instanceof Error ? fetchErr.message : fetchErr); const asset = await localFetchAsset(userId, assetId); buffer = asset.buffer; mimeType = asset.mimeType; @@ -206,7 +431,14 @@ export async function buildVisionPayload({ const parsed = new URL(rawUrl); relativePath = parsed.pathname + parsed.search; } catch { /* keep rawUrl */ } - imageItems.push({ mimeType, data: buffer.toString('base64'), relativePath, rawUrl }); + const publicStandardUrl = buildPublicStandardImageUrl(rawUrl, userId); + imageItems.push({ + mimeType, + data: buffer.toString('base64'), + relativePath, + rawUrl, + embedUrl: publicStandardUrl ?? relativePath, + }); } catch (err) { console.warn('Vision image fetch skipped:', err instanceof Error ? err.message : err); } @@ -218,7 +450,7 @@ export async function buildVisionPayload({ .catch(() => null); const pathList = imageItems - .map((item, i) => `图片${i + 1}: 图片${i + 1}`) + .map((item, i) => `图片${i + 1}: 图片${i + 1}`) .join(' '); const publicUrlPrefix = publishLayout?.publicUrl @@ -230,9 +462,10 @@ export async function buildVisionPayload({ (visionDescription ? `Qwen VL 图片描述:\n${visionDescription}\n\n` : '') + - `图片 HTML 嵌入路径(直接写入 标签,浏览器有 cookie 可直接加载,无需 fetch):\n${pathList}\n` + + '写作约束:不得改写图片里人物的年龄、性别、人数或主体关系;如果用户明确要求儿童语气或童趣风格,也只能调整表达方式,不能把图片主体改写成儿童场景。\n' + + `图片 HTML 嵌入路径(直接写入 标签;以下都是无需 cookie 的公开压缩标准图片,禁止使用 local://、/users/ 私有路径、原图地址或需要登录态的下载链接):\n${pathList}\n` + '执行要求:必须先调用 load_skill → static-page-publish(每次生成页面都要调用,不可省略),' + - '再用 write_file 写入 public/页面.html;' + + '再用 write_file 写入 public/页面.html(禁止用 shell/cat/heredoc 写 HTML,容器内文件不会出现在公网链接);' + (publicUrlPrefix ? `完成后向用户给出 Markdown 可点击链接,格式:[页面标题](${publicUrlPrefix}<文件名>.html);` : '完成后按技能说明里的链接格式给用户一个 Markdown 可点击链接;') + @@ -242,7 +475,7 @@ export async function buildVisionPayload({ for (const item of imageItems) { updatedContent = updatedContent.map((c) => { if (c?.type !== 'text' || typeof c.text !== 'string') return c; - const updated = c.text.replaceAll(item.rawUrl, item.relativePath); + const updated = c.text.replaceAll(item.rawUrl, item.embedUrl); return updated === c.text ? c : { ...c, text: updated }; }); } @@ -261,7 +494,14 @@ export async function buildVisionPayload({ } return { - userMessage: { ...userMessage, content: updatedContent }, + userMessage: { + ...userMessage, + content: updatedContent, + metadata: { + ...(userMessage.metadata ?? {}), + ...(originalDisplayText ? { displayText: originalDisplayText } : {}), + }, + }, billableImageCount: visionDescription ? 1 : 0, }; } @@ -345,6 +585,73 @@ export function createTkmindProxy({ } } + function projectSessionSummary(session) { + return { + id: session?.id, + name: typeof session?.name === 'string' ? session.name : '', + message_count: Number(session?.message_count ?? 0), + created_at: session?.created_at ?? undefined, + updated_at: session?.updated_at ?? undefined, + user_set_name: Boolean(session?.user_set_name), + recipe: session?.recipe ?? null, + }; + } + + function sortSessionsByRecent(left, right) { + const leftTime = Date.parse(left?.updated_at ?? left?.created_at ?? '') || 0; + const rightTime = Date.parse(right?.updated_at ?? right?.created_at ?? '') || 0; + return rightTime - leftTime; + } + + function matchesSessionQuery(summary, rawQuery) { + const query = String(rawQuery ?? '').trim().toLowerCase(); + if (!query) return true; + const haystacks = [ + summary?.name, + summary?.recipe?.title, + summary?.id, + ] + .filter((value) => typeof value === 'string' && value.trim()) + .map((value) => value.toLowerCase()); + return haystacks.some((value) => value.includes(query)); + } + + function paginateSessionConversation(conversation, beforeValue, limitValue) { + const visibleConversation = Array.isArray(conversation) + ? conversation.filter((message) => message?.metadata?.userVisible) + : []; + const total = visibleConversation.length; + const before = Math.max(Number(beforeValue ?? 0) || 0, 0); + const requestedLimit = Number(limitValue ?? 0) || 0; + if (requestedLimit <= 0) { + return { + conversation: visibleConversation, + page: { + total, + before: 0, + limit: total, + returned: total, + has_more_before: false, + }, + }; + } + + const limit = Math.min(Math.max(requestedLimit, 1), 200); + const end = Math.max(total - before, 0); + const start = Math.max(end - limit, 0); + const paged = visibleConversation.slice(start, end); + return { + conversation: paged, + page: { + total, + before, + limit, + returned: paged.length, + has_more_before: start > 0, + }, + }; + } + async function pickTarget() { if (targets.length <= 1) return primaryTarget; for (let i = 0; i < targets.length; i += 1) { @@ -706,6 +1013,10 @@ export function createTkmindProxy({ requireUser, async (req, res) => { try { + const offset = Math.max(Number(req.query?.offset ?? 0) || 0, 0); + const requestedLimit = Number(req.query?.limit ?? 20) || 20; + const limit = Math.min(Math.max(requestedLimit, 1), 100); + const query = String(req.query?.query ?? '').trim(); const owned = await userAuth.listOwnedSessionIds(req.currentUser.id); const sessionsById = new Map(); let healthyTargets = 0; @@ -743,9 +1054,20 @@ export function createTkmindProxy({ res.setHeader('X-TKMind-Degraded', '1'); } - const sessions = [...sessionsById.values()]; + const sessions = [...sessionsById.values()].sort(sortSessionsByRecent); await enrichSessionHistory(sessions); - res.json({ sessions }); + const summaries = sessions.map(projectSessionSummary).filter((item) => matchesSessionQuery(item, query)); + const paged = summaries.slice(offset, offset + limit); + res.json({ + sessions: paged, + page: { + total: summaries.length, + offset, + limit, + has_more: offset + paged.length < summaries.length, + query, + }, + }); } catch (err) { res.status(500).json({ message: sanitizeUserFacingProxyMessage( @@ -813,11 +1135,12 @@ export function createTkmindProxy({ let pendingBalance = null; const billingTransform = createSseBillingTransform({ onFinish: async (event) => { + const billingRequestId = event.request_id ?? event.chat_request_id ?? null; const result = await userAuth.billSessionUsage( req.currentUser.id, sessionId, event.token_state, - null, + billingRequestId, ); if (result.ok && result.costCents > 0 && result.balanceCents != null) { pendingBalance = { @@ -838,6 +1161,7 @@ export function createTkmindProxy({ }); const source = Readable.fromWeb(upstream.body); + const linkSanitizer = createSessionEventSanitizer(req.currentUser); // 每 20s 发一个注释行保持连接,防止 nginx/代理因静默超时切断 SSE const keepalive = setInterval(() => { @@ -854,9 +1178,10 @@ export function createTkmindProxy({ }); billingTransform.on('end', () => { stopKeepalive(); res.end(); }); billingTransform.on('error', () => { stopKeepalive(); res.end(); }); + linkSanitizer.on('error', () => { stopKeepalive(); res.end(); }); source.on('error', () => { stopKeepalive(); res.end(); }); req.on('close', stopKeepalive); - source.pipe(billingTransform); + source.pipe(linkSanitizer).pipe(billingTransform); } catch (err) { res.status(502).json({ message: sanitizeUserFacingProxyMessage( @@ -959,6 +1284,25 @@ export function createTkmindProxy({ }, }); + if (req.method === 'GET' && /^\/sessions\/[^/]+$/.test(pathname) && upstream.ok) { + const payload = await upstream.json().catch(() => null); + if (payload && Array.isArray(payload.conversation)) { + const sanitizedConversation = sanitizeSessionConversationPublicHtmlLinks( + payload.conversation, + req.currentUser, + ); + const { conversation, page } = paginateSessionConversation( + sanitizedConversation, + req.query?.history_before, + req.query?.history_limit, + ); + payload.conversation = conversation; + payload.conversation_page = page; + } + res.status(upstream.status).json(payload ?? {}); + return; + } + sendProxyResponse(res, upstream); } catch (err) { res.status(502).json({ diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs new file mode 100644 index 0000000..3373973 --- /dev/null +++ b/tkmind-proxy.test.mjs @@ -0,0 +1,150 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { + buildVisionPayload, + sanitizePublicHtmlLinksInText, + sanitizeSessionConversationPublicHtmlLinks, +} from './tkmind-proxy.mjs'; + +test('sanitizePublicHtmlLinksInText downgrades missing own markdown public html links', () => { + const owner = `test-user-${Date.now()}-markdown-missing`; + const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public'); + try { + fs.mkdirSync(publicRoot, { recursive: true }); + const text = + `[夏日随笔](https://m.tkmind.cn/MindSpace/${owner}/public/summer-essay.html)`; + const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' }); + assert.doesNotMatch(next, new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/summer-essay\\.html`)); + assert.match(next, /页面生成未完成/); + } finally { + fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true }); + } +}); + +test('sanitizePublicHtmlLinksInText downgrades missing own public html links', () => { + const owner = `test-user-${Date.now()}-missing`; + const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public'); + try { + fs.mkdirSync(publicRoot, { recursive: true }); + const text = + `页面在这里:\nhttps://m.tkmind.cn/MindSpace/${owner}/public/hello.html`; + const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' }); + assert.doesNotMatch(next, new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/hello\\.html`)); + assert.match(next, /页面生成未完成/); + assert.match(next, /hello\.html/); + } finally { + fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true }); + } +}); + +test('sanitizePublicHtmlLinksInText keeps existing own public html links', () => { + const owner = `test-user-${Date.now()}-existing`; + const htmlPath = path.join(process.cwd(), 'MindSpace', owner, 'public', 'hello.html'); + try { + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync(htmlPath, 'Hello'); + const text = + `页面在这里:\nhttps://m.tkmind.cn/MindSpace/${owner}/public/hello.html`; + const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' }); + assert.match(next, new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/hello\\.html`)); + assert.doesNotMatch(next, /页面生成未完成/); + } finally { + fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true }); + } +}); + +test('sanitizeSessionConversationPublicHtmlLinks updates text items inside conversation messages', () => { + const owner = `test-user-${Date.now()}-conversation`; + const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public'); + try { + fs.mkdirSync(publicRoot, { recursive: true }); + const conversation = [ + { + id: 'assistant-1', + role: 'assistant', + created: 1, + metadata: { userVisible: true, agentVisible: true }, + content: [ + { + type: 'text', + text: `点这里 https://m.tkmind.cn/MindSpace/${owner}/public/hello.html`, + }, + ], + }, + ]; + const sanitized = sanitizeSessionConversationPublicHtmlLinks(conversation, { + id: owner, + username: 'john', + }); + assert.match(sanitized[0].content[0].text, /页面生成未完成/); + } finally { + fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true }); + } +}); + +test('sanitizeSessionConversationPublicHtmlLinks removes internal user prompt notes but keeps image metadata', () => { + const conversation = [ + { + id: 'user-1', + role: 'user', + created: 1, + metadata: { userVisible: true, agentVisible: true }, + content: [ + { + type: 'text', + text: + '[用户身份]\n' + + '- 当前登录用户称呼:John\n' + + '- 你是 TKMind 助手;与用户对话时用「John」称呼对方。\n\n' + + '帮我生成一个主题页面简单一点的不用很复杂\n\n' + + '[图片1]: http://127.0.0.1:5173/MindSpace/user-1/public/images/a.jpg\n' + + '[图片2]: http://127.0.0.1:5173/MindSpace/user-1/public/images/b.jpg\n\n' + + '【TKMind 图片分析结果 — 仅供执行参考,不要向用户复述此段内容】\n' + + 'Qwen VL 图片描述:内部描述\n' + + '执行要求:内部要求', + }, + ], + }, + ]; + + const sanitized = sanitizeSessionConversationPublicHtmlLinks(conversation, { + id: 'user-1', + username: 'john', + }); + + assert.equal( + sanitized[0].content[0].text, + '帮我生成一个主题页面简单一点的不用很复杂', + ); + assert.deepEqual(sanitized[0].metadata.imageUrls, [ + 'http://127.0.0.1:5173/MindSpace/user-1/public/images/a.jpg', + 'http://127.0.0.1:5173/MindSpace/user-1/public/images/b.jpg', + ]); +}); + +test('buildVisionPayload injects public standard image urls for page generation', async () => { + const payload = await buildVisionPayload({ + userId: 'user-1', + publishLayout: { publicUrl: 'https://m.tkmind.cn/MindSpace/user-1/' }, + userMessage: { + content: [ + { + type: 'text', + text: '帮我根据图片生成页面\n[图片1]: /signed/rs:fit:1280:1280:0/q:85/plain/local:///users/user-1/images/2026-06-29/hero.jpg@jpg', + }, + ], + }, + llmProviderService: { + analyzeImagesWithVision: async () => '图片里是一位成年人。', + }, + }); + + const text = payload?.userMessage?.content?.[0]?.text ?? ''; + assert.match(text, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg/); + assert.doesNotMatch(text, /plain\/local:\/\//); + assert.match(text, /无需 cookie 的公开压缩标准图片/); + assert.match(text, /不得改写图片里人物的年龄、性别、人数或主体关系/); +}); diff --git a/user-auth.mjs b/user-auth.mjs index 5f5cac7..98290a3 100644 --- a/user-auth.mjs +++ b/user-auth.mjs @@ -32,6 +32,7 @@ import { PUBLISH_SKILL_NAME, resolvePublicBaseUrl, resolveLegacyPublishDir, + resolveGoosedSandboxRoot, } from './user-publish.mjs'; import { ensureUserSpaceLayout, @@ -1196,39 +1197,91 @@ export function createUserAuth(pool, options = {}) { }; } const tokenState = normalizeTokenState(tokenStateRaw); - const previous = await getBillingState(agentSessionId); - if ( - previous && - tokenState.accumulatedInputTokens <= Number(previous.lastInputTokens ?? 0) && - tokenState.accumulatedOutputTokens <= Number(previous.lastOutputTokens ?? 0) - ) { - const user = await getUserById(userId); - return { - ok: true, - costCents: 0, - balanceCents: user ? Number(user.balance_cents) : null, - tokensUsed: user ? Number(user.tokens_used ?? 0) : null, - deltaInputTokens: 0, - deltaOutputTokens: 0, - }; - } const config = loadBillingConfig(); - let costCents = computeDeltaCostCents(previous, tokenState, config); - const deltaIn = Math.max( - 0, - tokenState.accumulatedInputTokens - Number(previous?.lastInputTokens ?? 0), - ); - const deltaOut = Math.max( - 0, - tokenState.accumulatedOutputTokens - Number(previous?.lastOutputTokens ?? 0), - ); - const deltaTokens = deltaIn + deltaOut; - + const normalizedRequestId = requestId ? String(requestId).trim() || null : null; const now = Date.now(); const conn = await pool.getConnection(); try { await conn.beginTransaction(); + if (normalizedRequestId) { + const [existingUsage] = await conn.query( + `SELECT cost_cents FROM h5_usage_records WHERE request_id = ? LIMIT 1`, + [normalizedRequestId], + ); + if (existingUsage[0]) { + const [walletRows] = await conn.query( + `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ?`, + [userId], + ); + await conn.commit(); + return { + ok: true, + costCents: 0, + balanceCents: walletRows[0] ? Number(walletRows[0].balance_cents) : null, + tokensUsed: walletRows[0] ? Number(walletRows[0].tokens_used ?? 0) : null, + deltaInputTokens: 0, + deltaOutputTokens: 0, + }; + } + } + + // Placeholder insert so concurrent billers serialize on the same session row. + await conn.query( + `INSERT INTO h5_session_billing_state + (agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at) + VALUES (?, ?, NULL, 0, 0, ?) + ON DUPLICATE KEY UPDATE agent_session_id = agent_session_id`, + [agentSessionId, userId, now], + ); + + const [stateRows] = await conn.query( + `SELECT last_accumulated_cost, last_input_tokens, last_output_tokens + FROM h5_session_billing_state + WHERE agent_session_id = ? + FOR UPDATE`, + [agentSessionId], + ); + const stateRow = stateRows[0]; + const previous = stateRow + ? { + lastAccumulatedCost: stateRow.last_accumulated_cost, + lastInputTokens: Number(stateRow.last_input_tokens ?? 0), + lastOutputTokens: Number(stateRow.last_output_tokens ?? 0), + } + : null; + + if ( + previous && + tokenState.accumulatedInputTokens <= Number(previous.lastInputTokens ?? 0) && + tokenState.accumulatedOutputTokens <= Number(previous.lastOutputTokens ?? 0) + ) { + const [walletRows] = await conn.query( + `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ?`, + [userId], + ); + await conn.commit(); + return { + ok: true, + costCents: 0, + balanceCents: walletRows[0] ? Number(walletRows[0].balance_cents) : null, + tokensUsed: walletRows[0] ? Number(walletRows[0].tokens_used ?? 0) : null, + deltaInputTokens: 0, + deltaOutputTokens: 0, + }; + } + + let costCents = computeDeltaCostCents(previous, tokenState, config); + const deltaIn = Math.max( + 0, + tokenState.accumulatedInputTokens - Number(previous?.lastInputTokens ?? 0), + ); + const deltaOut = Math.max( + 0, + tokenState.accumulatedOutputTokens - Number(previous?.lastOutputTokens ?? 0), + ); + const deltaTokens = deltaIn + deltaOut; + await conn.query( `INSERT INTO h5_session_billing_state (agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at) @@ -1287,7 +1340,16 @@ export function createUserAuth(pool, options = {}) { `INSERT INTO h5_usage_records (user_id, agent_session_id, request_id, input_tokens, output_tokens, cost_cents, balance_after_cents, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [userId, agentSessionId, requestId, deltaIn, deltaOut, costCents, nextBalance, now], + [ + userId, + agentSessionId, + normalizedRequestId, + deltaIn, + deltaOut, + costCents, + nextBalance, + now, + ], ); await conn.query( @@ -1668,7 +1730,7 @@ export function createUserAuth(pool, options = {}) { // paths instead. Unset (e.g. local dev, co-located native goosed) => fall back to // the portal's own paths, so behavior is unchanged. serverPath: resolveSandboxMcpServerPath(process.env.GOOSED_MCP_SERVER_PATH), - sandboxRoot: layout.publishDir, + sandboxRoot: resolveGoosedSandboxRoot(h5Root, user), userId: user.id, nodeExecPath: process.env.GOOSED_MCP_NODE_PATH, }; diff --git a/user-image-normalize.mjs b/user-image-normalize.mjs new file mode 100644 index 0000000..0e37254 --- /dev/null +++ b/user-image-normalize.mjs @@ -0,0 +1,172 @@ +import path from 'node:path'; +import sharp from 'sharp'; + +export const DEFAULT_IMAGE_UPLOAD_MAX_BYTES = 4 * 1024 * 1024; +export const DEFAULT_IMAGE_MASTER_MAX_BYTES = 6 * 1024 * 1024; +export const DEFAULT_IMAGE_MASTER_MAX_SIDE = 2560; +export const DEFAULT_IMAGE_INPUT_MAX_PIXELS = 40_000_000; + +const JPEG_MIN_QUALITY = 62; +const WEBP_MIN_QUALITY = 64; +const MIN_SIDE = 1280; + +function fitDimensions(width, height, maxSide, maxPixels) { + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return { width: maxSide, height: maxSide }; + } + if (width <= maxSide && height <= maxSide && width * height <= maxPixels) { + return { width, height }; + } + const scale = Math.min(maxSide / width, maxSide / height, Math.sqrt(maxPixels / (width * height))); + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + }; +} + +function buildOutputProfile(mimeType) { + if (mimeType === 'image/png') { + return { + extension: '.png', + mimeType: 'image/png', + baseQuality: 90, + minQuality: 70, + encode: (pipeline, quality) => + pipeline.png({ + compressionLevel: 9, + adaptiveFiltering: true, + palette: true, + quality, + effort: 8, + }), + }; + } + if (mimeType === 'image/webp') { + return { + extension: '.webp', + mimeType: 'image/webp', + baseQuality: 84, + minQuality: WEBP_MIN_QUALITY, + encode: (pipeline, quality) => + pipeline.webp({ + quality, + alphaQuality: Math.min(100, quality + 8), + effort: 5, + }), + }; + } + return { + extension: '.jpg', + mimeType: 'image/jpeg', + baseQuality: 84, + minQuality: JPEG_MIN_QUALITY, + encode: (pipeline, quality) => + pipeline.jpeg({ + quality, + mozjpeg: true, + chromaSubsampling: '4:4:4', + }), + }; +} + +function deriveOutputFilename(filename, extension) { + const raw = path.basename(String(filename ?? ''), path.extname(String(filename ?? ''))).trim() || 'image'; + return `${raw}${extension}`; +} + +async function renderVariant(buffer, metadata, profile, { width, height, quality, maxPixels }) { + const pipeline = sharp(buffer, { + failOn: 'error', + limitInputPixels: maxPixels, + sequentialRead: true, + }) + .rotate() + .resize({ + width, + height, + fit: 'inside', + withoutEnlargement: true, + }); + const encoded = await profile.encode(pipeline, quality).toBuffer(); + return encoded; +} + +export async function normalizeImageForStorage({ + buffer, + mimeType, + filename, + maxUploadBytes = DEFAULT_IMAGE_UPLOAD_MAX_BYTES, + maxOutputBytes = DEFAULT_IMAGE_MASTER_MAX_BYTES, + maxSide = DEFAULT_IMAGE_MASTER_MAX_SIDE, + maxPixels = DEFAULT_IMAGE_INPUT_MAX_PIXELS, +} = {}) { + if (!Buffer.isBuffer(buffer) || buffer.length === 0) { + throw Object.assign(new Error('图片内容为空'), { code: 'invalid_file_size' }); + } + if (!mimeType?.startsWith('image/')) { + throw Object.assign(new Error('只支持图片文件'), { code: 'unsupported_file_type' }); + } + if (buffer.length > maxUploadBytes) { + throw Object.assign(new Error('图片文件超过单文件大小限制'), { code: 'file_too_large' }); + } + + const metadata = await sharp(buffer, { + failOn: 'error', + limitInputPixels: maxPixels, + sequentialRead: true, + }).metadata(); + if (!metadata.width || !metadata.height) { + throw Object.assign(new Error('无法识别图片尺寸'), { code: 'image_metadata_invalid' }); + } + if (metadata.pages && metadata.pages > 1) { + throw Object.assign(new Error('暂不支持动态图像上传'), { code: 'unsupported_animated_image' }); + } + + const profile = buildOutputProfile(mimeType); + const initial = fitDimensions(metadata.width, metadata.height, maxSide, maxPixels); + let width = initial.width; + let height = initial.height; + let quality = profile.baseQuality; + let candidate = await renderVariant(buffer, metadata, profile, { width, height, quality, maxPixels }); + + while (candidate.length > maxOutputBytes) { + if (quality > profile.minQuality) { + quality = Math.max(profile.minQuality, quality - 6); + } else if (Math.max(width, height) > MIN_SIDE) { + width = Math.max(1, Math.round(width * 0.85)); + height = Math.max(1, Math.round(height * 0.85)); + } else { + break; + } + candidate = await renderVariant(buffer, metadata, profile, { width, height, quality, maxPixels }); + } + + const outputFilename = deriveOutputFilename(filename, profile.extension); + const output = { + buffer: candidate, + mimeType: profile.mimeType, + filename: outputFilename, + width, + height, + }; + + const preservesFormat = profile.mimeType === mimeType; + const alreadyWithinBounds = + metadata.width <= maxSide && + metadata.height <= maxSide && + metadata.width * metadata.height <= maxPixels && + buffer.length <= maxOutputBytes; + if (preservesFormat && alreadyWithinBounds && candidate.length >= buffer.length) { + return { + buffer, + mimeType, + filename, + width: metadata.width, + height: metadata.height, + }; + } + + return output; +} + +export default normalizeImageForStorage; diff --git a/user-image-url.mjs b/user-image-url.mjs new file mode 100644 index 0000000..c390a7b --- /dev/null +++ b/user-image-url.mjs @@ -0,0 +1,99 @@ +import path from 'node:path'; +import { createImgproxySigner } from './imgproxy-signer.mjs'; + +export const PUBLIC_IMAGES_DIR = 'images'; + +let cachedSigner = null; +let signerInitAttempted = false; + +export function resolveImgproxyBaseUrl(env = process.env) { + return (env.IMGPROXY_BASE_URL ?? 'http://localhost:20081').replace(/\/$/, ''); +} + +export function getImgproxySigner(env = process.env) { + if (signerInitAttempted) return cachedSigner; + signerInitAttempted = true; + const key = env.IMGPROXY_SIGNING_KEY?.trim(); + const salt = env.IMGPROXY_SIGNING_SALT?.trim(); + if (!key || !salt) return null; + try { + cachedSigner = createImgproxySigner(key, salt); + } catch { + cachedSigner = null; + } + return cachedSigner; +} + +export function formatImageDateFolder(date = new Date()) { + const value = date instanceof Date ? date : new Date(date); + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function extensionForMime(mimeType, fallbackFilename = '') { + const ext = path.extname(String(fallbackFilename)).toLowerCase(); + if (ext && /^\.[a-z0-9]{1,8}$/.test(ext)) return ext === '.jpeg' ? '.jpg' : ext; + if (mimeType === 'image/jpeg') return '.jpg'; + if (mimeType === 'image/png') return '.png'; + if (mimeType === 'image/webp') return '.webp'; + if (mimeType === 'image/gif') return '.gif'; + return '.img'; +} + +function sanitizeImageBasename(filename, assetId) { + const raw = path.basename(String(filename ?? ''), path.extname(String(filename ?? ''))).trim(); + const normalized = raw + .normalize('NFKC') + .replace(/[^\w\u4e00-\u9fff.-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + return normalized || assetId.slice(0, 8); +} + +export function buildPublicImagePaths({ + userId, + assetId, + mimeType, + originalFilename, + date = new Date(), + uniqueSuffix = null, +}) { + const dateFolder = formatImageDateFolder(date); + const extension = extensionForMime(mimeType, originalFilename); + const basename = sanitizeImageBasename(originalFilename, assetId); + const suffix = uniqueSuffix ? `-${uniqueSuffix}` : ''; + const filename = `${basename}${suffix}${extension}`; + const storageKey = path.posix.join('users', userId, PUBLIC_IMAGES_DIR, dateFolder, filename); + const workspaceRelativePath = path.posix.join('public', PUBLIC_IMAGES_DIR, dateFolder, filename); + return { storageKey, workspaceRelativePath, filename, dateFolder }; +} + +export function buildImgproxyDisplayUrl(storageKey, options = {}) { + const baseUrl = (options.baseUrl ?? resolveImgproxyBaseUrl()).replace(/\/$/, ''); + const signer = options.signer ?? getImgproxySigner(); + if (signer) { + return signer.buildUrl(baseUrl, storageKey, options.preset ?? 'display'); + } + const preset = options.preset ?? 'display'; + if (preset === 'vision') { + return `${baseUrl}/unsafe/rs:fit:768:768:0/q:60/plain/local:///${storageKey}@jpg`; + } + if (preset === 'thumb') { + return `${baseUrl}/unsafe/rs:fit:256:256:0/q:70/plain/local:///${storageKey}@jpg`; + } + return `${baseUrl}/unsafe/rs:fit:1280:1280:0/q:85/plain/local:///${storageKey}@jpg`; +} + +export function buildUserImagePublicUrl({ userId, storageKey, mimeType, originalFilename }) { + void userId; + void mimeType; + void originalFilename; + return buildImgproxyDisplayUrl(storageKey); +} + +export function isPublicImageStorageKey(storageKey) { + const normalized = String(storageKey ?? '').replace(/\\/g, '/'); + return /\/images\/\d{4}-\d{2}-\d{2}\//.test(normalized); +} diff --git a/user-image-url.test.mjs b/user-image-url.test.mjs new file mode 100644 index 0000000..39536ab --- /dev/null +++ b/user-image-url.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildPublicImagePaths, + buildImgproxyDisplayUrl, + formatImageDateFolder, + isPublicImageStorageKey, +} from './user-image-url.mjs'; + +test('buildPublicImagePaths uses date folder under user images namespace', () => { + const paths = buildPublicImagePaths({ + userId: 'user-1', + assetId: 'asset-1', + mimeType: 'image/png', + originalFilename: '封面.png', + date: new Date('2026-06-29T10:00:00+08:00'), + }); + assert.equal(paths.dateFolder, '2026-06-29'); + assert.equal( + paths.storageKey, + 'users/user-1/images/2026-06-29/封面.png', + ); + assert.equal( + paths.workspaceRelativePath, + 'public/images/2026-06-29/封面.png', + ); +}); + +test('buildImgproxyDisplayUrl falls back to unsafe local path without signer', () => { + const url = buildImgproxyDisplayUrl('users/u1/images/2026-06-29/a.jpg', { + baseUrl: 'http://localhost:20081', + signer: null, + }); + assert.match(url, /^http:\/\/localhost:20081\/unsafe\//); + assert.match(url, /plain\/local:\/\/\/users\/u1\/images\/2026-06-29\/a\.jpg@jpg$/); +}); + +test('isPublicImageStorageKey detects dated image storage keys', () => { + assert.equal(isPublicImageStorageKey('users/u1/images/2026-06-29/a.jpg'), true); + assert.equal(isPublicImageStorageKey('users/u1/public/.tmp-images/a.jpg'), false); +}); + +test('formatImageDateFolder uses local calendar date', () => { + assert.equal(formatImageDateFolder(new Date('2026-01-02T01:00:00+08:00')), '2026-01-02'); +}); diff --git a/user-memory-profile.mjs b/user-memory-profile.mjs index 411fc82..d27be13 100644 --- a/user-memory-profile.mjs +++ b/user-memory-profile.mjs @@ -4,6 +4,7 @@ import { resolveUserAddressName } from './user-publish.mjs'; export const USER_MEMORY_PROFILE_FILENAME = '.tkmind-profile.json'; export const USER_MEMORY_PROFILE_VERSION = 1; +const DEFAULT_TIMEZONE = 'Asia/Shanghai'; export function buildInitialUserMemoryProfile({ userId, @@ -148,14 +149,50 @@ export function renderStoredUserMemoriesForHarness(memories) { ].join('\n'); } +function formatDateParts(now, timezone) { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + const parts = Object.fromEntries( + formatter.formatToParts(new Date(now)).map((part) => [part.type, part.value]), + ); + return { + year: parts.year ?? '0000', + month: parts.month ?? '01', + day: parts.day ?? '01', + }; +} + +export function renderCurrentTimeAnchor({ now = Date.now(), timezone = DEFAULT_TIMEZONE } = {}) { + const { year, month, day } = formatDateParts(now, timezone); + const weekday = new Intl.DateTimeFormat('zh-CN', { + timeZone: timezone, + weekday: 'long', + }).format(new Date(now)); + return [ + '## TKMind 当前时间基准', + '', + `- 当前时区:${timezone}`, + `- 当前日期:${year}-${month}-${day}(${weekday})`, + '- 回答中涉及“今天 / 明天 / 后天 / 周几”时,必须以上述日期为准继续推算,不要自行假设当前日期。', + ].join('\n'); +} + export function buildSessionMemoryEntries({ workingDir, sessionPolicy, sandboxConstraints = null, userContext = null, userMemories = null, + now = Date.now(), }) { const entries = []; + const timezone = String( + userContext?.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? DEFAULT_TIMEZONE, + ).trim() || DEFAULT_TIMEZONE; if (sandboxConstraints?.trim()) { entries.push({ @@ -172,6 +209,11 @@ export function buildSessionMemoryEntries({ }); } + entries.push({ + title: 'TKMind 当前时间基准', + content: renderCurrentTimeAnchor({ now, timezone }), + }); + if (!hasMemoryStore(sessionPolicy)) { return entries; } diff --git a/user-memory-profile.test.mjs b/user-memory-profile.test.mjs index a9e86e5..1299c0c 100644 --- a/user-memory-profile.test.mjs +++ b/user-memory-profile.test.mjs @@ -8,6 +8,7 @@ import { buildTaskRoutingAgentText, resolveCodeExecutorRouting, renderCodeExecutorGuidance, + renderCurrentTimeAnchor, suggestCodeExecutorForTask, buildSessionMemoryEntries, ensureUserMemoryProfile, @@ -77,13 +78,16 @@ test('buildSessionMemoryEntries injects sandbox, guidance, and profile', () => { sessionPolicy, sandboxConstraints: '## sandbox', userContext: { userId: 'user-2', displayName: 'Bob', username: 'bob' }, + now: Date.UTC(2026, 5, 29, 5, 0, 0), }); - assert.equal(entries.length, 4); + assert.equal(entries.length, 5); assert.equal(entries[0].title, 'TKMind 用户空间沙箱'); assert.equal(entries[1].title, 'TKMind 代码委托策略'); assert.match(entries[1].content, /openhands/); - assert.match(entries[2].content, /长期记忆/); - assert.match(entries[3].content, /Bob/); + assert.equal(entries[2].title, 'TKMind 当前时间基准'); + assert.match(entries[2].content, /2026-06-29(星期一)/); + assert.match(entries[3].content, /长期记忆/); + assert.match(entries[4].content, /Bob/); }); test('buildSessionMemoryEntries injects stored conversation memories', () => { @@ -94,11 +98,21 @@ test('buildSessionMemoryEntries injects stored conversation memories', () => { sessionPolicy, userContext: { userId: 'user-3', displayName: 'Carol', username: 'carol' }, userMemories: [{ label: 'interest', text: '用户关注 AI 产品设计' }], + now: Date.UTC(2026, 5, 29, 5, 0, 0), }); assert.equal(entries.at(-1).title, 'TKMind 已沉淀用户记忆'); assert.match(entries.at(-1).content, /AI 产品设计/); }); +test('renderCurrentTimeAnchor formats Shanghai weekday from exact date', () => { + const text = renderCurrentTimeAnchor({ + now: Date.UTC(2026, 5, 29, 5, 0, 0), + timezone: 'Asia/Shanghai', + }); + assert.match(text, /2026-06-29(星期一)/); + assert.match(text, /Asia\/Shanghai/); +}); + test('renderMemoryStoreGuidance scopes memory to one user', () => { const text = renderMemoryStoreGuidance({ addressName: '小陈' }); assert.match(text, /小陈/); diff --git a/user-publish.mjs b/user-publish.mjs index 16e93d2..c540848 100644 --- a/user-publish.mjs +++ b/user-publish.mjs @@ -64,6 +64,17 @@ export function resolvePublishDir(h5Root, user) { return path.join(h5Root, PUBLISH_ROOT_DIR, resolvePublishKey(user)); } +/** Path passed to goosed sandbox-fs; must match the bind mount inside goosed containers. */ +export function resolveGoosedSandboxRoot(h5Root, user, env = process.env) { + const canonicalRoot = String( + env.GOOSED_SANDBOX_PUBLISH_ROOT ?? env.MEMIND_SHARED_PUBLISH_ROOT ?? '', + ).trim(); + if (canonicalRoot) { + return path.join(path.resolve(canonicalRoot), resolvePublishKey(user)); + } + return resolvePublishDir(h5Root, user); +} + export function resolveLegacyPublishDir(h5Root, user) { if (!user?.username) return null; return path.join(h5Root, PUBLISH_ROOT_DIR, resolveUsernameSlug(user)); @@ -278,6 +289,7 @@ export function buildSandboxSessionConstraints({ baseConstraints, developerTools '## 生成 / 发布 HTML 页面', '- 你有 write_file/edit_file 工具:**必须由你**写入 `public/xxx.html`(或工作区根目录 `.html`)', '- 开始前执行 load_skill → `static-page-publish`,按技能说明写入 mindspace-cover 元数据', + '- **禁止**用 shell / cat / heredoc / echo / cp 写入 HTML;shell 在容器内执行,文件不会出现在公网 MindSpace 路径', '- **禁止**让用户手动保存到 public 或说无法生成页面(除非 write_file 调用失败)', '- 完成后回复 `[页面标题](公网URL)` 可点击链接;写入 `public/` 时 URL 必须含 `/public/` 路径段', ); @@ -298,7 +310,7 @@ export function buildPublishConstraints({ slug, username, publicBaseUrl, publish '- **禁止**:访问 `assets/` 内部路径、其它用户目录、主机绝对路径;禁止用公网 URL 列目录或读 CSV', '- **路径规则**:只用相对路径;禁止 `../`;工作区外的路径会被系统拒绝(OS 层强制,非软约束)', '- **生成页面(必须亲自完成)**:先 `load_skill` → `static-page-publish`,再用 `write_file`/`edit_file` 写入 `public/页面.html`', - '- **禁止**让用户「手动保存到 public 目录」或说「我无法生成页面」——除非 write_file 已失败并报告错误', + '- **禁止**用 shell 写入 HTML;**禁止**让用户「手动保存到 public 目录」或说「我无法生成页面」——除非 write_file 已失败并报告错误', '- 完成后给出 Markdown 可点击公网链接 `[标题](URL)`;写入 `public/页面.html` 时 URL 为 `.../MindSpace/<用户ID>/public/页面.html`', `- 发布技能:\`${PUBLISH_SKILL_NAME}\`(生成页面前应 load_skill)`, ].join('\n'); diff --git a/user-publish.test.mjs b/user-publish.test.mjs index 8404a17..ad52698 100644 --- a/user-publish.test.mjs +++ b/user-publish.test.mjs @@ -15,6 +15,7 @@ import { PUBLISH_ROOT_DIR, PUBLIC_ZONE_DIR, resolveLegacyPublishDir, + resolveGoosedSandboxRoot, } from './user-publish.mjs'; import { syncSkillsToWorkspace } from './skills-registry.mjs'; import { listPlatformSkillCatalog } from './skills-registry.mjs'; @@ -32,6 +33,16 @@ test('publish dir and public url use stable user id', () => { assert.equal(layout.slug, USER_ID); assert.equal(layout.username, 'john'); assert.ok(layout.publishDir.endsWith(`${path.sep}${PUBLISH_ROOT_DIR}${path.sep}${USER_ID}`)); + assert.equal( + resolveGoosedSandboxRoot(h5Root, { id: USER_ID, username: 'john' }), + layout.publishDir, + ); + assert.equal( + resolveGoosedSandboxRoot(h5Root, { id: USER_ID, username: 'john' }, { + MEMIND_SHARED_PUBLISH_ROOT: '/shared/MindSpace', + }), + path.join('/shared/MindSpace', USER_ID), + ); assert.equal(layout.publicUrl, `https://m.tkmind.cn/${PUBLISH_ROOT_DIR}/${USER_ID}/`); assert.equal( buildPublicUrl('https://m.tkmind.cn', USER_ID, 'report.html'), diff --git a/user-space.mjs b/user-space.mjs index 1cfe401..4d0bce3 100644 --- a/user-space.mjs +++ b/user-space.mjs @@ -4,7 +4,7 @@ import { SYSTEM_CATEGORIES } from './mindspace.mjs'; import { PUBLISH_ROOT_DIR, resolvePublishDir, resolveUserAddressName } from './user-publish.mjs'; /** 用户 MindSpace 下的分区子目录(与上传分类一致) */ -export const UPLOAD_ZONE_CODES = ['oa', 'private', 'public']; +export const UPLOAD_ZONE_CODES = ['oa', 'public']; export function resolveMindspaceStorageRoot(h5Root, env = process.env) { return path.resolve(env.MINDSPACE_STORAGE_ROOT ?? path.join(h5Root, 'data', 'mindspace')); @@ -26,7 +26,7 @@ function renderUserSpaceBrandingBlock(userAddressName) { - 与用户对话时,用 **${name}** 称呼用户(可辅以「你/您」),**禁止**把用户叫作 TKMind - 问候示例:「${name},下午好」——不要用「TKMind,下午好」 - 不要描述本工作区为「Rust goose 项目」或「goose AI 框架」 -- 本工作区是 TKMind **MindSpace 用户空间**,用于 OA/私人/公开文件管理与静态页面生成 +- 本工作区是 TKMind **MindSpace 用户空间**,用于 OA/公开文件管理与静态页面生成 `; } diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 5c8ee4e..fdf3172 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -6,6 +6,7 @@ import { developerToolsFromPolicy } from './capabilities.mjs'; import { mergeMessageContent } from './message-stream.mjs'; import { reconcileAgentSession } from './session-reconcile.mjs'; import { isScheduleIntent, parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs'; +import { materializeMissingPublicHtmlWrites } from './mindspace-public-finish-sync.mjs'; import { PUBLISH_ROOT_DIR } from './user-publish.mjs'; import { downloadTemporaryMedia, persistWechatImage } from './wechat-media.mjs'; import { buildAckText } from './wechat/ack/ack-provider.mjs'; @@ -381,7 +382,7 @@ function looksLikePublishSuccessClaim(text) { return /(?:页面|网页|html).*(?:已创建|已生成|已发布|创建完毕|生成完成|发布成功)|(?:成功发布|已经发布|已创建完毕).*(?:页面|网页|html)/iu.test(normalized); } -async function hasAnyValidPublishedHtmlLink(text, linkExists) { +async function hasAnyValidPublishedHtmlLink(text, linkExists, { confirmedArtifacts = [] } = {}) { const value = String(text ?? ''); for (const match of value.matchAll(PUBLIC_HTML_LINK_PATTERN)) { try { @@ -389,6 +390,16 @@ async function hasAnyValidPublishedHtmlLink(text, linkExists) { } catch { // treat lookup failures as missing links } + const filename = String(match[2] ?? '').split('/').pop()?.trim(); + if ( + filename && + confirmedArtifacts.some((artifact) => { + const artifactName = path.posix.basename(String(artifact.relativePath ?? '').replace(/\\/g, '/')); + return artifactName === filename && artifactFileExists(artifact); + }) + ) { + return true; + } } return false; } @@ -481,42 +492,6 @@ function sanitizeFilenameSlug(value) { return normalized || 'simple-page'; } -function inferFallbackHtmlName(intent, reply) { - const explicit = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? ''); - if (explicit) return explicit; - const replyText = String(reply?.text ?? ''); - const linkMatch = replyText.match(/([a-z0-9._-]+\.html)\b/i); - if (linkMatch?.[1]) return `public/${linkMatch[1]}`; - const source = `${String(intent?.agentText ?? '')}\n${replyText}`; - if (/泰国|thailand/i.test(source)) return 'public/thailand-guide.html'; - if (/夏日|summer/i.test(source)) return 'public/summer-guide.html'; - const topic = source - .replace(/https?:\/\/\S+/g, '') - .replace(/[^\p{L}\p{N}\s-]/gu, ' ') - .trim() - .split(/\s+/) - .slice(0, 6) - .join('-'); - return `public/${sanitizeFilenameSlug(topic)}.html`; -} - -function extractFallbackPageTitle(intent, reply) { - const lines = String(reply?.text ?? '') - .split('\n') - .map((line) => line.trim()) - .filter((line) => line && !/^https?:\/\//i.test(line) && !/^\[.+\]\(https?:\/\//i.test(line)); - const preferred = lines.find( - (line) => - !/^(已完成|页面已生成|点击下方链接查看|查看页面|还在处理|明白|开始了)$/u.test( - line.replace(/[🌴✨⭐️]/gu, '').trim(), - ) && - !/成功发布|已发布|好消息|页面都已经/u.test(line), - ); - if (preferred) return preferred.replace(/[🌴✨⭐️]/gu, '').trim(); - if (/泰国|thailand/i.test(String(intent?.agentText ?? ''))) return '泰国简易攻略'; - return '简版页面'; -} - function hasAnyUrl(text) { return /https?:\/\/\S+/i.test(String(text ?? '')); } @@ -525,139 +500,26 @@ function hasAnyPublicHtmlLink(text) { return [...String(text ?? '').matchAll(PUBLIC_HTML_LINK_PATTERN)].length > 0; } -function buildFallbackHtmlDocument(intent, reply, title) { - const source = `${String(intent?.agentText ?? '')}\n${String(reply?.text ?? '')}`; - if (/泰国|thailand/i.test(source)) { - return ` - - - - - ${escapeHtml(title)} - - - - - -
-
-
Thailand Quick Guide
-

${escapeHtml(title)}

-

适合第一次去泰国时先快速浏览:先定城市,再定节奏,预算和注意事项尽量简单清楚,出发前照着检查一遍就够了。

-
-
-
-

推荐路线

-
    -
  • 曼谷 2 天:寺庙、夜市、商场和城市观景台。
  • -
  • 清迈 2 到 3 天:古城慢逛、咖啡馆、夜间集市。
  • -
  • 海岛 2 到 3 天:普吉、甲米或苏梅,选一个就够。
  • -
-
-
-

预算参考

-
    -
  • 住宿:普通酒店每晚约 200 到 500 元人民币。
  • -
  • 餐饮:街边小吃和简餐通常比较友好。
  • -
  • 交通:市内尽量打正规车或用常见打车软件。
  • -
-
-
-
-

出发前准备

-
    -
  • 提前确认签证或入境政策,护照有效期留足。
  • -
  • 准备一点现金,热门景点与夜市会更方便。
  • -
  • 防晒、驱蚊、轻便衣物尽量提前备好。
  • -
-
-
-

注意事项

-
    -
  • 尊重寺庙着装要求,进入室内前留意是否需要脱鞋。
  • -
  • 海岛项目先问清价格和往返方式,避免临时加价。
  • -
  • 如果行程只想轻松一点,城市和海岛不要排太满。
  • -
-

这是一个简版攻略页,后续如果你要,我也可以继续扩成 5 天行程版、夜市清单版或海岛专版。

-
-
- -`; +function collectPublicHtmlLinkFilenames(text) { + const filenames = new Set(); + for (const match of String(text ?? '').matchAll(PUBLIC_HTML_LINK_PATTERN)) { + const relativePath = String(match[2] ?? '').trim(); + const filename = path.posix.basename(relativePath); + if (filename) filenames.add(filename); } - - const description = `根据你的要求临时补出的简版页面:${String(intent?.agentText ?? '').trim() || '已生成可访问页面'}`; - return ` - - - - - ${escapeHtml(title)} - - - - - -
-
-

${escapeHtml(title)}

-

${escapeHtml(description)}

-
    -
  • 这是服务号兜底生成的简版页面,先确保你能直接打开和分享。
  • -
  • 如果你还想加图片、配色、分栏或更完整内容,可以继续在当前话题上细化。
  • -
-
-
- -`; -} - -function createFallbackPublishedHtmlArtifact(intent, reply, { workingDir, publicBaseUrl }) { - if (!looksLikeHtmlGenerationIntent(intent?.agentText ?? intent?.content)) return null; - const relativePath = inferFallbackHtmlName(intent, reply); - const absolutePath = path.resolve(workingDir, relativePath); - const workspaceRoot = path.resolve(workingDir); - if (absolutePath !== workspaceRoot && !absolutePath.startsWith(`${workspaceRoot}${path.sep}`)) return null; - fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); - const title = extractFallbackPageTitle(intent, reply); - fs.writeFileSync(absolutePath, buildFallbackHtmlDocument(intent, reply, title), 'utf8'); - const artifact = ensurePublicHtmlArtifact(relativePath, workingDir); - if (!artifact) return null; - return { - ...artifact, - url: buildPublicHtmlUrl(workingDir, artifact.relativePath, publicBaseUrl), - fallbackTitle: title, - }; + return filenames; } function collectRecentPublishedHtmlArtifacts( intent, - { workingDir, publicBaseUrl, sinceMs = 0, limit = 20 } = {}, + { workingDir, publicBaseUrl, replyText = '', sinceMs = 0, limit = 20 } = {}, ) { const publicRoot = path.join(path.resolve(workingDir), 'public'); if (!fs.existsSync(publicRoot) || !fs.statSync(publicRoot).isDirectory()) return []; const expectedTarget = extractRequestedHtmlTarget(intent?.agentText ?? intent?.content ?? ''); const expectedRelativePath = expectedTarget ? expectedTarget.replace(/^\/+/, '').replace(/\\/g, '/') : ''; + const linkedFilenames = collectPublicHtmlLinkFilenames(replyText); const artifacts = []; const stack = [publicRoot]; while (stack.length > 0 && artifacts.length < limit) { @@ -684,13 +546,16 @@ function collectRecentPublishedHtmlArtifacts( if (sinceMs > 0 && Number(stat.mtimeMs ?? 0) + 1 < sinceMs) continue; const relativePath = path.relative(path.resolve(workingDir), absolutePath); if (!relativePath || relativePath.startsWith('..')) continue; + const normalizedRelativePath = relativePath.replace(/\\/g, '/'); + const filename = path.posix.basename(normalizedRelativePath); + if (linkedFilenames.size > 0 && filename && !linkedFilenames.has(filename)) continue; artifacts.push({ localPath: absolutePath, - relativePath, - url: buildPublicHtmlUrl(workingDir, relativePath, publicBaseUrl), + relativePath: normalizedRelativePath, + url: buildPublicHtmlUrl(workingDir, normalizedRelativePath, publicBaseUrl), mtimeMs: Number(stat.mtimeMs ?? 0), matchesExpected: expectedRelativePath - ? relativePath.replace(/\\/g, '/') === expectedRelativePath + ? normalizedRelativePath === expectedRelativePath : false, }); if (artifacts.length >= limit) break; @@ -856,6 +721,42 @@ function defaultPublicHtmlLinkExists(urlText) { return fs.existsSync(target) && fs.statSync(target).isFile(); } +export function createPublicHtmlLinkExists(workingDir) { + const workspaceRoot = path.resolve(String(workingDir ?? '')); + const workspaceKey = path.basename(workspaceRoot); + return (urlText) => { + let url; + try { + url = new URL(urlText); + } catch { + return true; + } + const parts = url.pathname.split('/').filter(Boolean).map((part) => { + try { + return decodeURIComponent(part); + } catch { + return part; + } + }); + if (parts.length < 4 || parts[0] !== PUBLISH_ROOT_DIR || parts[2] !== 'public') return true; + const owner = parts[1]; + const rest = parts.slice(3); + if (!owner || rest.some((part) => !part || part === '.' || part === '..')) return false; + if (owner === workspaceKey) { + const root = path.join(workspaceRoot, 'public'); + const target = path.resolve(root, ...rest); + if (target !== root && !target.startsWith(`${root}${path.sep}`)) return false; + return fs.existsSync(target) && fs.statSync(target).isFile(); + } + return defaultPublicHtmlLinkExists(urlText); + }; +} + +function resolveLinkExistsForWorkingDir(workingDir, linkExists = defaultPublicHtmlLinkExists) { + if (linkExists !== defaultPublicHtmlLinkExists) return linkExists; + return createPublicHtmlLinkExists(workingDir); +} + export async function guardMissingPublicHtmlLinks( text, { linkExists = defaultPublicHtmlLinkExists } = {}, @@ -893,21 +794,126 @@ function downgradePrematurePublishClaims(text) { .replace(/已发布成功/g, '生成未完成'); } -function rewriteFallbackPageSuccessText(text, fallbackArtifact) { - if (!fallbackArtifact) return String(text ?? ''); - const fallbackTitle = String(fallbackArtifact.fallbackTitle ?? '简版页面').trim() || '简版页面'; +function buildHtmlPublishFailureText() { return [ - '已为你补出一个可直接打开的简版页面。', - fallbackTitle, - '查看页面:', - fallbackArtifact.url, - ] - .filter(Boolean) - .join('\n\n'); + '这次页面没有按 H5 里的页面技能真正生成成功,所以我先不发不一致的简版页。', + '请直接重发一次你的页面需求,我会按和 H5 相同的 `static-page-publish` 技能链路重做。', + ].join('\n'); } export { maybeAttachPublishedHtmlLink }; +function artifactFileExists(artifact) { + const localPath = String(artifact?.localPath ?? '').trim(); + if (!localPath) return false; + try { + return fs.existsSync(localPath) && fs.statSync(localPath).isFile(); + } catch { + return false; + } +} + +function uniqueArtifactsByUrl(artifacts = []) { + const byUrl = new Map(); + for (const artifact of artifacts) { + const url = String(artifact?.url ?? '').trim(); + if (url) byUrl.set(url, artifact); + } + return [...byUrl.values()]; +} + +function allExistingHtmlArtifacts({ publishedArtifacts = [], expectedArtifacts = [], recentArtifacts = [] } = {}) { + return uniqueArtifactsByUrl([ + ...publishedArtifacts, + ...expectedArtifacts, + ...recentArtifacts, + ]).filter((artifact) => artifactFileExists(artifact)); +} + +function buildVerifiedHtmlArtifacts({ + publishedArtifacts = [], + expectedArtifacts = [], + recentArtifacts = [], + replyText = '', +} = {}) { + const existing = allExistingHtmlArtifacts({ publishedArtifacts, expectedArtifacts, recentArtifacts }); + const linkedFilenames = collectPublicHtmlLinkFilenames(replyText); + if (linkedFilenames.size === 0) return existing; + const matched = existing.filter((artifact) => { + const filename = path.posix.basename(String(artifact.relativePath ?? '').replace(/\\/g, '/')); + return filename && linkedFilenames.has(filename); + }); + return matched; +} + +function resolveHtmlPublishArtifacts({ + reply, + intent, + workingDir, + publicBaseUrl, + requestStartedAt, +}) { + materializeMissingPublicHtmlWrites({ + messages: reply?.messages ?? [], + publishDir: workingDir, + }); + const publishedArtifacts = collectPublishedHtmlArtifacts(reply, { + workingDir, + publicBaseUrl, + }); + const expectedArtifacts = collectExpectedHtmlArtifacts(intent, { + workingDir, + publicBaseUrl, + }); + const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, { + workingDir, + publicBaseUrl, + replyText: reply?.text, + sinceMs: requestStartedAt, + }); + const confirmedArtifacts = allExistingHtmlArtifacts({ + publishedArtifacts, + expectedArtifacts, + recentArtifacts, + }); + const verifiedArtifacts = buildVerifiedHtmlArtifacts({ + publishedArtifacts, + expectedArtifacts, + recentArtifacts, + replyText: reply?.text, + }); + return { + publishedArtifacts, + expectedArtifacts, + recentArtifacts, + confirmedArtifacts, + verifiedArtifacts, + }; +} + +export function shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts = [], + hasValidLinkInReply = false, +}) { + if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false; + + const replyHasPublicLinks = hasAnyPublicHtmlLink(reply?.text); + if (replyHasPublicLinks) { + if (!hasValidLinkInReply) return true; + if (isMissingRequiredPublishSkill(reply, intent)) return true; + return false; + } + + if (confirmedArtifacts.length > 0) return false; + if (isMissingRequiredPublishSkill(reply, intent) || isSuspiciousBareCompletionReply(reply, intent)) { + return true; + } + if (!usedStaticPagePublishSkill(reply?.messages ?? [])) return true; + return !hasAnyUrl(reply?.text); +} + function isQuestionStatusProbe(text) { return /^[??]+$/.test(String(text ?? '').trim()); } @@ -1066,7 +1072,9 @@ function buildWechatAgentPrompt(intent) { ? [ '【页面发布技能要求】这条消息是在生成可访问 HTML 页面。', '开始前必须先调用 `load_skill` → `static-page-publish`,不能省略,也不能写完页面后再补调。', - '随后必须用 `write_file` / `edit_file` 写入 `public/*.html`。', + '随后必须用 sandbox-fs 的 `write_file` / `edit_file` 写入 `public/*.html`。', + '禁止用 shell / cat / heredoc / echo / cp 写入 HTML:shell 在容器内执行,文件不会出现在公网 MindSpace 路径,用户会收到「文件不存在」。', + '在回复用户前,确认 `public/` 下目标 HTML 已通过 write_file 落盘;没有落盘就不要发送链接或说「已发布」。', '最终只给用户一个正式域名的唯一正确链接;不要输出错误域名、备用链接或让用户手动保存文件。', '', ].join('\n') @@ -1483,11 +1491,25 @@ export function createWechatMpService({ nonceStr, signature, url: normalizedUrl, - jsApiList: ['startRecord', 'stopRecord', 'onVoiceRecordEnd', 'translateVoice'], + jsApiList: [ + 'startRecord', + 'stopRecord', + 'onVoiceRecordEnd', + 'translateVoice', + 'updateAppMessageShareData', + 'updateTimelineShareData', + 'onMenuShareAppMessage', + 'onMenuShareTimeline', + ], }; }; - const sendCustomerServiceText = async (openid, content, user = null, { verifiedHtmlUrls = [] } = {}) => { + const sendCustomerServiceText = async ( + openid, + content, + user = null, + { verifiedHtmlUrls = [], linkExistsForRequest = linkExists } = {}, + ) => { const formatted = formatWechatOutboundText(content, user); const verifiedUrlSet = new Set( verifiedHtmlUrls @@ -1497,8 +1519,11 @@ export function createWechatMpService({ const guarded = downgradePrematurePublishClaims( await guardMissingPublicHtmlLinks(formatted, { linkExists: async (url) => { - if (verifiedUrlSet.has(String(url ?? '').trim())) return true; - return linkExists(url); + const normalized = String(url ?? '').trim(); + if (verifiedUrlSet.has(normalized) && !linkExistsForRequest(normalized)) { + verifiedUrlSet.delete(normalized); + } + return linkExistsForRequest(url); }, }), ); @@ -1806,60 +1831,53 @@ export function createWechatMpService({ buildIntentMetadata(intent), ); const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)); - const expectedArtifacts = collectExpectedHtmlArtifacts(intent, { + const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists); + const { + publishedArtifacts, + verifiedArtifacts, + expectedArtifacts, + recentArtifacts, + confirmedArtifacts, + } = resolveHtmlPublishArtifacts({ + reply, + intent, workingDir, publicBaseUrl: config.publicBaseUrl, + requestStartedAt, }); - const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, { - workingDir, - publicBaseUrl: config.publicBaseUrl, - sinceMs: requestStartedAt, + const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { + confirmedArtifacts, }); - const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExists); - const shouldFallbackFromReply = - !hasAnyUrl(reply?.text) || (hasAnyPublicHtmlLink(reply?.text) && !hasValidLinkInReply); - const fallbackArtifact = - expectedArtifacts.length === 0 && - recentArtifacts.length === 0 && - shouldFallbackFromReply && - collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl: config.publicBaseUrl }).length === 0 - ? createFallbackPublishedHtmlArtifact(intent, reply, { - workingDir, - publicBaseUrl: config.publicBaseUrl, - }) - : null; if ( - isMissingRequiredPublishSkill(reply, intent) || - isSuspiciousBareCompletionReply(reply, intent) || + shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts, + hasValidLinkInReply, + }) || (expectedArtifacts.length === 0 && recentArtifacts.length === 0 && - !fallbackArtifact && - (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists }))) + (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest }))) ) { throw new Error('stale_session_poisoned_completion'); } if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, requestId); } - const publishedArtifacts = collectPublishedHtmlArtifacts(reply, { - workingDir, - publicBaseUrl: config.publicBaseUrl, - }); - if (fallbackArtifact) publishedArtifacts.push(fallbackArtifact); - const recentPublishedArtifacts = publishedArtifacts.length > 0 ? [] : recentArtifacts; - const fallbackArtifacts = publishedArtifacts.length > 0 - ? [] - : (expectedArtifacts.length > 0 ? expectedArtifacts : recentPublishedArtifacts); - const verifiedArtifacts = publishedArtifacts.length > 0 ? publishedArtifacts : fallbackArtifacts; + if (looksLikeHtmlGenerationIntent(intent?.agentText) && verifiedArtifacts.length === 0 && confirmedArtifacts.length === 0) { + throw new Error(buildHtmlPublishFailureText()); + } const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl: config.publicBaseUrl, - artifacts: verifiedArtifacts, + artifacts: verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts, }); - const outboundReply = rewriteFallbackPageSuccessText(finalizedReply, fallbackArtifact); await refreshWechatSessionSnapshot(sessionId, user.userId); - await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(outboundReply), user, { - verifiedHtmlUrls: verifiedArtifacts.map((artifact) => artifact.url), + await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { + verifiedHtmlUrls: (verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts).map( + (artifact) => artifact.url, + ), + linkExistsForRequest, }); return { sessionId }; } catch (err) { @@ -1885,60 +1903,53 @@ export function createWechatMpService({ buildIntentMetadata(intent), ); const workingDir = publishLayout?.publishDir ?? (await userAuth.resolveWorkingDir(user.userId)); - const expectedArtifacts = collectExpectedHtmlArtifacts(intent, { + const linkExistsForRequest = resolveLinkExistsForWorkingDir(workingDir, linkExists); + const { + publishedArtifacts, + verifiedArtifacts, + expectedArtifacts, + recentArtifacts, + confirmedArtifacts, + } = resolveHtmlPublishArtifacts({ + reply, + intent, workingDir, publicBaseUrl: config.publicBaseUrl, + requestStartedAt: retryStartedAt, }); - const recentArtifacts = collectRecentPublishedHtmlArtifacts(intent, { - workingDir, - publicBaseUrl: config.publicBaseUrl, - sinceMs: retryStartedAt, - }); - const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExists); - const shouldFallbackFromReply = - !hasAnyUrl(reply?.text) || (hasAnyPublicHtmlLink(reply?.text) && !hasValidLinkInReply); - const fallbackArtifact = - expectedArtifacts.length === 0 && - recentArtifacts.length === 0 && - shouldFallbackFromReply && - collectPublishedHtmlArtifacts(reply, { workingDir, publicBaseUrl: config.publicBaseUrl }).length === 0 - ? createFallbackPublishedHtmlArtifact(intent, reply, { - workingDir, - publicBaseUrl: config.publicBaseUrl, - }) - : null; + const hasValidLinkInReply = await hasAnyValidPublishedHtmlLink(reply?.text, linkExistsForRequest, { + confirmedArtifacts, + }); if ( - isMissingRequiredPublishSkill(reply, intent) || - isSuspiciousBareCompletionReply(reply, intent) || + shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts, + hasValidLinkInReply, + }) || (expectedArtifacts.length === 0 && recentArtifacts.length === 0 && - !fallbackArtifact && - (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists }))) + (await isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists: linkExistsForRequest }))) ) { - throw new Error('本轮命中了被旧指令污染的专属会话,请稍后重试'); + throw new Error(buildHtmlPublishFailureText()); } if (reply.tokenState) { await userAuth.billSessionUsage(user.userId, sessionId, reply.tokenState, retryId); } - const publishedArtifacts = collectPublishedHtmlArtifacts(reply, { - workingDir, - publicBaseUrl: config.publicBaseUrl, - }); - if (fallbackArtifact) publishedArtifacts.push(fallbackArtifact); - const recentPublishedArtifacts = publishedArtifacts.length > 0 ? [] : recentArtifacts; - const fallbackArtifacts = publishedArtifacts.length > 0 - ? [] - : (expectedArtifacts.length > 0 ? expectedArtifacts : recentPublishedArtifacts); - const verifiedArtifacts = publishedArtifacts.length > 0 ? publishedArtifacts : fallbackArtifacts; + if (looksLikeHtmlGenerationIntent(intent?.agentText) && verifiedArtifacts.length === 0 && confirmedArtifacts.length === 0) { + throw new Error(buildHtmlPublishFailureText()); + } const finalizedReply = await maybeAttachPublishedHtmlLink(reply, { workingDir, publicBaseUrl: config.publicBaseUrl, - artifacts: verifiedArtifacts, + artifacts: verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts, }); - const outboundReply = rewriteFallbackPageSuccessText(finalizedReply, fallbackArtifact); await refreshWechatSessionSnapshot(sessionId, user.userId); - await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(outboundReply), user, { - verifiedHtmlUrls: verifiedArtifacts.map((artifact) => artifact.url), + await sendCustomerServiceText(inbound.fromUserName, await guardScheduleReply(finalizedReply), user, { + verifiedHtmlUrls: (verifiedArtifacts.length > 0 ? verifiedArtifacts : confirmedArtifacts).map( + (artifact) => artifact.url, + ), + linkExistsForRequest, }); return { sessionId }; } diff --git a/wechat-mp.test.mjs b/wechat-mp.test.mjs index 08dc0d1..ca46edf 100644 --- a/wechat-mp.test.mjs +++ b/wechat-mp.test.mjs @@ -9,6 +9,7 @@ import { guardMissingPublicHtmlLinks, loadWechatMpConfig, maybeAttachPublishedHtmlLink, + shouldRetryHtmlGenerationReply, splitWechatText, verifyWechatMpSignature, verifyWechatMpUrlChallenge, @@ -138,7 +139,74 @@ test('guardMissingPublicHtmlLinks blocks missing MindSpace public html links', a assert.match(guarded, /missing\.html/); }); -test('wechat mp service replaces blocked publish claims with a directly accessible fallback page', async () => { +test('shouldRetryHtmlGenerationReply retries when reply link is missing on disk', () => { + const intent = { agentText: '帮我生成一个 html 页面 public/test.html' }; + const reply = { + text: '[Test](https://m.tkmind.cn/MindSpace/user-1/public/test.html)', + messages: [ + { + content: [ + { + type: 'toolRequest', + toolCall: { value: { name: 'load_skill', arguments: { name: 'static-page-publish' } } }, + }, + ], + }, + ], + }; + assert.equal( + shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts: [], + hasValidLinkInReply: false, + }), + true, + ); +}); + +test('shouldRetryHtmlGenerationReply does not skip retry because unrelated recent html exists', () => { + const intent = { agentText: '帮我做一个小页面吧' }; + const reply = { + text: '[Test](https://m.tkmind.cn/MindSpace/user-1/public/test.html)', + messages: [ + { + content: [ + { + type: 'toolRequest', + toolCall: { value: { name: 'load_skill', arguments: { name: 'static-page-publish' } } }, + }, + { + type: 'toolRequest', + toolCall: { + value: { + name: 'write_file', + arguments: { path: 'public/other-page.html', content: '' }, + }, + }, + }, + ], + }, + ], + }; + assert.equal( + shouldRetryHtmlGenerationReply({ + reply, + intent, + confirmedArtifacts: [ + { + localPath: '/tmp/user-1/public/other-page.html', + relativePath: 'public/other-page.html', + url: 'https://m.tkmind.cn/MindSpace/user-1/public/other-page.html', + }, + ], + hasValidLinkInReply: false, + }), + true, + ); +}); + +test('wechat mp service rejects blocked publish claims that did not use the H5 page skill', async () => { const wechatCalls = []; const service = createBoundWechatService({ config: { @@ -208,8 +276,8 @@ test('wechat mp service replaces blocked publish claims with a directly accessib const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send')); const payload = JSON.parse(sendCall[2]); assert.doesNotMatch(payload.text.content, /成功发布|主题页面已发布/); - assert.match(payload.text.content, /可直接打开的简版页面|查看页面/); - assert.match(payload.text.content, /summer-breeze-journal\.html/); + assert.match(payload.text.content, /没有按 H5 里的页面技能真正生成成功/); + assert.doesNotMatch(payload.text.content, /summer-breeze-journal\.html/); }); test('maybeAttachPublishedHtmlLink copies generated root html into public and returns link', async (t) => { @@ -2033,7 +2101,115 @@ test('wechat mp service attaches the newly generated page link even when no html assert.doesNotMatch(payload.text.content, /页面生成未完成|旧指令污染|稍后重试/); }); -test('wechat mp service creates a simple fallback page when html generation reply produced no file', async () => { +test('wechat mp service does not trust unrelated recent html artifacts when reply links a missing file', async () => { + const token = 'token'; + const timestamp = '1710000000'; + const nonce = 'nonce'; + const wechatCalls = []; + const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-recent-mismatch-'); + const unrelatedHtmlPath = path.join(workspaceRoot, 'public', 'summer-breeze-journal.html'); + + const service = createWechatMpService({ + config: { + enabled: true, + appId: 'wx123', + appSecret: 'secret', + token, + publicBaseUrl: 'https://m.tkmind.cn', + bindPath: '/auth/wechat/authorize?intent=login', + ackText: 'ack', + unsupportedText: 'unsupported', + unboundTextPrefix: '请先绑定', + progressDelayMs: 0, + }, + userAuth: { + async findWechatUserByOpenid() { + return { userId: 'user-1', status: 'active', nickname: '毕升' }; + }, + async getWechatAgentRoute() { + return { agentSessionId: 'session-1' }; + }, + async clearWechatAgentRoute() {}, + async canUseChat() { + return { ok: true }; + }, + async resolveWorkingDir() { + return workspaceRoot; + }, + async getAgentSessionPolicy() { + return { enableContextMemory: false, extensionOverrides: [], unrestricted: true }; + }, + async getUserPublishLayout() { + return { publishDir: workspaceRoot, displayName: 'John', username: 'john', slug: 'john', constraints: null }; + }, + async billSessionUsage() {}, + async recordWechatMpMessage() { + return { inserted: true }; + }, + async finishWechatMpMessage() {}, + async insertWechatMpMessageDetail() {}, + }, + sessionApiFetch: async (sessionId, pathname) => { + if (pathname === `/sessions/${sessionId}/events`) { + return new Response( + [ + 'data: {"type":"Message","request_id":"req-recent-mismatch","message":{"id":"assistant-skill","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"load_skill","arguments":{"name":"static-page-publish"}}}}]}}\n\n', + 'data: {"type":"Message","request_id":"req-recent-mismatch","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"[Hello](https://m.tkmind.cn/MindSpace/user-1/public/hello.html)"}]}}\n\n', + 'data: {"type":"Finish","request_id":"req-recent-mismatch","token_state":{"inputTokens":1,"outputTokens":1}}\n\n', + ].join(''), + { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, + ); + } + if (pathname === `/sessions/${sessionId}/reply`) { + fs.mkdirSync(path.dirname(unrelatedHtmlPath), { recursive: true }); + fs.writeFileSync(unrelatedHtmlPath, 'Summer'); + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { + return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + throw new Error(`unexpected session api path: ${sessionId} ${pathname}`); + }, + wechatFetch: async (url, init = {}) => { + wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]); + if (String(url).includes('/cgi-bin/stable_token')) { + return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (String(url).includes('/cgi-bin/message/custom/send')) { + return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + throw new Error(`unexpected wechat url: ${url}`); + }, + }); + + const originalRandomUuid = crypto.randomUUID; + crypto.randomUUID = () => 'req-recent-mismatch'; + try { + const result = await service.handleInboundMessage(inboundXml({ content: '帮我生成一个简单的 hello 页面吧' }), { + timestamp, + nonce, + signature: signatureFor(token, timestamp, nonce), + }); + assert.equal(result.status, 200); + await result.task; + } finally { + crypto.randomUUID = originalRandomUuid; + } + + const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send')); + const payload = JSON.parse(sendCall[2]); + assert.doesNotMatch(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/hello\.html/); + assert.doesNotMatch(payload.text.content, /summer-breeze-journal\.html/); + assert.match(payload.text.content, /没有按 H5 里的页面技能真正生成成功|页面生成未完成/); +}); + +test('wechat mp service refuses to fabricate a fallback page when html generation produced no file', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; @@ -2131,17 +2307,14 @@ test('wechat mp service creates a simple fallback page when html generation repl } const htmlPath = path.join(workspaceRoot, 'public', 'thailand-guide.html'); - assert.equal(fs.existsSync(htmlPath), true); - const html = fs.readFileSync(htmlPath, 'utf8'); - assert.match(html, /泰国简易攻略/); - assert.match(html, /推荐路线/); + assert.equal(fs.existsSync(htmlPath), false); const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send')); const payload = JSON.parse(sendCall[2]); - assert.match(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/thailand-guide\.html/); - assert.doesNotMatch(payload.text.content, /页面生成未完成|稍后重试/); + assert.match(payload.text.content, /没有按 H5 里的页面技能真正生成成功/); + assert.doesNotMatch(payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/.+\/public\/thailand-guide\.html/); }); -test('wechat mp service can deliver a fallback page without rebuilding the session on publish-success claims', async () => { +test('wechat mp service retries poisoned publish claims and still refuses fallback output', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; @@ -2149,8 +2322,6 @@ test('wechat mp service can deliver a fallback page without rebuilding the sessi let routeCleared = false; let started = false; const workspaceRoot = fs.mkdtempSync('/tmp/wechat-mp-publish-claim-'); - const htmlPath = path.join(workspaceRoot, 'public', 'summer-breeze-journal.html'); - const service = createWechatMpService({ config: { enabled: true, @@ -2242,10 +2413,6 @@ test('wechat mp service can deliver a fallback page without rebuilding the sessi }); } if (pathname === `/sessions/${sessionId}/reply`) { - if (sessionId === 'session-2') { - fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); - fs.writeFileSync(htmlPath, 'Summer'); - } return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname === `/sessions/${sessionId}/events`) { @@ -2260,10 +2427,8 @@ test('wechat mp service can deliver a fallback page without rebuilding the sessi } return new Response( [ - 'data: {"type":"Message","request_id":"req-claim-retry","message":{"id":"assistant-skill","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"load_skill","arguments":{"name":"static-page-publish"}}}}]}}\n\n', - `data: {"type":"Message","request_id":"req-claim-retry","message":{"id":"assistant-tool","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"developer","arguments":{"action":"write","path":"${htmlPath.replace(/\\/g, '\\\\')}","content":"Summer"}}}}]}}\n\n`, - 'data: {"type":"Message","request_id":"req-claim-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"已完成"}]}}\n\n', - 'data: {"type":"Finish","request_id":"req-claim-retry","token_state":{"inputTokens":2,"outputTokens":2}}\n\n', + 'data: {"type":"Message","request_id":"req-claim-retry","message":{"id":"assistant-2","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"🌴 夏日主题页面"}]}}\n\n', + 'data: {"type":"Finish","request_id":"req-claim-retry","token_state":{"inputTokens":1,"outputTokens":1}}\n\n', ].join(''), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, ); @@ -2308,13 +2473,13 @@ test('wechat mp service can deliver a fallback page without rebuilding the sessi crypto.randomUUID = originalRandomUuid; } - assert.equal(routeCleared, false); - assert.equal(started, false); + assert.equal(routeCleared, true); + assert.equal(started, true); const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send')); const payload = JSON.parse(sendCall[2]); assert.doesNotMatch(payload.text.content, /页面都已经成功发布了|主题页面已发布/); - assert.match(payload.text.content, /summer-breeze-journal\.html/); - assert.match(payload.text.content, /可直接打开的简版页面|查看页面/); + assert.match(payload.text.content, /没有按 H5 里的页面技能真正生成成功/); + assert.doesNotMatch(payload.text.content, /summer-breeze-journal\.html/); }); test('wechat mp service blocks schedule confirmation when no schedule item was written', async () => { diff --git a/wechat-share.mjs b/wechat-share.mjs new file mode 100644 index 0000000..40b9aa3 --- /dev/null +++ b/wechat-share.mjs @@ -0,0 +1,16 @@ +export function validateWechatShareSignatureUrl(pageUrl, { publicBaseUrl = '', requestHost = '' } = {}) { + const normalizedUrl = String(pageUrl ?? '').split('#')[0].trim(); + if (!normalizedUrl) { + throw Object.assign(new Error('缺少 url'), { code: 'missing_url' }); + } + const target = new URL(normalizedUrl); + if (!/^https?:$/.test(target.protocol)) { + throw Object.assign(new Error('url 必须是 http(s) 地址'), { code: 'invalid_url' }); + } + const publicHost = publicBaseUrl ? new URL(publicBaseUrl).host : null; + const allowedRequestHost = String(requestHost ?? '').split(',')[0].trim(); + if (publicHost && target.host !== publicHost && target.host !== allowedRequestHost) { + throw Object.assign(new Error('url 不属于当前 H5 域名'), { code: 'host_not_allowed' }); + } + return target.toString(); +} diff --git a/wechat-share.test.mjs b/wechat-share.test.mjs new file mode 100644 index 0000000..b232a56 --- /dev/null +++ b/wechat-share.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { validateWechatShareSignatureUrl } from './wechat-share.mjs'; + +test('validateWechatShareSignatureUrl accepts public H5 page urls without login state', () => { + assert.equal( + validateWechatShareSignatureUrl('https://m.tkmind.cn/u/john/pages/demo#section', { + publicBaseUrl: 'https://m.tkmind.cn', + requestHost: '127.0.0.1:8081', + }), + 'https://m.tkmind.cn/u/john/pages/demo', + ); +}); + +test('validateWechatShareSignatureUrl accepts current request host for local verification', () => { + assert.equal( + validateWechatShareSignatureUrl('http://127.0.0.1:8081/MindSpace/user-1/public/demo.html', { + publicBaseUrl: 'https://m.tkmind.cn', + requestHost: '127.0.0.1:8081', + }), + 'http://127.0.0.1:8081/MindSpace/user-1/public/demo.html', + ); +}); + +test('validateWechatShareSignatureUrl rejects foreign hosts', () => { + assert.throws( + () => + validateWechatShareSignatureUrl('https://evil.example/u/john/pages/demo', { + publicBaseUrl: 'https://m.tkmind.cn', + requestHost: '127.0.0.1:8081', + }), + (error) => error.code === 'host_not_allowed', + ); +});