diff --git a/.runtime/portal/RUNBOOK.txt b/.runtime/portal/RUNBOOK.txt
new file mode 100644
index 0000000..7127d3d
--- /dev/null
+++ b/.runtime/portal/RUNBOOK.txt
@@ -0,0 +1,25 @@
+Portal runtime artifact
+
+This directory is meant to run on production without the source tree.
+Required persisted items to inherit on the host:
+ .env
+ MindSpace/
+ data/
+ users/
+ .tailscale/
+ public/plaza-covers/
+ logs/
+
+Bundled alongside server.mjs (required for sandbox-fs MCP):
+ mindspace-sandbox-mcp.mjs (esbuild bundle; includes schedule-service deps)
+
+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
+ H5_USERS_ROOT / MINDSPACE_STORAGE_ROOT / MEMIND_SHARED_PUBLISH_ROOT
+
+Deployment and operations transport:
+ 105 fixed IP: 120.26.184.105
+ 103 / Studio fixed IP: 58.38.22.103
+ Do not switch back to 10.10.* LAN paths unless explicitly required.
diff --git a/.runtime/portal/mindspace-sandbox-mcp.mjs b/.runtime/portal/mindspace-sandbox-mcp.mjs
new file mode 100755
index 0000000..4a499ba
--- /dev/null
+++ b/.runtime/portal/mindspace-sandbox-mcp.mjs
@@ -0,0 +1,1399 @@
+#!/usr/bin/env node
+import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);
+
+// mindspace-sandbox-mcp.mjs
+import path from "node:path";
+import fs from "node:fs";
+import readline from "node:readline";
+import { execFileSync } from "node:child_process";
+import mysql from "mysql2/promise";
+
+// schedule-service.mjs
+import crypto from "node:crypto";
+
+// schedule-time.mjs
+var DEFAULT_TIMEZONE = "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;
+}
+function getLocalParts(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
+ const formatter = new Intl.DateTimeFormat("en-CA", {
+ timeZone: normalizeTimezone(timezone),
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false
+ });
+ const parts = Object.fromEntries(
+ formatter.formatToParts(new Date(epochMs)).map((part) => [part.type, part.value])
+ );
+ return {
+ year: Number(parts.year),
+ month: Number(parts.month),
+ day: Number(parts.day),
+ hour: Number(parts.hour === "24" ? 0 : parts.hour),
+ minute: Number(parts.minute),
+ second: Number(parts.second)
+ };
+}
+function localDateLabel(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
+ const parts = getLocalParts(epochMs, timezone);
+ return `${parts.month}\u6708${parts.day}\u65E5`;
+}
+function startOfLocalDay(epochMs = Date.now(), timezone = DEFAULT_TIMEZONE) {
+ const parts = getLocalParts(epochMs, timezone);
+ return zonedTimeToEpochMs(
+ {
+ year: parts.year,
+ month: parts.month,
+ day: parts.day,
+ hour: 0,
+ minute: 0,
+ second: 0
+ },
+ timezone
+ );
+}
+function addLocalDays(epochMs, days, timezone = DEFAULT_TIMEZONE) {
+ 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);
+ return zonedTimeToEpochMs(
+ {
+ year: shifted.getUTCFullYear(),
+ month: shifted.getUTCMonth() + 1,
+ day: shifted.getUTCDate(),
+ hour: shifted.getUTCHours(),
+ minute: shifted.getUTCMinutes(),
+ second: shifted.getUTCSeconds()
+ },
+ timezone
+ );
+}
+function zonedTimeToEpochMs(parts, timezone = DEFAULT_TIMEZONE) {
+ const tz = normalizeTimezone(timezone);
+ let guess = Date.UTC(
+ Number(parts.year),
+ Number(parts.month) - 1,
+ Number(parts.day),
+ Number(parts.hour ?? 0),
+ Number(parts.minute ?? 0),
+ Number(parts.second ?? 0),
+ 0
+ );
+ for (let i = 0; i < 3; i += 1) {
+ const actual = getLocalParts(guess, tz);
+ const actualAsUtc = Date.UTC(
+ actual.year,
+ actual.month - 1,
+ actual.day,
+ actual.hour,
+ actual.minute,
+ actual.second,
+ 0
+ );
+ const desiredAsUtc = Date.UTC(
+ Number(parts.year),
+ Number(parts.month) - 1,
+ Number(parts.day),
+ Number(parts.hour ?? 0),
+ Number(parts.minute ?? 0),
+ Number(parts.second ?? 0),
+ 0
+ );
+ const delta = actualAsUtc - desiredAsUtc;
+ if (delta === 0) break;
+ guess -= delta;
+ }
+ return guess;
+}
+function nextDailyRunAt({ hour, minute = 0, timezone = DEFAULT_TIMEZONE, now = Date.now() }) {
+ const tz = normalizeTimezone(timezone);
+ const todayStart = startOfLocalDay(now, tz);
+ const todayParts = getLocalParts(todayStart, tz);
+ let runAt = zonedTimeToEpochMs(
+ {
+ year: todayParts.year,
+ month: todayParts.month,
+ day: todayParts.day,
+ hour: Number(hour),
+ minute: Number(minute),
+ second: 0
+ },
+ tz
+ );
+ if (runAt <= now) {
+ const tomorrowStart = addLocalDays(todayStart, 1, tz);
+ const tomorrowParts = getLocalParts(tomorrowStart, tz);
+ runAt = zonedTimeToEpochMs(
+ {
+ year: tomorrowParts.year,
+ month: tomorrowParts.month,
+ day: tomorrowParts.day,
+ hour: Number(hour),
+ minute: Number(minute),
+ second: 0
+ },
+ tz
+ );
+ }
+ return runAt;
+}
+function formatLocalTime(epochMs, timezone = DEFAULT_TIMEZONE) {
+ const parts = getLocalParts(epochMs, timezone);
+ return `${pad2(parts.hour)}:${pad2(parts.minute)}`;
+}
+
+// schedule-service.mjs
+var DEFAULT_TIMEZONE2 = "Asia/Shanghai";
+function nowMs() {
+ return Date.now();
+}
+function rowToItem(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ userId: row.user_id,
+ kind: row.kind,
+ title: row.title,
+ description: row.description ?? null,
+ status: row.status,
+ startAt: row.start_at == null ? null : Number(row.start_at),
+ 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,
+ location: row.location ?? null,
+ createdAt: Number(row.created_at),
+ updatedAt: Number(row.updated_at)
+ };
+}
+function rowToDigest(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ userId: row.user_id,
+ hour: Number(row.hour),
+ minute: Number(row.minute),
+ timezone: row.timezone || DEFAULT_TIMEZONE2,
+ channel: row.channel || "wechat",
+ status: row.status,
+ nextRunAt: Number(row.next_run_at),
+ lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at),
+ attempts: Number(row.attempts ?? 0)
+ };
+}
+function rowToBalanceAlert(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ userId: row.user_id,
+ thresholdCents: Number(row.threshold_cents),
+ channel: row.channel || "wechat",
+ status: row.status,
+ nextRunAt: Number(row.next_run_at),
+ lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at),
+ lastNotifiedBalanceCents: row.last_notified_balance_cents == null ? null : Number(row.last_notified_balance_cents),
+ attempts: Number(row.attempts ?? 0),
+ lastError: row.last_error ?? null,
+ lockedUntil: row.locked_until == null ? null : Number(row.locked_until),
+ sourceChannel: row.source_channel ?? null,
+ sourceSessionId: row.source_session_id ?? null,
+ sourceMessageId: row.source_message_id ?? null,
+ sourceText: row.source_text ?? null
+ };
+}
+function rowToReminder(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ userId: row.user_id,
+ itemId: row.item_id,
+ remindAt: Number(row.remind_at),
+ offsetMinutes: row.offset_minutes == null ? null : Number(row.offset_minutes),
+ channel: row.channel || "wechat",
+ status: row.status,
+ attempts: Number(row.attempts ?? 0),
+ lastError: row.last_error ?? null,
+ lockedUntil: row.locked_until == null ? null : Number(row.locked_until),
+ sentAt: row.sent_at == null ? null : Number(row.sent_at),
+ createdAt: Number(row.created_at),
+ updatedAt: Number(row.updated_at)
+ };
+}
+function parseJsonColumn(value) {
+ if (value == null || value === "") return null;
+ if (typeof value === "string") {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return null;
+ }
+ }
+ if (typeof value === "object") return value;
+ return null;
+}
+function rowToUserNotification(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ userId: row.user_id,
+ channel: row.channel,
+ notificationType: row.notification_type,
+ title: row.title,
+ body: row.body,
+ data: parseJsonColumn(row.data_json),
+ status: row.status,
+ readAt: row.read_at == null ? null : Number(row.read_at),
+ createdAt: Number(row.created_at),
+ updatedAt: Number(row.updated_at)
+ };
+}
+function formatTodoDigest(items, { now = nowMs(), timezone = DEFAULT_TIMEZONE2 } = {}) {
+ const date = localDateLabel(now, timezone);
+ if (!items.length) {
+ return `${date} \u5F85\u529E\u8BB0\u5F55
+
+\u4ECA\u5929\u6682\u65F6\u6CA1\u6709\u5F85\u529E\u3002`;
+ }
+ const lines = [`${date} \u5F85\u529E\u8BB0\u5F55`, ""];
+ items.forEach((item, index) => {
+ const time = item.dueAt || item.startAt ? `\uFF08${formatLocalTime(item.dueAt || item.startAt, timezone)}\uFF09` : "";
+ lines.push(`${index + 1}. ${item.title}${time}`);
+ });
+ return lines.join("\n");
+}
+function createScheduleService(pool, options = {}) {
+ const defaultTimezone = normalizeTimezone(options.defaultTimezone || process.env.H5_DEFAULT_TIMEZONE);
+ const clock = options.clock || { now: nowMs };
+ const createItem = async ({
+ userId,
+ kind = "task",
+ title,
+ description = null,
+ startAt = null,
+ endAt = null,
+ dueAt = null,
+ allDay = false,
+ timezone = defaultTimezone,
+ location = null,
+ sourceChannel = "agent",
+ sourceSessionId = null,
+ sourceMessageId = null,
+ sourceText = null,
+ metadata = null
+ }) => {
+ if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
+ const cleanTitle = String(title ?? "").trim();
+ if (!cleanTitle) throw new Error("\u7F3A\u5C11\u5F85\u529E\u6807\u9898");
+ const safeKind = kind === "event" ? "event" : "task";
+ const id = crypto.randomUUID();
+ const ts = clock.now();
+ await pool.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,
+ metadata_json, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ id,
+ userId,
+ safeKind,
+ cleanTitle,
+ description,
+ startAt,
+ endAt,
+ dueAt,
+ allDay ? 1 : 0,
+ normalizeTimezone(timezone),
+ location,
+ sourceChannel,
+ sourceSessionId,
+ sourceMessageId,
+ sourceText,
+ metadata ? JSON.stringify(metadata) : null,
+ ts,
+ ts
+ ]
+ );
+ return {
+ id,
+ userId,
+ kind: safeKind,
+ title: cleanTitle,
+ startAt,
+ endAt,
+ dueAt,
+ allDay: Boolean(allDay),
+ timezone: normalizeTimezone(timezone),
+ status: "active",
+ createdAt: ts,
+ updatedAt: ts
+ };
+ };
+ const listItems = async ({ userId, from = null, to = null, status = "active", limit = 100 } = {}) => {
+ if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
+ const clauses = ["user_id = ?", "deleted_at IS NULL"];
+ const params = [userId];
+ if (status) {
+ clauses.push("status = ?");
+ params.push(status);
+ }
+ if (from != null && to != null) {
+ clauses.push(
+ `(
+ (start_at IS NOT NULL AND start_at >= ? AND start_at < ?)
+ OR (due_at IS NOT NULL AND due_at >= ? AND due_at < ?)
+ OR (start_at IS NULL AND due_at IS NULL)
+ )`
+ );
+ params.push(from, to, from, to);
+ }
+ params.push(Math.max(1, Math.min(500, Number(limit) || 100)));
+ const [rows] = await pool.query(
+ `SELECT *
+ FROM h5_schedule_items
+ WHERE ${clauses.join(" AND ")}
+ ORDER BY COALESCE(start_at, due_at, 9223372036854775807), created_at
+ LIMIT ?`,
+ params
+ );
+ return rows.map(rowToItem);
+ };
+ const listItemsBySourceMessage = async ({ userId, sourceMessageId, limit = 20 } = {}) => {
+ if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
+ const cleanSourceMessageId = String(sourceMessageId ?? "").trim();
+ if (!cleanSourceMessageId) return [];
+ const [rows] = await pool.query(
+ `SELECT *
+ FROM h5_schedule_items
+ WHERE user_id = ?
+ AND source_message_id = ?
+ AND deleted_at IS NULL
+ ORDER BY created_at DESC
+ LIMIT ?`,
+ [userId, cleanSourceMessageId, Math.max(1, Math.min(100, Number(limit) || 20))]
+ );
+ return rows.map(rowToItem);
+ };
+ const listTodayTodoItems = async ({ userId, timezone = defaultTimezone, now = clock.now() } = {}) => {
+ const start = startOfLocalDay(now, timezone);
+ const end = addLocalDays(start, 1, timezone);
+ return listItems({ userId, from: start, to: end, status: "active", limit: 200 });
+ };
+ const createReminder = async ({
+ userId,
+ itemId,
+ remindAt,
+ offsetMinutes = null,
+ channel = "wechat"
+ }) => {
+ if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
+ if (!itemId) throw new Error("\u7F3A\u5C11\u4E8B\u9879");
+ const safeRemindAt = Number(remindAt);
+ if (!Number.isFinite(safeRemindAt) || safeRemindAt <= 0) {
+ throw new Error("\u63D0\u9192\u65F6\u95F4\u65E0\u6548");
+ }
+ const safeOffsetMinutes = offsetMinutes == null || offsetMinutes === "" ? null : Number(offsetMinutes);
+ if (safeOffsetMinutes != null && !Number.isInteger(safeOffsetMinutes)) {
+ throw new Error("\u63D0\u9192\u504F\u79FB\u5206\u949F\u65E0\u6548");
+ }
+ const safeChannel = channel === "in_app" ? "in_app" : "wechat";
+ const [itemRows] = await pool.query(
+ `SELECT id
+ FROM h5_schedule_items
+ WHERE id = ? AND user_id = ? AND deleted_at IS NULL
+ LIMIT 1`,
+ [itemId, userId]
+ );
+ if (!itemRows[0]) throw new Error("\u4E8B\u9879\u4E0D\u5B58\u5728\u6216\u65E0\u6743\u8BBF\u95EE");
+ const id = crypto.randomUUID();
+ const ts = clock.now();
+ await pool.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)
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, NULL, NULL, NULL, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ offset_minutes = VALUES(offset_minutes),
+ status = 'pending',
+ last_error = NULL,
+ locked_until = NULL,
+ updated_at = VALUES(updated_at)`,
+ [id, userId, itemId, safeRemindAt, safeOffsetMinutes, safeChannel, ts, ts]
+ );
+ const [rows] = await pool.query(
+ `SELECT *
+ FROM h5_schedule_reminders
+ WHERE item_id = ? AND remind_at = ? AND channel = ?
+ LIMIT 1`,
+ [itemId, safeRemindAt, safeChannel]
+ );
+ return rowToReminder(rows[0]) || {
+ id,
+ userId,
+ itemId,
+ remindAt: safeRemindAt,
+ offsetMinutes: safeOffsetMinutes,
+ channel: safeChannel,
+ status: "pending",
+ attempts: 0,
+ lastError: null,
+ lockedUntil: null,
+ sentAt: null,
+ createdAt: ts,
+ updatedAt: ts
+ };
+ };
+ const listDigestSubscriptions = async ({
+ userId,
+ digestType = "todo_day",
+ channel = null,
+ status = null,
+ limit = 20
+ } = {}) => {
+ if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
+ const clauses = ["user_id = ?", "digest_type = ?"];
+ const params = [userId, digestType];
+ if (channel) {
+ clauses.push("channel = ?");
+ params.push(channel);
+ }
+ if (status) {
+ const statuses = Array.isArray(status) ? status : [status];
+ clauses.push(`status IN (${statuses.map(() => "?").join(", ")})`);
+ params.push(...statuses);
+ }
+ params.push(Math.max(1, Math.min(200, Number(limit) || 20)));
+ const [rows] = await pool.query(
+ `SELECT *
+ FROM h5_schedule_digest_subscriptions
+ WHERE ${clauses.join(" AND ")}
+ ORDER BY next_run_at ASC, created_at DESC
+ LIMIT ?`,
+ params
+ );
+ return rows.map(rowToDigest);
+ };
+ const createDailyTodoDigest = async ({
+ userId,
+ hour,
+ minute = 0,
+ timezone = defaultTimezone,
+ channel = "wechat",
+ sourceChannel = "agent",
+ sourceSessionId = null,
+ sourceMessageId = null,
+ sourceText = null
+ }) => {
+ if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
+ const safeHour = Number(hour);
+ const safeMinute = Number(minute);
+ if (!Number.isInteger(safeHour) || safeHour < 0 || safeHour > 23) {
+ throw new Error("\u63D0\u9192\u5C0F\u65F6\u65E0\u6548");
+ }
+ if (!Number.isInteger(safeMinute) || safeMinute < 0 || safeMinute > 59) {
+ throw new Error("\u63D0\u9192\u5206\u949F\u65E0\u6548");
+ }
+ const tz = normalizeTimezone(timezone);
+ const nextRunAt = nextDailyRunAt({
+ hour: safeHour,
+ minute: safeMinute,
+ timezone: tz,
+ now: clock.now()
+ });
+ const id = crypto.randomUUID();
+ const ts = clock.now();
+ await pool.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)
+ VALUES (?, ?, 'todo_day', ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ hour = VALUES(hour),
+ minute = VALUES(minute),
+ timezone = VALUES(timezone),
+ channel = VALUES(channel),
+ status = 'active',
+ next_run_at = VALUES(next_run_at),
+ source_channel = VALUES(source_channel),
+ source_session_id = VALUES(source_session_id),
+ source_message_id = VALUES(source_message_id),
+ source_text = VALUES(source_text),
+ updated_at = VALUES(updated_at)`,
+ [
+ id,
+ userId,
+ safeHour,
+ safeMinute,
+ tz,
+ channel,
+ nextRunAt,
+ sourceChannel,
+ sourceSessionId,
+ sourceMessageId,
+ sourceText,
+ ts,
+ ts
+ ]
+ );
+ const [rows] = await pool.query(
+ `SELECT * FROM h5_schedule_digest_subscriptions
+ WHERE user_id = ? AND digest_type = 'todo_day' AND channel = ?
+ LIMIT 1`,
+ [userId, channel]
+ );
+ return rowToDigest(rows[0]) || {
+ id,
+ userId,
+ hour: safeHour,
+ minute: safeMinute,
+ timezone: tz,
+ channel,
+ status: "active",
+ nextRunAt,
+ lastRunAt: null,
+ attempts: 0
+ };
+ };
+ const createBalanceLowAlert = async ({
+ userId,
+ thresholdCents,
+ channel = "wechat",
+ sourceChannel = "agent",
+ sourceSessionId = null,
+ sourceMessageId = null,
+ sourceText = null
+ } = {}) => {
+ if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
+ const safeThreshold = Number(thresholdCents);
+ if (!Number.isFinite(safeThreshold) || safeThreshold < 0) {
+ throw new Error("\u4F59\u989D\u9608\u503C\u65E0\u6548");
+ }
+ const now = clock.now();
+ const id = crypto.randomUUID();
+ await pool.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,
+ source_channel, source_session_id, source_message_id, source_text, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 'active', ?, NULL, NULL, 0, NULL, NULL, ?, ?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ threshold_cents = VALUES(threshold_cents),
+ channel = VALUES(channel),
+ status = 'active',
+ next_run_at = VALUES(next_run_at),
+ last_error = NULL,
+ locked_until = NULL,
+ updated_at = VALUES(updated_at)`,
+ [id, userId, safeThreshold, channel, now, sourceChannel, sourceSessionId, sourceMessageId, sourceText, now, now]
+ );
+ const [rows] = await pool.query(
+ `SELECT * FROM h5_balance_alert_subscriptions WHERE user_id = ? AND channel = ? LIMIT 1`,
+ [userId, channel]
+ );
+ return rowToBalanceAlert(rows[0]) || {
+ id,
+ userId,
+ thresholdCents: safeThreshold,
+ channel,
+ status: "active",
+ nextRunAt: now,
+ lastRunAt: null,
+ lastNotifiedBalanceCents: null,
+ attempts: 0
+ };
+ };
+ const listDueBalanceAlerts = async ({ now = clock.now(), limit = 50 } = {}) => {
+ const [rows] = await pool.query(
+ `SELECT *
+ FROM h5_balance_alert_subscriptions
+ WHERE next_run_at <= ?
+ AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))
+ ORDER BY next_run_at ASC
+ LIMIT ?`,
+ [now, now, Math.max(1, Math.min(200, Number(limit) || 50))]
+ );
+ return rows.map(rowToBalanceAlert);
+ };
+ const lockBalanceAlert = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => {
+ const lockedUntil = now + lockMs;
+ const [result] = await pool.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(
+ `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(
+ `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 = ?
+ WHERE id = ?`,
+ [now + 5 * 60 * 1e3, now, subscription.lastNotifiedBalanceCents ?? null, now, subscription.id]
+ );
+ return { ...subscription, status: "active", lastRunAt: now };
+ };
+ const markBalanceAlertFailed = async (subscription, error, { now = clock.now(), retryMs = 10 * 60 * 1e3, maxAttempts = 5 } = {}) => {
+ const attempts = Number(subscription.attempts ?? 0);
+ const status = attempts >= maxAttempts ? "failed" : "active";
+ const nextRunAt = status === "active" ? now + retryMs : subscription.nextRunAt;
+ await pool.query(
+ `UPDATE h5_balance_alert_subscriptions
+ SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ?
+ WHERE id = ?`,
+ [status, nextRunAt, String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), now, subscription.id]
+ );
+ };
+ const listDueDigestSubscriptions = async ({ now = clock.now(), limit = 50 } = {}) => {
+ const [rows] = await pool.query(
+ `SELECT *
+ FROM h5_schedule_digest_subscriptions
+ WHERE next_run_at <= ?
+ AND (status = 'active' OR (status = 'locked' AND locked_until IS NOT NULL AND locked_until <= ?))
+ ORDER BY next_run_at ASC
+ LIMIT ?`,
+ [now, now, Math.max(1, Math.min(200, Number(limit) || 50))]
+ );
+ return rows.map(rowToDigest);
+ };
+ const lockDigestSubscription = async (id, { now = clock.now(), lockMs = 12e4 } = {}) => {
+ const lockedUntil = now + lockMs;
+ const [result] = await pool.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(
+ `SELECT * FROM h5_schedule_digest_subscriptions WHERE id = ? LIMIT 1`,
+ [id]
+ );
+ return rowToDigest(rows[0]);
+ };
+ const markDigestSent = async (subscription, { now = clock.now() } = {}) => {
+ const nextRunAt = nextDailyRunAt({
+ hour: subscription.hour,
+ minute: subscription.minute,
+ timezone: subscription.timezone,
+ now: now + 1e3
+ });
+ await pool.query(
+ `UPDATE h5_schedule_digest_subscriptions
+ SET status = 'active', next_run_at = ?, last_run_at = ?, locked_until = NULL,
+ last_error = NULL, updated_at = ?
+ WHERE id = ?`,
+ [nextRunAt, now, now, subscription.id]
+ );
+ return { ...subscription, status: "active", nextRunAt, lastRunAt: now };
+ };
+ const markDigestFailed = async (subscription, error, { now = clock.now(), retryMs = 10 * 60 * 1e3, maxAttempts = 5 } = {}) => {
+ const attempts = Number(subscription.attempts ?? 0);
+ const status = attempts >= maxAttempts ? "failed" : "active";
+ const nextRunAt = status === "active" ? now + retryMs : subscription.nextRunAt;
+ await pool.query(
+ `UPDATE h5_schedule_digest_subscriptions
+ SET status = ?, next_run_at = ?, locked_until = NULL, last_error = ?, updated_at = ?
+ WHERE id = ?`,
+ [status, nextRunAt, String(error?.message ?? error ?? "\u53D1\u9001\u5931\u8D25").slice(0, 500), now, subscription.id]
+ );
+ };
+ const logDelivery = async ({ subscriptionId, userId, channel = "wechat", status, providerMessageId = null, errorCode = null, errorMessage = null }) => {
+ await pool.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, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ crypto.randomUUID(),
+ subscriptionId,
+ userId,
+ channel,
+ status,
+ providerMessageId,
+ errorCode,
+ errorMessage ? String(errorMessage).slice(0, 500) : null,
+ clock.now()
+ ]
+ );
+ };
+ const buildTodoDigestText = async ({ userId, timezone = defaultTimezone, now = clock.now() }) => {
+ const items = await listTodayTodoItems({ userId, timezone, now });
+ return formatTodoDigest(items, { now, timezone });
+ };
+ const getUserWalletSnapshot = async (userId) => {
+ if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
+ const [rows] = await pool.query(
+ `SELECT balance_cents AS balanceCents, tokens_used AS tokensUsed
+ FROM h5_user_wallets
+ WHERE user_id = ?
+ LIMIT 1`,
+ [userId]
+ );
+ return rows[0] ?? null;
+ };
+ const createUserNotification = async ({
+ userId,
+ channel = "web",
+ notificationType,
+ title,
+ body,
+ data = null
+ }) => {
+ if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
+ if (!notificationType) throw new Error("\u7F3A\u5C11\u901A\u77E5\u7C7B\u578B");
+ 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 = crypto.randomUUID();
+ await pool.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, ?, ?)`,
+ [id, userId, channel, notificationType, title, body, data ? JSON.stringify(data) : null, now, now]
+ );
+ return { id, userId, channel, notificationType, title, body, data, status: "unread", readAt: null, createdAt: now, updatedAt: now };
+ };
+ const listUserNotifications = async ({ userId, status = "unread", limit = 20 } = {}) => {
+ if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
+ const clauses = ["user_id = ?"];
+ const params = [userId];
+ if (status && status !== "all") {
+ clauses.push("status = ?");
+ params.push(status);
+ }
+ params.push(Math.max(1, Math.min(100, Number(limit) || 20)));
+ const [rows] = await pool.query(
+ `SELECT *
+ FROM h5_user_notifications
+ WHERE ${clauses.join(" AND ")}
+ ORDER BY created_at DESC
+ LIMIT ?`,
+ params
+ );
+ return rows.map(rowToUserNotification);
+ };
+ 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(
+ `UPDATE h5_user_notifications
+ SET status = 'read', read_at = ?, updated_at = ?
+ WHERE id = ? AND user_id = ? AND status <> 'read'`,
+ [now, now, notificationId, userId]
+ );
+ return Number(result?.affectedRows ?? 0) > 0;
+ };
+ const markAllUserNotificationsRead = async ({ userId } = {}) => {
+ if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
+ const now = clock.now();
+ const [result] = await pool.query(
+ `UPDATE h5_user_notifications
+ SET status = 'read', read_at = ?, updated_at = ?
+ WHERE user_id = ? AND status <> 'read'`,
+ [now, now, userId]
+ );
+ return Number(result?.affectedRows ?? 0);
+ };
+ const deleteUserNotification = async ({ userId, notificationId } = {}) => {
+ if (!userId || !notificationId) throw new Error("\u7F3A\u5C11\u901A\u77E5\u53C2\u6570");
+ const [result] = await pool.query(
+ `DELETE FROM h5_user_notifications
+ WHERE id = ? AND user_id = ?`,
+ [notificationId, userId]
+ );
+ return Number(result?.affectedRows ?? 0) > 0;
+ };
+ const clearUserNotifications = async ({ userId, status = "all" } = {}) => {
+ if (!userId) throw new Error("\u7F3A\u5C11\u7528\u6237");
+ const clauses = ["user_id = ?"];
+ const params = [userId];
+ if (status && status !== "all") {
+ clauses.push("status = ?");
+ params.push(status);
+ }
+ const [result] = await pool.query(
+ `DELETE FROM h5_user_notifications
+ WHERE ${clauses.join(" AND ")}`,
+ params
+ );
+ return Number(result?.affectedRows ?? 0);
+ };
+ return {
+ createItem,
+ createReminder,
+ listItems,
+ listItemsBySourceMessage,
+ listTodayTodoItems,
+ listDigestSubscriptions,
+ createDailyTodoDigest,
+ createBalanceLowAlert,
+ listDueDigestSubscriptions,
+ lockDigestSubscription,
+ markDigestSent,
+ markDigestFailed,
+ listDueBalanceAlerts,
+ lockBalanceAlert,
+ markBalanceAlertSent,
+ markBalanceAlertFailed,
+ logDelivery,
+ buildTodoDigestText,
+ getUserWalletSnapshot,
+ createUserNotification,
+ listUserNotifications,
+ markUserNotificationRead,
+ markAllUserNotificationsRead,
+ deleteUserNotification,
+ clearUserNotifications
+ };
+}
+
+// mindspace-sandbox-mcp.mjs
+var SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim();
+if (!SANDBOX_ROOT) {
+ process.stderr.write("[mindspace-sandbox-mcp] SANDBOX_ROOT is not set \u2014 refusing to start\n");
+ process.exit(1);
+}
+var SANDBOX = path.resolve(SANDBOX_ROOT);
+var PRIVATE_DATA_DIR = path.join(SANDBOX, ".mindspace");
+var PRIVATE_DATA_DB = path.join(PRIVATE_DATA_DIR, "private-data.sqlite");
+var SQLITE_BIN = process.env.SQLITE_BIN?.trim() || "sqlite3";
+var PRIVATE_DATA_MAX_BYTES = Number(process.env.PRIVATE_DATA_MAX_BYTES ?? 20 * 1024 * 1024);
+var PRIVATE_DATA_QUERY_TIMEOUT_MS = Number(process.env.PRIVATE_DATA_QUERY_TIMEOUT_MS ?? 5e3);
+var PRIVATE_DATA_MAX_ROWS = Number(process.env.PRIVATE_DATA_MAX_ROWS ?? 200);
+var PRIVATE_DATA_USER_ID = process.env.PRIVATE_DATA_USER_ID?.trim();
+var allowedToolsEnv = process.env.ALLOWED_TOOLS?.trim();
+var ALLOWED_TOOLS = allowedToolsEnv ? new Set(allowedToolsEnv.split(",").map((s) => s.trim())) : null;
+function resolveSandboxed(p) {
+ if (!p || typeof p !== "string") throw new Error("\u8DEF\u5F84\u53C2\u6570\u65E0\u6548");
+ const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(SANDBOX, 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" });
+ }
+ return resolved;
+}
+var ALL_TOOLS = [
+ {
+ name: "read_file",
+ description: "\u8BFB\u53D6\u6587\u4EF6\u5185\u5BB9\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\u7684\u6587\u4EF6\uFF09",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "\u6587\u4EF6\u8DEF\u5F84\uFF08\u76F8\u5BF9\u5DE5\u4F5C\u533A\u6216\u7EDD\u5BF9\u8DEF\u5F84\uFF09" }
+ },
+ required: ["path"]
+ }
+ },
+ {
+ name: "write_file",
+ description: "\u5199\u5165\u6587\u4EF6\u5185\u5BB9\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\u7684\u6587\u4EF6\uFF1B\u4E0D\u5B58\u5728\u5219\u521B\u5EFA\uFF0C\u5DF2\u5B58\u5728\u5219\u8986\u76D6\uFF09",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "\u6587\u4EF6\u8DEF\u5F84" },
+ content: { type: "string", description: "\u6587\u4EF6\u5185\u5BB9" }
+ },
+ required: ["path", "content"]
+ }
+ },
+ {
+ name: "edit_file",
+ description: "\u5C06\u6587\u4EF6\u4E2D\u7684\u65E7\u5185\u5BB9\u66FF\u6362\u4E3A\u65B0\u5185\u5BB9\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\u7684\u6587\u4EF6\uFF09",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "\u6587\u4EF6\u8DEF\u5F84" },
+ old_str: { type: "string", description: "\u8981\u66FF\u6362\u7684\u539F\u59CB\u5185\u5BB9\uFF08\u5FC5\u987B\u5728\u6587\u4EF6\u4E2D\u552F\u4E00\uFF09" },
+ new_str: { type: "string", description: "\u66FF\u6362\u540E\u7684\u65B0\u5185\u5BB9" }
+ },
+ required: ["path", "old_str", "new_str"]
+ }
+ },
+ {
+ name: "list_dir",
+ description: "\u5217\u51FA\u76EE\u5F55\u5185\u5BB9\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\u7684\u76EE\u5F55\uFF09",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: {
+ type: "string",
+ description: "\u76EE\u5F55\u8DEF\u5F84\uFF08\u7701\u7565\u5219\u5217\u51FA\u5DE5\u4F5C\u533A\u6839\u76EE\u5F55\uFF09"
+ }
+ }
+ }
+ },
+ {
+ name: "create_dir",
+ description: "\u521B\u5EFA\u76EE\u5F55\uFF08\u4EC5\u9650\u5DE5\u4F5C\u533A\u5185\uFF1B\u5DF2\u5B58\u5728\u4E0D\u62A5\u9519\uFF09",
+ inputSchema: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "\u76EE\u5F55\u8DEF\u5F84" }
+ },
+ required: ["path"]
+ }
+ },
+ {
+ name: "private_data_info",
+ description: "\u67E5\u770B\u5F53\u524D\u7528\u6237\u201C\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u201D\u7684\u72B6\u6001\u3002\u6BCF\u4E2A\u7528\u6237\u53EA\u6709\u4E00\u4E2A\u79C1\u6709 SQLite \u6570\u636E\u5E93\uFF0C\u9002\u5408\u4FDD\u5B58\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u3001\u8C03\u7814\u6570\u636E\u548C\u5206\u6790\u4E2D\u95F4\u8868\uFF1B\u4E0D\u8981\u521B\u5EFA\u989D\u5916 SQLite \u6587\u4EF6\u3002",
+ inputSchema: { type: "object", properties: {} }
+ },
+ {
+ name: "private_data_schema",
+ description: "\u67E5\u770B\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u4E2D\u7684\u8868\u548C\u5B57\u6BB5\u3002\u7528\u4E8E\u4E86\u89E3\u5F53\u524D\u7528\u6237\u81EA\u5DF1\u7684 SQLite \u8868\u7ED3\u6784\u3002",
+ inputSchema: { type: "object", properties: {} }
+ },
+ {
+ name: "private_data_query",
+ description: "\u53EA\u8BFB\u67E5\u8BE2\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u3002\u4EC5\u5141\u8BB8 SELECT/WITH\uFF0C\u9ED8\u8BA4\u6700\u591A\u8FD4\u56DE 200 \u884C\uFF0C\u9002\u5408\u7EDF\u8BA1\u95EE\u5377\u56DE\u7B54\u3001\u7B5B\u9009\u8868\u5355\u6570\u636E\u3001\u5206\u6790\u7528\u6237\u79C1\u6709\u6570\u636E\u3002",
+ inputSchema: {
+ type: "object",
+ properties: {
+ sql: { type: "string", description: "\u53EA\u8BFB SQL\uFF0C\u5FC5\u987B\u662F SELECT/WITH" }
+ },
+ required: ["sql"]
+ }
+ },
+ {
+ name: "private_data_execute",
+ description: "\u5199\u5165\u6216\u53D8\u66F4\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u3002\u53EF\u7528\u4E8E\u521B\u5EFA\u95EE\u5377/\u8868\u5355/\u6E05\u5355\u7B49\u79C1\u6709\u8868\u5E76\u5199\u5165\u6570\u636E\uFF1B\u7981\u6B62 ATTACH\u3001load_extension\u3001PRAGMA writable_schema\u3001VACUUM INTO \u7B49\u8D8A\u754C\u6216\u5371\u9669\u64CD\u4F5C\u3002",
+ inputSchema: {
+ type: "object",
+ properties: {
+ sql: { type: "string", description: "\u8981\u6267\u884C\u7684 SQLite SQL" }
+ },
+ required: ["sql"]
+ }
+ }
+];
+var quotaPool = null;
+var scheduleService = null;
+function isQuotaSyncConfigured() {
+ return Boolean(
+ PRIVATE_DATA_USER_ID && (process.env.DATABASE_URL || process.env.MYSQL_HOST && process.env.MYSQL_DATABASE)
+ );
+}
+function getQuotaPool() {
+ if (!isQuotaSyncConfigured()) return null;
+ if (quotaPool) return quotaPool;
+ quotaPool = process.env.DATABASE_URL ? mysql.createPool(process.env.DATABASE_URL) : mysql.createPool({
+ host: process.env.MYSQL_HOST ?? "localhost",
+ port: Number(process.env.MYSQL_PORT ?? 3306),
+ user: process.env.MYSQL_USER ?? "boot",
+ password: process.env.MYSQL_PASSWORD ?? "",
+ database: process.env.MYSQL_DATABASE ?? "tkmind",
+ waitForConnections: true,
+ connectionLimit: 2
+ });
+ return quotaPool;
+}
+function isScheduleConfigured() {
+ return isQuotaSyncConfigured();
+}
+function getScheduleService() {
+ if (!PRIVATE_DATA_USER_ID) throw new Error("\u5F53\u524D\u4F1A\u8BDD\u6CA1\u6709\u7528\u6237\u4E0A\u4E0B\u6587\uFF0C\u4E0D\u80FD\u64CD\u4F5C\u5F85\u529E");
+ const pool = getQuotaPool();
+ if (!pool || !isScheduleConfigured()) {
+ throw new Error("\u5F53\u524D\u73AF\u5883\u672A\u914D\u7F6E\u5F85\u529E\u6570\u636E\u5E93\u8FDE\u63A5");
+ }
+ if (!scheduleService) {
+ scheduleService = createScheduleService(pool, {
+ defaultTimezone: process.env.H5_DEFAULT_TIMEZONE || "Asia/Shanghai"
+ });
+ }
+ return scheduleService;
+}
+if (isScheduleConfigured()) {
+ ALL_TOOLS.push(
+ {
+ name: "schedule_create_item",
+ description: "\u4E3A\u5F53\u524D\u7528\u6237\u521B\u5EFA\u5F85\u529E\u6216\u65E5\u7A0B\u4E8B\u9879\u3002\u4EC5\u5199\u5165\u5F53\u524D\u7528\u6237\u81EA\u5DF1\u7684 h5_schedule_items\uFF0C\u4E0D\u53EF\u64CD\u4F5C\u5176\u4ED6\u7528\u6237\u6570\u636E\u3002",
+ inputSchema: {
+ type: "object",
+ properties: {
+ kind: { type: "string", description: "task \u6216 event\uFF0C\u9ED8\u8BA4 task" },
+ title: { type: "string", description: "\u4E8B\u9879\u6807\u9898" },
+ description: { type: "string", description: "\u4E8B\u9879\u63CF\u8FF0\uFF0C\u53EF\u9009" },
+ startAt: { type: "number", description: "\u5F00\u59CB\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" },
+ endAt: { type: "number", description: "\u7ED3\u675F\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" },
+ dueAt: { type: "number", description: "\u622A\u6B62\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" },
+ allDay: { type: "boolean", description: "\u662F\u5426\u5168\u5929\u4E8B\u9879\uFF0C\u53EF\u9009" },
+ timezone: { type: "string", description: "\u65F6\u533A\uFF0C\u53EF\u9009" },
+ location: { type: "string", description: "\u5730\u70B9\uFF0C\u53EF\u9009" },
+ sourceMessageId: { type: "string", description: "\u670D\u52A1\u53F7\u6D88\u606F ID\uFF1B\u6709\u503C\u65F6\u5FC5\u987B\u539F\u6837\u4F20\u5165" },
+ sourceText: { type: "string", description: "\u539F\u59CB\u7528\u6237\u6587\u672C\uFF0C\u53EF\u9009" }
+ },
+ required: ["title"]
+ }
+ },
+ {
+ name: "schedule_create_reminder",
+ description: "\u4E3A\u5F53\u524D\u7528\u6237\u5DF2\u6709\u4E8B\u9879\u521B\u5EFA\u5355\u6B21\u63D0\u9192\u3002\u5FC5\u987B\u5148\u6709 itemId\uFF0C\u518D\u5199\u5165 h5_schedule_reminders\u3002",
+ inputSchema: {
+ type: "object",
+ properties: {
+ itemId: { type: "string", description: "\u4E8B\u9879 ID" },
+ remindAt: { type: "number", description: "\u63D0\u9192\u89E6\u53D1\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233" },
+ offsetMinutes: { type: "number", description: "\u76F8\u5BF9\u4E8B\u9879\u65F6\u95F4\u7684\u63D0\u524D\u5206\u949F\u6570\uFF0C\u53EF\u9009" },
+ channel: { type: "string", description: "\u63D0\u9192\u901A\u9053\uFF0C\u9ED8\u8BA4 wechat" }
+ },
+ required: ["itemId", "remindAt"]
+ }
+ },
+ {
+ name: "schedule_list_items",
+ description: "\u67E5\u8BE2\u5F53\u524D\u7528\u6237\u7684\u5F85\u529E/\u65E5\u7A0B\u4E8B\u9879\u5217\u8868\u3002\u4EC5\u8FD4\u56DE\u5F53\u524D\u7528\u6237\u6570\u636E\uFF0C\u53EF\u6309\u65F6\u95F4\u8303\u56F4\u8FC7\u6EE4\u3002",
+ inputSchema: {
+ type: "object",
+ properties: {
+ from: { type: "number", description: "\u8D77\u59CB\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" },
+ to: { type: "number", description: "\u7ED3\u675F\u65F6\u95F4 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF0C\u53EF\u9009" },
+ status: { type: "string", description: "\u4E8B\u9879\u72B6\u6001\uFF0C\u9ED8\u8BA4 active\uFF0C\u53EF\u9009" },
+ limit: { type: "number", description: "\u8FD4\u56DE\u6570\u91CF\uFF0C\u9ED8\u8BA4 50\uFF0C\u53EF\u9009" }
+ }
+ }
+ }
+ );
+}
+var TOOLS = ALLOWED_TOOLS ? ALL_TOOLS.filter((t) => ALLOWED_TOOLS.has(t.name)) : ALL_TOOLS;
+function ensurePrivateDataDb() {
+ fs.mkdirSync(PRIVATE_DATA_DIR, { recursive: true });
+ if (!fs.existsSync(PRIVATE_DATA_DB)) {
+ runSqlite(["-batch", PRIVATE_DATA_DB, "PRAGMA journal_mode=WAL; PRAGMA user_version = 1;"]);
+ }
+ return PRIVATE_DATA_DB;
+}
+function privateDataSize() {
+ let total = 0;
+ for (const file of fs.existsSync(PRIVATE_DATA_DIR) ? fs.readdirSync(PRIVATE_DATA_DIR) : []) {
+ if (file === "private-data.sqlite" || file.startsWith("private-data.sqlite-")) {
+ total += fs.statSync(path.join(PRIVATE_DATA_DIR, file)).size;
+ }
+ }
+ return total;
+}
+function runSqlite(args) {
+ return execFileSync(SQLITE_BIN, args, {
+ encoding: "utf8",
+ timeout: PRIVATE_DATA_QUERY_TIMEOUT_MS,
+ maxBuffer: 1024 * 1024
+ });
+}
+function sqliteScalar(sql) {
+ return Number(runSqlite(["-batch", "-noheader", PRIVATE_DATA_DB, sql]).trim() || 0);
+}
+async function getQuotaState({ forUpdate = false, conn = null } = {}) {
+ const pool = getQuotaPool();
+ if (!pool) return null;
+ const db = conn ?? pool;
+ const [rows] = await db.query(
+ `SELECT id, quota_bytes, used_bytes, reserved_bytes, status
+ FROM h5_user_spaces
+ WHERE user_id = ?
+ LIMIT 1 ${forUpdate ? "FOR UPDATE" : ""}`,
+ [PRIVATE_DATA_USER_ID]
+ );
+ const row = rows[0];
+ if (!row) return null;
+ const quotaBytes = Number(row.quota_bytes ?? 0);
+ const usedBytes = Number(row.used_bytes ?? 0);
+ const reservedBytes = Number(row.reserved_bytes ?? 0);
+ return {
+ id: row.id,
+ status: row.status,
+ quotaBytes,
+ usedBytes,
+ reservedBytes,
+ availableBytes: Math.max(0, quotaBytes - usedBytes - reservedBytes)
+ };
+}
+async function sqliteMaxPagePragmaForQuota(currentBytes) {
+ const quota = await getQuotaState();
+ if (!quota) return "";
+ if (quota.status !== "active") throw new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u5199");
+ if (quota.availableBytes <= 0 && currentBytes >= PRIVATE_DATA_MAX_BYTES) {
+ throw new Error("\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u914D\u989D\u4E0D\u8DB3");
+ }
+ const allowedSize = Math.min(PRIVATE_DATA_MAX_BYTES, currentBytes + quota.availableBytes);
+ const pageSize = sqliteScalar("PRAGMA page_size;") || 4096;
+ const currentPages = sqliteScalar("PRAGMA page_count;") || 1;
+ const maxPages = Math.max(currentPages, Math.max(1, Math.floor(allowedSize / pageSize)));
+ return `PRAGMA max_page_count=${maxPages};`;
+}
+async function syncPrivateDataQuota(deltaBytes) {
+ const pool = getQuotaPool();
+ if (!pool || !deltaBytes) return null;
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ const quota = await getQuotaState({ forUpdate: true, conn });
+ if (!quota) {
+ await conn.rollback();
+ return null;
+ }
+ if (quota.status !== "active") throw new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u5199");
+ if (deltaBytes > quota.availableBytes) {
+ throw new Error(`\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u914D\u989D\u4E0D\u8DB3\uFF1A\u9700\u8981 ${deltaBytes} \u5B57\u8282\uFF0C\u53EF\u7528 ${quota.availableBytes} \u5B57\u8282`);
+ }
+ await conn.query(
+ `UPDATE h5_user_spaces
+ SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ?
+ WHERE id = ? AND user_id = ?`,
+ [deltaBytes, Date.now(), quota.id, PRIVATE_DATA_USER_ID]
+ );
+ await conn.commit();
+ return { deltaBytes };
+ } catch (err) {
+ await conn.rollback();
+ throw err;
+ } finally {
+ conn.release();
+ }
+}
+function stripSqlComments(sql) {
+ return String(sql ?? "").replace(/--.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").trim();
+}
+function rejectDangerousSql(sql, { readonly = false } = {}) {
+ const cleaned = stripSqlComments(sql);
+ if (!cleaned) throw new Error("SQL \u4E0D\u80FD\u4E3A\u7A7A");
+ if (cleaned.length > 2e4) throw new Error("SQL \u8FC7\u957F");
+ if (/^\s*\./m.test(cleaned)) {
+ throw new Error("SQL \u4E0D\u5141\u8BB8\u4F7F\u7528 sqlite3 dot command");
+ }
+ if (/\b(ATTACH|DETACH|LOAD_EXTENSION|VACUUM\s+INTO)\b/i.test(cleaned)) {
+ throw new Error("SQL \u5305\u542B\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u4E0D\u5141\u8BB8\u7684\u64CD\u4F5C");
+ }
+ if (/\bPRAGMA\s+writable_schema\b/i.test(cleaned)) {
+ throw new Error("SQL \u5305\u542B\u4E0D\u5141\u8BB8\u7684 PRAGMA");
+ }
+ if (readonly && !/^\s*(SELECT|WITH)\b/i.test(cleaned)) {
+ throw new Error("private_data_query \u53EA\u5141\u8BB8 SELECT/WITH");
+ }
+ return cleaned;
+}
+async function queryPrivateData(sql) {
+ const db = ensurePrivateDataDb();
+ const cleaned = rejectDangerousSql(sql, { readonly: true });
+ const limited = `SELECT * FROM (${cleaned.replace(/;\s*$/, "")}) LIMIT ${PRIVATE_DATA_MAX_ROWS}`;
+ const output = runSqlite(["-json", db, limited]);
+ return output.trim() || "[]";
+}
+async function executePrivateData(sql) {
+ const db = ensurePrivateDataDb();
+ const before = privateDataSize();
+ if (before > PRIVATE_DATA_MAX_BYTES) throw new Error("\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u5DF2\u8D85\u8FC7\u5927\u5C0F\u9650\u5236");
+ const cleaned = rejectDangerousSql(sql);
+ const quotaPragma = await sqliteMaxPagePragmaForQuota(before);
+ runSqlite(["-batch", db, `${quotaPragma}
+${cleaned}`]);
+ const after = privateDataSize();
+ if (after > PRIVATE_DATA_MAX_BYTES) {
+ throw new Error(`\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4\u8D85\u8FC7\u5927\u5C0F\u9650\u5236\uFF1A${after}/${PRIVATE_DATA_MAX_BYTES} \u5B57\u8282`);
+ }
+ const delta = after - before;
+ const quotaSync = await syncPrivateDataQuota(delta);
+ return `\u5DF2\u6267\u884C\u3002\u5F53\u524D\u6570\u636E\u7A7A\u95F4\u5927\u5C0F ${after} \u5B57\u8282${quotaSync ? `\uFF0C\u5DF2\u540C\u6B65\u7A7A\u95F4\u5360\u7528 ${delta} \u5B57\u8282` : ""}`;
+}
+async function privateDataSchema() {
+ const db = ensurePrivateDataDb();
+ const output = runSqlite([
+ "-json",
+ db,
+ `SELECT m.name AS table_name, p.cid, p.name AS column_name, p.type, p."notnull" AS not_null, p.pk
+ FROM sqlite_master m
+ JOIN pragma_table_info(m.name) p
+ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
+ ORDER BY m.name, p.cid`
+ ]);
+ return output.trim() || "[]";
+}
+async function callTool(name, args) {
+ if (ALLOWED_TOOLS && !ALLOWED_TOOLS.has(name)) {
+ throw new Error(`\u5DE5\u5177 ${name} \u672A\u6388\u6743`);
+ }
+ switch (name) {
+ case "read_file": {
+ const abs = resolveSandboxed(args.path);
+ const content = fs.readFileSync(abs, "utf8");
+ return [{ type: "text", text: content }];
+ }
+ case "write_file": {
+ const abs = resolveSandboxed(args.path);
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
+ fs.writeFileSync(abs, args.content ?? "", "utf8");
+ return [{ type: "text", text: `\u5DF2\u5199\u5165 ${args.path}\uFF08${(args.content ?? "").length} \u5B57\u8282\uFF09` }];
+ }
+ case "edit_file": {
+ const abs = resolveSandboxed(args.path);
+ const original = fs.readFileSync(abs, "utf8");
+ const oldStr = args.old_str ?? "";
+ if (oldStr && !original.includes(oldStr)) {
+ throw new Error("edit_file: old_str \u5728\u6587\u4EF6\u4E2D\u4E0D\u5B58\u5728\uFF0C\u66FF\u6362\u5931\u8D25");
+ }
+ const updated = oldStr ? original.replace(oldStr, args.new_str ?? "") : args.new_str ?? "";
+ fs.writeFileSync(abs, updated, "utf8");
+ return [{ type: "text", text: `\u5DF2\u7F16\u8F91 ${args.path}` }];
+ }
+ case "list_dir": {
+ const target = args?.path ?? ".";
+ const abs = resolveSandboxed(target);
+ const entries = fs.readdirSync(abs, { withFileTypes: true });
+ const lines = entries.map((e) => `${e.isDirectory() ? "[\u76EE\u5F55]" : "[\u6587\u4EF6]"} ${e.name}`);
+ return [{ type: "text", text: lines.join("\n") || "\uFF08\u7A7A\u76EE\u5F55\uFF09" }];
+ }
+ case "create_dir": {
+ const abs = resolveSandboxed(args.path);
+ fs.mkdirSync(abs, { recursive: true });
+ return [{ type: "text", text: `\u5DF2\u521B\u5EFA\u76EE\u5F55 ${args.path}` }];
+ }
+ case "private_data_info": {
+ ensurePrivateDataDb();
+ const size = privateDataSize();
+ const quota = await getQuotaState().catch(() => null);
+ return [
+ {
+ type: "text",
+ text: JSON.stringify(
+ {
+ name: "\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4",
+ database: ".mindspace/private-data.sqlite",
+ maxBytes: PRIVATE_DATA_MAX_BYTES,
+ sizeBytes: size,
+ quotaSyncEnabled: Boolean(getQuotaPool()),
+ quota: quota ? {
+ status: quota.status,
+ quotaBytes: quota.quotaBytes,
+ usedBytes: quota.usedBytes,
+ reservedBytes: quota.reservedBytes,
+ availableBytes: quota.availableBytes
+ } : null,
+ rules: [
+ "\u6BCF\u4E2A\u7528\u6237\u53EA\u80FD\u4F7F\u7528\u8FD9\u4E2A\u552F\u4E00 SQLite \u6570\u636E\u5E93",
+ "\u9002\u5408\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u3001\u8C03\u7814\u6570\u636E\u548C\u5206\u6790\u4E2D\u95F4\u8868",
+ "\u4E0D\u8981\u5B58\u8D26\u53F7\u3001\u8BA1\u8D39\u3001\u6743\u9650\u3001\u5BA1\u8BA1\u3001\u516C\u5F00\u5E73\u53F0\u6570\u636E\u6216\u8DE8\u7528\u6237\u6570\u636E"
+ ]
+ },
+ null,
+ 2
+ )
+ }
+ ];
+ }
+ case "private_data_schema":
+ return [{ type: "text", text: await privateDataSchema() }];
+ case "private_data_query":
+ return [{ type: "text", text: await queryPrivateData(args.sql) }];
+ case "private_data_execute":
+ return [{ type: "text", text: await executePrivateData(args.sql) }];
+ case "schedule_create_item": {
+ const item = await getScheduleService().createItem({
+ userId: PRIVATE_DATA_USER_ID,
+ kind: args.kind,
+ title: args.title,
+ description: args.description ?? null,
+ startAt: args.startAt ?? null,
+ endAt: args.endAt ?? null,
+ dueAt: args.dueAt ?? null,
+ allDay: Boolean(args.allDay),
+ timezone: args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai",
+ location: args.location ?? null,
+ sourceChannel: "agent",
+ sourceMessageId: args.sourceMessageId ?? null,
+ sourceText: args.sourceText ?? null,
+ metadata: { source: "schedule_assistant_skill" }
+ });
+ return [{ type: "text", text: JSON.stringify(item, null, 2) }];
+ }
+ case "schedule_create_reminder": {
+ const reminder = await getScheduleService().createReminder({
+ userId: PRIVATE_DATA_USER_ID,
+ itemId: args.itemId,
+ remindAt: args.remindAt,
+ offsetMinutes: args.offsetMinutes ?? null,
+ channel: args.channel ?? "wechat"
+ });
+ return [{ type: "text", text: JSON.stringify(reminder, null, 2) }];
+ }
+ case "schedule_list_items": {
+ const items = await getScheduleService().listItems({
+ userId: PRIVATE_DATA_USER_ID,
+ from: args.from ?? null,
+ to: args.to ?? null,
+ status: args.status ?? "active",
+ limit: args.limit ?? 50
+ });
+ return [{ type: "text", text: JSON.stringify(items, null, 2) }];
+ }
+ default:
+ throw new Error(`\u672A\u77E5\u5DE5\u5177\uFF1A${name}`);
+ }
+}
+function send(obj) {
+ process.stdout.write(JSON.stringify(obj) + "\n");
+}
+function respond(id, result) {
+ send({ jsonrpc: "2.0", id, result });
+}
+function respondError(id, message, code = -32603) {
+ send({ jsonrpc: "2.0", id, error: { code, message } });
+}
+var rl = readline.createInterface({ input: process.stdin, terminal: false });
+rl.on("line", async (raw) => {
+ const line = raw.trim();
+ if (!line) return;
+ let msg;
+ try {
+ msg = JSON.parse(line);
+ } catch {
+ process.stderr.write(`[mindspace-sandbox-mcp] invalid JSON: ${line}
+`);
+ return;
+ }
+ const { id, method, params } = msg;
+ switch (method) {
+ case "initialize":
+ respond(id, {
+ protocolVersion: "2024-11-05",
+ serverInfo: { name: "mindspace-sandbox", version: "1.0.0" },
+ capabilities: { tools: {} }
+ });
+ break;
+ case "initialized":
+ break;
+ case "tools/list":
+ respond(id, { tools: TOOLS });
+ break;
+ case "tools/call": {
+ const { name, arguments: toolArgs } = params ?? {};
+ try {
+ const content = await callTool(name, toolArgs ?? {});
+ respond(id, { content, isError: false });
+ } catch (err) {
+ respond(id, {
+ content: [{ type: "text", text: err.message }],
+ isError: true
+ });
+ }
+ break;
+ }
+ default:
+ if (id !== void 0 && id !== null) {
+ respondError(id, `Method not found: ${method}`, -32601);
+ }
+ }
+});
+rl.on("close", () => process.exit(0));
diff --git a/.runtime/portal/package.json b/.runtime/portal/package.json
new file mode 100644
index 0000000..5497b5a
--- /dev/null
+++ b/.runtime/portal/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "tkmind-h5-portal-runtime",
+ "private": true,
+ "type": "module"
+}
diff --git a/.runtime/portal/public/MP_verify_nXjhx0ErC6MhQ0SZ.txt b/.runtime/portal/public/MP_verify_nXjhx0ErC6MhQ0SZ.txt
new file mode 100644
index 0000000..c6b1270
--- /dev/null
+++ b/.runtime/portal/public/MP_verify_nXjhx0ErC6MhQ0SZ.txt
@@ -0,0 +1 @@
+nXjhx0ErC6MhQ0SZ
diff --git a/.runtime/portal/public/hello-john.html b/.runtime/portal/public/hello-john.html
new file mode 100644
index 0000000..992c5f1
--- /dev/null
+++ b/.runtime/portal/public/hello-john.html
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+ Hello, John!
+
+
+
+
+
+
+
+
+
👋
+
Hello!
+
Welcome, John
+
+
+
+
diff --git a/.runtime/portal/public/plaza-covers/business-1bt5ajr.jpg b/.runtime/portal/public/plaza-covers/business-1bt5ajr.jpg
new file mode 100644
index 0000000..80de088
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-1bt5ajr.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-1dpghh8.jpg b/.runtime/portal/public/plaza-covers/business-1dpghh8.jpg
new file mode 100644
index 0000000..76d994a
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-1dpghh8.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-30-84q88u.jpg b/.runtime/portal/public/plaza-covers/business-30-84q88u.jpg
new file mode 100644
index 0000000..a22930d
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-30-84q88u.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-coffee-shop.jpg b/.runtime/portal/public/plaza-covers/business-coffee-shop.jpg
new file mode 100644
index 0000000..9117550
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-coffee-shop.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-e09bef.jpg b/.runtime/portal/public/plaza-covers/business-e09bef.jpg
new file mode 100644
index 0000000..80de088
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-e09bef.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-nfc9ph.jpg b/.runtime/portal/public/plaza-covers/business-nfc9ph.jpg
new file mode 100644
index 0000000..27b08b0
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-nfc9ph.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-pilates.jpg b/.runtime/portal/public/plaza-covers/business-pilates.jpg
new file mode 100644
index 0000000..e7ad1cc
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-pilates.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-sop-33nw2t.jpg b/.runtime/portal/public/plaza-covers/business-sop-33nw2t.jpg
new file mode 100644
index 0000000..27b08b0
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-sop-33nw2t.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-zfht2z.jpg b/.runtime/portal/public/plaza-covers/business-zfht2z.jpg
new file mode 100644
index 0000000..2da05d3
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-zfht2z.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg b/.runtime/portal/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg
new file mode 100644
index 0000000..27b08b0
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg b/.runtime/portal/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg
new file mode 100644
index 0000000..80de088
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg b/.runtime/portal/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg
new file mode 100644
index 0000000..2da05d3
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-独立书店文创周边选品策略.jpg b/.runtime/portal/public/plaza-covers/business-独立书店文创周边选品策略.jpg
new file mode 100644
index 0000000..76d994a
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-独立书店文创周边选品策略.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg b/.runtime/portal/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg
new file mode 100644
index 0000000..27b08b0
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg b/.runtime/portal/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg
new file mode 100644
index 0000000..a22930d
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg b/.runtime/portal/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg
new file mode 100644
index 0000000..80de088
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-17bb2q5.jpg b/.runtime/portal/public/plaza-covers/creative-17bb2q5.jpg
new file mode 100644
index 0000000..3330d02
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-17bb2q5.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-1a69un3.jpg b/.runtime/portal/public/plaza-covers/creative-1a69un3.jpg
new file mode 100644
index 0000000..dbf1769
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-1a69un3.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-1wuxajv.jpg b/.runtime/portal/public/plaza-covers/creative-1wuxajv.jpg
new file mode 100644
index 0000000..1e93c59
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-1wuxajv.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-ai-1m59cjr.jpg b/.runtime/portal/public/plaza-covers/creative-ai-1m59cjr.jpg
new file mode 100644
index 0000000..dbf1769
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-ai-1m59cjr.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-ceramics-1o5zmj5.jpg b/.runtime/portal/public/plaza-covers/creative-ceramics-1o5zmj5.jpg
new file mode 100644
index 0000000..495339d
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-ceramics-1o5zmj5.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-cyber-city.jpg b/.runtime/portal/public/plaza-covers/creative-cyber-city.jpg
new file mode 100644
index 0000000..980e792
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-cyber-city.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-music-ep.jpg b/.runtime/portal/public/plaza-covers/creative-music-ep.jpg
new file mode 100644
index 0000000..980e792
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-music-ep.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg b/.runtime/portal/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg
new file mode 100644
index 0000000..3330d02
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-vis-tkrnir.jpg b/.runtime/portal/public/plaza-covers/creative-vis-tkrnir.jpg
new file mode 100644
index 0000000..1e93c59
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-vis-tkrnir.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg b/.runtime/portal/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg
new file mode 100644
index 0000000..1e93c59
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg b/.runtime/portal/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg
new file mode 100644
index 0000000..3330d02
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-复古胶片人像调色预设包.jpg b/.runtime/portal/public/plaza-covers/creative-复古胶片人像调色预设包.jpg
new file mode 100644
index 0000000..dbf1769
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-复古胶片人像调色预设包.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg b/.runtime/portal/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg
new file mode 100644
index 0000000..495339d
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg b/.runtime/portal/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg
new file mode 100644
index 0000000..3330d02
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg b/.runtime/portal/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg
new file mode 100644
index 0000000..dbf1769
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg b/.runtime/portal/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg
new file mode 100644
index 0000000..1e93c59
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-1293jxp.jpg b/.runtime/portal/public/plaza-covers/data-analysis-1293jxp.jpg
new file mode 100644
index 0000000..1b25426
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-1293jxp.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-a-b-1slrtg.jpg b/.runtime/portal/public/plaza-covers/data-analysis-a-b-1slrtg.jpg
new file mode 100644
index 0000000..0969588
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-a-b-1slrtg.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg b/.runtime/portal/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg
new file mode 100644
index 0000000..0969588
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg b/.runtime/portal/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-cohort.jpg b/.runtime/portal/public/plaza-covers/data-analysis-cohort.jpg
new file mode 100644
index 0000000..b5db113
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-cohort.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-g6tcgg.jpg b/.runtime/portal/public/plaza-covers/data-analysis-g6tcgg.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-g6tcgg.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-gmv.jpg b/.runtime/portal/public/plaza-covers/data-analysis-gmv.jpg
new file mode 100644
index 0000000..5b146d7
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-gmv.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg b/.runtime/portal/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg
new file mode 100644
index 0000000..a39faf3
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-sql-9sel2d.jpg b/.runtime/portal/public/plaza-covers/data-analysis-sql-9sel2d.jpg
new file mode 100644
index 0000000..1b25426
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-sql-9sel2d.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-ztijjg.jpg b/.runtime/portal/public/plaza-covers/data-analysis-ztijjg.jpg
new file mode 100644
index 0000000..0969588
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-ztijjg.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg b/.runtime/portal/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg
new file mode 100644
index 0000000..1b25426
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg b/.runtime/portal/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg b/.runtime/portal/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg
new file mode 100644
index 0000000..1b25426
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg b/.runtime/portal/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg
new file mode 100644
index 0000000..0969588
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg b/.runtime/portal/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg
new file mode 100644
index 0000000..a39faf3
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg b/.runtime/portal/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-100-40-l2vjup.jpg b/.runtime/portal/public/plaza-covers/lifestyle-100-40-l2vjup.jpg
new file mode 100644
index 0000000..e2e61e9
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-100-40-l2vjup.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-1o3s87z.jpg b/.runtime/portal/public/plaza-covers/lifestyle-1o3s87z.jpg
new file mode 100644
index 0000000..e9f9bda
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-1o3s87z.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-1wfbcos.jpg b/.runtime/portal/public/plaza-covers/lifestyle-1wfbcos.jpg
new file mode 100644
index 0000000..f73669e
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-1wfbcos.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-30-173irnz.jpg b/.runtime/portal/public/plaza-covers/lifestyle-30-173irnz.jpg
new file mode 100644
index 0000000..e9f9bda
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-30-173irnz.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg b/.runtime/portal/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg
new file mode 100644
index 0000000..e9f9bda
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-7-1cwt66m.jpg b/.runtime/portal/public/plaza-covers/lifestyle-7-1cwt66m.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-7-1cwt66m.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-desk.jpg b/.runtime/portal/public/plaza-covers/lifestyle-desk.jpg
new file mode 100644
index 0000000..8599dcc
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-desk.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-m33c9i.jpg b/.runtime/portal/public/plaza-covers/lifestyle-m33c9i.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-m33c9i.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-o90i4e.jpg b/.runtime/portal/public/plaza-covers/lifestyle-o90i4e.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-o90i4e.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-writing.jpg b/.runtime/portal/public/plaza-covers/lifestyle-writing.jpg
new file mode 100644
index 0000000..8599dcc
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-writing.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg b/.runtime/portal/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg
new file mode 100644
index 0000000..f73669e
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg b/.runtime/portal/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg b/.runtime/portal/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg
new file mode 100644
index 0000000..e9f9bda
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg b/.runtime/portal/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg b/.runtime/portal/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg
new file mode 100644
index 0000000..e2e61e9
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg b/.runtime/portal/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-0-1000-35irqz.jpg b/.runtime/portal/public/plaza-covers/other-0-1000-35irqz.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-0-1000-35irqz.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-176h7rn.jpg b/.runtime/portal/public/plaza-covers/other-176h7rn.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-176h7rn.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-187ouof.jpg b/.runtime/portal/public/plaza-covers/other-187ouof.jpg
new file mode 100644
index 0000000..3c2e59b
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-187ouof.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-1nd2tz4.jpg b/.runtime/portal/public/plaza-covers/other-1nd2tz4.jpg
new file mode 100644
index 0000000..5dca65c
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-1nd2tz4.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-20-1de41cz.jpg b/.runtime/portal/public/plaza-covers/other-20-1de41cz.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-20-1de41cz.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-opensource.jpg b/.runtime/portal/public/plaza-covers/other-opensource.jpg
new file mode 100644
index 0000000..21dfb28
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-opensource.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-vr-60-1yt52pj.jpg b/.runtime/portal/public/plaza-covers/other-vr-60-1yt52pj.jpg
new file mode 100644
index 0000000..3c2e59b
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-vr-60-1yt52pj.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-vr-健身-60-天体验报告.jpg b/.runtime/portal/public/plaza-covers/other-vr-健身-60-天体验报告.jpg
new file mode 100644
index 0000000..3c2e59b
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-vr-健身-60-天体验报告.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-wishlist.jpg b/.runtime/portal/public/plaza-covers/other-wishlist.jpg
new file mode 100644
index 0000000..6bef7a3
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-wishlist.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-xu49pe.jpg b/.runtime/portal/public/plaza-covers/other-xu49pe.jpg
new file mode 100644
index 0000000..15cbd88
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-xu49pe.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-个人知识库搭建方法论.jpg b/.runtime/portal/public/plaza-covers/other-个人知识库搭建方法论.jpg
new file mode 100644
index 0000000..3c2e59b
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-个人知识库搭建方法论.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-二手交易向可持续生活转型.jpg b/.runtime/portal/public/plaza-covers/other-二手交易向可持续生活转型.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-二手交易向可持续生活转型.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg b/.runtime/portal/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg b/.runtime/portal/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-社群运营-从群聊到价值观.jpg b/.runtime/portal/public/plaza-covers/other-社群运营-从群聊到价值观.jpg
new file mode 100644
index 0000000..15cbd88
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-社群运营-从群聊到价值观.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/other-自由职业者财务规划入门.jpg b/.runtime/portal/public/plaza-covers/other-自由职业者财务规划入门.jpg
new file mode 100644
index 0000000..5dca65c
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/other-自由职业者财务规划入门.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-6owsyr.jpg b/.runtime/portal/public/plaza-covers/study-notes-6owsyr.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-6owsyr.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-docker-1adiwh7.jpg b/.runtime/portal/public/plaza-covers/study-notes-docker-1adiwh7.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-docker-1adiwh7.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg b/.runtime/portal/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-fobvok.jpg b/.runtime/portal/public/plaza-covers/study-notes-fobvok.jpg
new file mode 100644
index 0000000..15cbd88
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-fobvok.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-git-code-review-1avjty.jpg b/.runtime/portal/public/plaza-covers/study-notes-git-code-review-1avjty.jpg
new file mode 100644
index 0000000..edd5abf
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-git-code-review-1avjty.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg b/.runtime/portal/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg
new file mode 100644
index 0000000..edd5abf
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-llm.jpg b/.runtime/portal/public/plaza-covers/study-notes-llm.jpg
new file mode 100644
index 0000000..32c7d0c
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-llm.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-pandas-1mg4mom.jpg b/.runtime/portal/public/plaza-covers/study-notes-pandas-1mg4mom.jpg
new file mode 100644
index 0000000..edd5abf
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-pandas-1mg4mom.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg b/.runtime/portal/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg
new file mode 100644
index 0000000..edd5abf
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-pca-54364r.jpg b/.runtime/portal/public/plaza-covers/study-notes-pca-54364r.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-pca-54364r.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-rust.jpg b/.runtime/portal/public/plaza-covers/study-notes-rust.jpg
new file mode 100644
index 0000000..3fa0a55
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-rust.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg b/.runtime/portal/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg b/.runtime/portal/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg b/.runtime/portal/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg
new file mode 100644
index 0000000..15cbd88
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg b/.runtime/portal/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg b/.runtime/portal/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-10-ap683v.jpg b/.runtime/portal/public/plaza-covers/travel-10-ap683v.jpg
new file mode 100644
index 0000000..d7437c5
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-10-ap683v.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-15qb47w.jpg b/.runtime/portal/public/plaza-covers/travel-15qb47w.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-15qb47w.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-3-1lbpfse.jpg b/.runtime/portal/public/plaza-covers/travel-3-1lbpfse.jpg
new file mode 100644
index 0000000..f5e5f1f
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-3-1lbpfse.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-4-70oznz.jpg b/.runtime/portal/public/plaza-covers/travel-4-70oznz.jpg
new file mode 100644
index 0000000..4490c59
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-4-70oznz.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-48-p5s92m.jpg b/.runtime/portal/public/plaza-covers/travel-48-p5s92m.jpg
new file mode 100644
index 0000000..f5e5f1f
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-48-p5s92m.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-6-1hwz8po.jpg b/.runtime/portal/public/plaza-covers/travel-6-1hwz8po.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-6-1hwz8po.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-fjj9j0.jpg b/.runtime/portal/public/plaza-covers/travel-fjj9j0.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-fjj9j0.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-kyoto.jpg b/.runtime/portal/public/plaza-covers/travel-kyoto.jpg
new file mode 100644
index 0000000..2bd1d1d
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-kyoto.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-malaysia.jpg b/.runtime/portal/public/plaza-covers/travel-malaysia.jpg
new file mode 100644
index 0000000..2bd1d1d
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-malaysia.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg b/.runtime/portal/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg b/.runtime/portal/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg
new file mode 100644
index 0000000..d7437c5
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg b/.runtime/portal/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg
new file mode 100644
index 0000000..f5e5f1f
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg b/.runtime/portal/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg
new file mode 100644
index 0000000..4490c59
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg b/.runtime/portal/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg b/.runtime/portal/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg
new file mode 100644
index 0000000..f5e5f1f
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg b/.runtime/portal/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-1y4i5h1.jpg b/.runtime/portal/public/plaza-covers/work-report-1y4i5h1.jpg
new file mode 100644
index 0000000..206f41a
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-1y4i5h1.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-2025-1fz5i3.jpg b/.runtime/portal/public/plaza-covers/work-report-2025-1fz5i3.jpg
new file mode 100644
index 0000000..884182f
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-2025-1fz5i3.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-72ek9j.jpg b/.runtime/portal/public/plaza-covers/work-report-72ek9j.jpg
new file mode 100644
index 0000000..00bf9c0
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-72ek9j.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-ai-service.jpg b/.runtime/portal/public/plaza-covers/work-report-ai-service.jpg
new file mode 100644
index 0000000..dc1d298
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-ai-service.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg b/.runtime/portal/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg
new file mode 100644
index 0000000..884182f
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg b/.runtime/portal/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg
new file mode 100644
index 0000000..884182f
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-disney.jpg b/.runtime/portal/public/plaza-covers/work-report-disney.jpg
new file mode 100644
index 0000000..49f866d
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-disney.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-explore.jpg b/.runtime/portal/public/plaza-covers/work-report-explore.jpg
new file mode 100644
index 0000000..ef2d817
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-explore.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-growth.jpg b/.runtime/portal/public/plaza-covers/work-report-growth.jpg
new file mode 100644
index 0000000..d8959b2
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-growth.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-hello.jpg b/.runtime/portal/public/plaza-covers/work-report-hello.jpg
new file mode 100644
index 0000000..5c69410
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-hello.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-mindspace-qwuy2q.jpg b/.runtime/portal/public/plaza-covers/work-report-mindspace-qwuy2q.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-mindspace-qwuy2q.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-mindspace-公开发布指南.jpg b/.runtime/portal/public/plaza-covers/work-report-mindspace-公开发布指南.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-mindspace-公开发布指南.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-okr-ivflp3.jpg b/.runtime/portal/public/plaza-covers/work-report-okr-ivflp3.jpg
new file mode 100644
index 0000000..00bf9c0
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-okr-ivflp3.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-q3-1v4dgld.jpg b/.runtime/portal/public/plaza-covers/work-report-q3-1v4dgld.jpg
new file mode 100644
index 0000000..509e7ba
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-q3-1v4dgld.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg b/.runtime/portal/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-supply-chain.jpg b/.runtime/portal/public/plaza-covers/work-report-supply-chain.jpg
new file mode 100644
index 0000000..1782e04
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-supply-chain.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg b/.runtime/portal/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg
new file mode 100644
index 0000000..00bf9c0
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg b/.runtime/portal/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg
new file mode 100644
index 0000000..00bf9c0
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg b/.runtime/portal/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg b/.runtime/portal/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg
new file mode 100644
index 0000000..206f41a
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg b/.runtime/portal/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg
new file mode 100644
index 0000000..884182f
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg differ
diff --git a/.runtime/portal/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg b/.runtime/portal/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg
new file mode 100644
index 0000000..509e7ba
Binary files /dev/null and b/.runtime/portal/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg differ
diff --git a/.runtime/portal/public/thumbnail-demo/index.html b/.runtime/portal/public/thumbnail-demo/index.html
new file mode 100644
index 0000000..6c63f7f
--- /dev/null
+++ b/.runtime/portal/public/thumbnail-demo/index.html
@@ -0,0 +1,67 @@
+
+
+
+
+
+ MindSpace 预览图 Demo
+
+
+
+ MindSpace 预览图升级 Demo
+ 左侧为旧版横版缩略图,中间为新版 3:4 信息流封面(自动从 HTML 提取标题、配色、emoji),右侧为保存页面时实际写入的 thumbnail.svg。下方是小红书风格卡片效果。
+
+
+
+ 样本 1
+
+
旧版(横版字卡)
+
新版(3:4 精美封面)
+
generateHtmlThumbnail 输出
+
+
+
+
+
+
马来西亚旅游攻略
+
旅行攻略 · 新版 3:4 封面
+
+
+
+
+ 样本 2
+
+
旧版(横版字卡)
+
新版(3:4 精美封面)
+
generateHtmlThumbnail 输出
+
+
+
+
+
+
麻婆豆腐
+
美食专题 · 新版 3:4 封面
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.runtime/portal/public/thumbnail-demo/malaysia-travel-feed.svg b/.runtime/portal/public/thumbnail-demo/malaysia-travel-feed.svg
new file mode 100644
index 0000000..aca64f1
--- /dev/null
+++ b/.runtime/portal/public/thumbnail-demo/malaysia-travel-feed.svg
@@ -0,0 +1,45 @@
+
+
\ No newline at end of file
diff --git a/.runtime/portal/public/thumbnail-demo/malaysia-travel-generated.svg b/.runtime/portal/public/thumbnail-demo/malaysia-travel-generated.svg
new file mode 100644
index 0000000..aca64f1
--- /dev/null
+++ b/.runtime/portal/public/thumbnail-demo/malaysia-travel-generated.svg
@@ -0,0 +1,45 @@
+
+
\ No newline at end of file
diff --git a/.runtime/portal/public/thumbnail-demo/malaysia-travel-legacy.svg b/.runtime/portal/public/thumbnail-demo/malaysia-travel-legacy.svg
new file mode 100644
index 0000000..e82c0f0
--- /dev/null
+++ b/.runtime/portal/public/thumbnail-demo/malaysia-travel-legacy.svg
@@ -0,0 +1,52 @@
+
+
\ No newline at end of file
diff --git a/.runtime/portal/public/thumbnail-demo/mapo-tofu-feed.svg b/.runtime/portal/public/thumbnail-demo/mapo-tofu-feed.svg
new file mode 100644
index 0000000..0129022
--- /dev/null
+++ b/.runtime/portal/public/thumbnail-demo/mapo-tofu-feed.svg
@@ -0,0 +1,52 @@
+
+
\ No newline at end of file
diff --git a/.runtime/portal/public/thumbnail-demo/mapo-tofu-generated.svg b/.runtime/portal/public/thumbnail-demo/mapo-tofu-generated.svg
new file mode 100644
index 0000000..0129022
--- /dev/null
+++ b/.runtime/portal/public/thumbnail-demo/mapo-tofu-generated.svg
@@ -0,0 +1,52 @@
+
+
\ No newline at end of file
diff --git a/.runtime/portal/public/thumbnail-demo/mapo-tofu-legacy.svg b/.runtime/portal/public/thumbnail-demo/mapo-tofu-legacy.svg
new file mode 100644
index 0000000..3362f19
--- /dev/null
+++ b/.runtime/portal/public/thumbnail-demo/mapo-tofu-legacy.svg
@@ -0,0 +1,52 @@
+
+
\ No newline at end of file
diff --git a/.runtime/portal/schema.sql b/.runtime/portal/schema.sql
new file mode 100644
index 0000000..c02695e
--- /dev/null
+++ b/.runtime/portal/schema.sql
@@ -0,0 +1,999 @@
+CREATE TABLE IF NOT EXISTS h5_users (
+ id CHAR(36) PRIMARY KEY,
+ username VARCHAR(64) NOT NULL UNIQUE,
+ slug VARCHAR(64) NULL,
+ email VARCHAR(255) NULL,
+ display_name VARCHAR(128) NOT NULL,
+ salt VARCHAR(64) NOT NULL,
+ password_hash VARCHAR(128) NOT NULL,
+ password_algorithm VARCHAR(32) NOT NULL DEFAULT 'pbkdf2-sha512',
+ role ENUM('user', 'admin') NOT NULL DEFAULT 'user',
+ status ENUM('active', 'suspended', 'disabled') NOT NULL DEFAULT 'active',
+ plan_type VARCHAR(32) NOT NULL DEFAULT 'free',
+ workspace_root VARCHAR(512) NOT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_users_slug (slug),
+ UNIQUE KEY uq_h5_users_email (email)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_user_spaces (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ space_name VARCHAR(128) NOT NULL DEFAULT '我的空间',
+ quota_bytes BIGINT NOT NULL DEFAULT 5242880,
+ used_bytes BIGINT NOT NULL DEFAULT 0,
+ reserved_bytes BIGINT NOT NULL DEFAULT 0,
+ status ENUM('active', 'locked', 'deleted') NOT NULL DEFAULT 'active',
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_user_space_user (user_id),
+ CONSTRAINT fk_h5_space_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
+ CONSTRAINT chk_h5_space_quota CHECK (
+ quota_bytes >= 0 AND used_bytes >= 0 AND reserved_bytes >= 0
+ )
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS mindspace_config (
+ `key` VARCHAR(64) PRIMARY KEY,
+ value TEXT NOT NULL,
+ description VARCHAR(255) NULL,
+ updated_at BIGINT NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_space_categories (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ space_id CHAR(36) NOT NULL,
+ category_code VARCHAR(32) NOT NULL,
+ category_name VARCHAR(128) NOT NULL,
+ visibility_policy VARCHAR(64) NOT NULL,
+ ai_access_policy VARCHAR(64) NOT NULL,
+ publish_policy VARCHAR(64) NOT NULL,
+ is_system TINYINT(1) NOT NULL DEFAULT 0,
+ sort_order INT NOT NULL DEFAULT 0,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_space_category_code (space_id, category_code),
+ KEY idx_h5_space_category_user (user_id, sort_order),
+ CONSTRAINT fk_h5_category_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_category_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_assets (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ space_id CHAR(36) NOT NULL,
+ category_id CHAR(36) NOT NULL,
+ parent_id CHAR(36) NULL,
+ asset_type VARCHAR(32) NOT NULL,
+ mime_type VARCHAR(128) NOT NULL,
+ original_filename VARCHAR(255) NOT NULL,
+ display_name VARCHAR(255) NOT NULL,
+ current_version_id CHAR(36) NULL,
+ size_bytes BIGINT NOT NULL DEFAULT 0,
+ checksum CHAR(64) NOT NULL,
+ risk_level ENUM('none', 'low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'none',
+ visibility ENUM('private', 'internal', 'public_candidate') NOT NULL DEFAULT 'private',
+ status ENUM('uploaded', 'processing', 'ready', 'quarantined', 'archived', 'deleted') NOT NULL DEFAULT 'ready',
+ source_type ENUM('upload', 'chat', 'agent', 'template', 'generated', 'workspace') NOT NULL DEFAULT 'upload',
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ deleted_at BIGINT NULL,
+ KEY idx_h5_assets_category (user_id, category_id, updated_at),
+ KEY idx_h5_assets_parent (user_id, parent_id),
+ KEY idx_h5_assets_checksum (user_id, checksum),
+ CONSTRAINT fk_h5_asset_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_asset_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_asset_category FOREIGN KEY (category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_asset_parent FOREIGN KEY (parent_id) REFERENCES h5_assets(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_asset_versions (
+ id CHAR(36) PRIMARY KEY,
+ asset_id CHAR(36) NOT NULL,
+ version_no INT NOT NULL,
+ storage_key VARCHAR(512) NOT NULL,
+ size_bytes BIGINT NOT NULL,
+ checksum CHAR(64) NOT NULL,
+ mime_type VARCHAR(128) NOT NULL,
+ created_by CHAR(36) NOT NULL,
+ change_note VARCHAR(512) NULL,
+ scan_status ENUM('pending', 'passed', 'warned', 'blocked') NOT NULL DEFAULT 'pending',
+ created_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_asset_version (asset_id, version_no),
+ UNIQUE KEY uq_h5_asset_storage_key (storage_key),
+ CONSTRAINT fk_h5_asset_version_asset FOREIGN KEY (asset_id) REFERENCES h5_assets(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_asset_version_user FOREIGN KEY (created_by) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_upload_sessions (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ space_id CHAR(36) NOT NULL,
+ category_id CHAR(36) NOT NULL,
+ filename VARCHAR(255) NOT NULL,
+ expected_size BIGINT NOT NULL,
+ actual_size BIGINT NULL,
+ declared_mime_type VARCHAR(128) NULL,
+ detected_mime_type VARCHAR(128) NULL,
+ reserved_bytes BIGINT NOT NULL,
+ temporary_storage_key VARCHAR(512) NOT NULL,
+ checksum CHAR(64) NULL,
+ completed_asset_id CHAR(36) NULL,
+ status ENUM('reserved', 'uploaded', 'completed', 'cancelled', 'expired', 'failed') NOT NULL DEFAULT 'reserved',
+ expires_at BIGINT NOT NULL,
+ created_at BIGINT NOT NULL,
+ completed_at BIGINT NULL,
+ KEY idx_h5_upload_user_status (user_id, status, expires_at),
+ CONSTRAINT fk_h5_upload_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_upload_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_upload_category FOREIGN KEY (category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_page_records (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ space_id CHAR(36) NOT NULL,
+ category_id CHAR(36) NOT NULL,
+ source_session_id VARCHAR(128) NULL,
+ source_message_id VARCHAR(128) NULL,
+ source_asset_id CHAR(36) NULL,
+ title VARCHAR(255) NOT NULL,
+ summary VARCHAR(1000) NULL,
+ cover_image_asset_id CHAR(36) NULL,
+ page_type VARCHAR(32) NOT NULL DEFAULT 'article',
+ template_id VARCHAR(64) NOT NULL DEFAULT 'editorial',
+ draft_content_ref VARCHAR(512) NULL,
+ current_version_id CHAR(36) NULL,
+ current_publish_id CHAR(36) NULL,
+ status ENUM('draft', 'reviewing', 'risk_found', 'ready', 'published', 'protected', 'expired', 'offline', 'deleted') NOT NULL DEFAULT 'draft',
+ visibility ENUM('private', 'internal', 'public') NOT NULL DEFAULT 'private',
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ deleted_at BIGINT NULL,
+ KEY idx_h5_pages_user_status (user_id, status, updated_at),
+ KEY idx_h5_pages_category (user_id, category_id, updated_at),
+ KEY idx_h5_pages_source_session (user_id, source_session_id),
+ CONSTRAINT fk_h5_page_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_page_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_page_category FOREIGN KEY (category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_page_source_asset FOREIGN KEY (source_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL,
+ CONSTRAINT fk_h5_page_cover_asset FOREIGN KEY (cover_image_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_page_versions (
+ id CHAR(36) PRIMARY KEY,
+ page_id CHAR(36) NOT NULL,
+ version_no INT NOT NULL,
+ content_asset_id CHAR(36) NOT NULL,
+ bundle_asset_id CHAR(36) NULL,
+ source_snapshot_json JSON NULL,
+ security_scan_id CHAR(36) NULL,
+ created_by CHAR(36) NOT NULL,
+ change_note VARCHAR(512) NULL,
+ immutable TINYINT(1) NOT NULL DEFAULT 0,
+ created_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_page_version (page_id, version_no),
+ CONSTRAINT fk_h5_page_version_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_page_version_content FOREIGN KEY (content_asset_id) REFERENCES h5_assets(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_page_version_bundle FOREIGN KEY (bundle_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL,
+ CONSTRAINT fk_h5_page_version_user FOREIGN KEY (created_by) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_security_scans (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ target_type ENUM('asset', 'page_version', 'publication_bundle') NOT NULL,
+ target_id CHAR(36) NOT NULL,
+ scanner_version VARCHAR(64) NOT NULL,
+ status ENUM('queued', 'running', 'passed', 'warned', 'blocked', 'failed') NOT NULL,
+ risk_level ENUM('none', 'low', 'medium', 'high', 'critical') NOT NULL DEFAULT 'none',
+ findings_count INT NOT NULL DEFAULT 0,
+ summary_json JSON NULL,
+ started_at BIGINT NOT NULL,
+ completed_at BIGINT NULL,
+ KEY idx_h5_security_scan_target (user_id, target_type, target_id, completed_at),
+ CONSTRAINT fk_h5_security_scan_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_security_findings (
+ id CHAR(36) PRIMARY KEY,
+ scan_id CHAR(36) NOT NULL,
+ finding_type VARCHAR(64) NOT NULL,
+ risk_level ENUM('low', 'medium', 'high', 'critical') NOT NULL,
+ occurrence_count INT NOT NULL DEFAULT 1,
+ sample_masked VARCHAR(255) NULL,
+ blocking TINYINT(1) NOT NULL DEFAULT 0,
+ created_at BIGINT NOT NULL,
+ KEY idx_h5_security_finding_scan (scan_id, risk_level),
+ CONSTRAINT fk_h5_security_finding_scan FOREIGN KEY (scan_id) REFERENCES h5_security_scans(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_publish_records (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ page_id CHAR(36) NOT NULL,
+ page_version_id CHAR(36) NOT NULL,
+ publish_type ENUM('page', 'share') NOT NULL DEFAULT 'page',
+ url_slug VARCHAR(64) NOT NULL,
+ public_url VARCHAR(512) NOT NULL,
+ access_mode ENUM('public', 'password', 'private_link', 'time_limited', 'login_required', 'owner_only') NOT NULL,
+ password_hash VARCHAR(255) NULL,
+ token_hash CHAR(64) NULL,
+ token_prefix VARCHAR(16) NULL,
+ expires_at BIGINT NULL,
+ published_at BIGINT NOT NULL,
+ offline_at BIGINT NULL,
+ status ENUM('draft', 'online', 'expired', 'offline', 'blocked') NOT NULL DEFAULT 'online',
+ view_count BIGINT NOT NULL DEFAULT 0,
+ security_scan_id CHAR(36) NOT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ KEY idx_h5_publish_route (url_slug, status, published_at),
+ KEY idx_h5_publish_page (user_id, page_id, published_at),
+ UNIQUE KEY uq_h5_publish_token_hash (token_hash),
+ CONSTRAINT fk_h5_publish_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_publish_page FOREIGN KEY (page_id) REFERENCES h5_page_records(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_publish_page_version FOREIGN KEY (page_version_id) REFERENCES h5_page_versions(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_publish_scan FOREIGN KEY (security_scan_id) REFERENCES h5_security_scans(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_publication_events (
+ id CHAR(36) PRIMARY KEY,
+ publish_id CHAR(36) NOT NULL,
+ event_type ENUM('published', 'republished', 'settings_changed', 'expired', 'offlined', 'blocked') NOT NULL,
+ actor_id CHAR(36) NOT NULL,
+ old_page_version_id CHAR(36) NULL,
+ new_page_version_id CHAR(36) NULL,
+ access_mode VARCHAR(32) NOT NULL,
+ detail_json JSON NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_h5_publication_event (publish_id, created_at),
+ CONSTRAINT fk_h5_publication_event_publish FOREIGN KEY (publish_id) REFERENCES h5_publish_records(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_publication_event_actor FOREIGN KEY (actor_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_publication_views (
+ id CHAR(36) PRIMARY KEY,
+ publish_id CHAR(36) NOT NULL,
+ viewer_user_id CHAR(36) NULL,
+ referrer_host VARCHAR(255) NULL,
+ device_type ENUM('desktop', 'mobile', 'tablet', 'bot', 'unknown') NOT NULL,
+ viewed_at BIGINT NOT NULL,
+ KEY idx_h5_publication_view_time (publish_id, viewed_at),
+ KEY idx_h5_publication_viewer (viewer_user_id, viewed_at),
+ CONSTRAINT fk_h5_publication_view_publish FOREIGN KEY (publish_id) REFERENCES h5_publish_records(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_publication_view_user FOREIGN KEY (viewer_user_id) REFERENCES h5_users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_agent_jobs (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ session_id VARCHAR(128) NULL,
+ job_type VARCHAR(64) NOT NULL,
+ instruction TEXT NOT NULL,
+ permission_scope JSON NULL,
+ user_context_json JSON NULL,
+ output_category_id CHAR(36) NOT NULL,
+ output_type VARCHAR(32) NOT NULL,
+ max_output_bytes BIGINT NOT NULL DEFAULT 2097152,
+ status ENUM('queued', 'running', 'completed', 'failed', 'cancelled', 'timed_out') NOT NULL DEFAULT 'queued',
+ idempotency_key VARCHAR(128) NULL,
+ progress_json JSON NULL,
+ job_token_hash CHAR(64) NULL,
+ result_page_id CHAR(36) NULL,
+ result_asset_id CHAR(36) NULL,
+ error_code VARCHAR(64) NULL,
+ error_message VARCHAR(1000) NULL,
+ queued_at BIGINT NOT NULL,
+ started_at BIGINT NULL,
+ heartbeat_at BIGINT NULL,
+ completed_at BIGINT NULL,
+ expires_at BIGINT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_agent_job_idempotency (user_id, idempotency_key),
+ KEY idx_h5_agent_job_user_status (user_id, status, queued_at),
+ KEY idx_h5_agent_job_token (job_token_hash),
+ CONSTRAINT fk_h5_agent_job_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_agent_job_output_category FOREIGN KEY (output_category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_agent_job_result_page FOREIGN KEY (result_page_id) REFERENCES h5_page_records(id) ON DELETE SET NULL,
+ CONSTRAINT fk_h5_agent_job_result_asset FOREIGN KEY (result_asset_id) REFERENCES h5_assets(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_agent_job_assets (
+ id CHAR(36) PRIMARY KEY,
+ job_id CHAR(36) NOT NULL,
+ asset_id CHAR(36) NOT NULL,
+ asset_version_id CHAR(36) NOT NULL,
+ permission ENUM('read', 'write', 'create_derivative') NOT NULL DEFAULT 'read',
+ created_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_agent_job_asset (job_id, asset_id, asset_version_id),
+ KEY idx_h5_agent_job_asset_job (job_id, created_at),
+ CONSTRAINT fk_h5_agent_job_asset_job FOREIGN KEY (job_id) REFERENCES h5_agent_jobs(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_agent_job_asset_asset FOREIGN KEY (asset_id) REFERENCES h5_assets(id) ON DELETE CASCADE,
+ CONSTRAINT fk_h5_agent_job_asset_version FOREIGN KEY (asset_version_id) REFERENCES h5_asset_versions(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_user_wallets (
+ user_id CHAR(36) PRIMARY KEY,
+ balance_cents BIGINT NOT NULL DEFAULT 0,
+ tokens_used BIGINT NOT NULL DEFAULT 0,
+ updated_at BIGINT NOT NULL,
+ CONSTRAINT fk_h5_wallet_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_user_path_grants (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ path VARCHAR(512) NOT NULL,
+ mode ENUM('read', 'readwrite') NOT NULL DEFAULT 'readwrite',
+ UNIQUE KEY uq_h5_user_path (user_id, path),
+ CONSTRAINT fk_h5_path_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_user_sessions (
+ agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_h5_user_sessions_user (user_id),
+ CONSTRAINT fk_h5_session_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_session_billing_state (
+ agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ last_accumulated_cost DOUBLE NULL,
+ last_input_tokens BIGINT NOT NULL DEFAULT 0,
+ last_output_tokens BIGINT NOT NULL DEFAULT 0,
+ updated_at BIGINT NOT NULL,
+ CONSTRAINT fk_h5_billing_state_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_usage_records (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ agent_session_id VARCHAR(128) NOT NULL,
+ request_id VARCHAR(128) NULL,
+ input_tokens INT NOT NULL DEFAULT 0,
+ output_tokens INT NOT NULL DEFAULT 0,
+ cost_cents BIGINT NOT NULL,
+ balance_after_cents BIGINT NOT NULL,
+ created_at BIGINT NOT NULL,
+ 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;
+
+CREATE TABLE IF NOT EXISTS h5_user_policies (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ subject_type ENUM('role', 'user') NOT NULL,
+ subject_id VARCHAR(64) NOT NULL,
+ policy_key VARCHAR(64) NOT NULL,
+ policy_value VARCHAR(512) NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_user_policy (subject_type, subject_id, policy_key),
+ KEY idx_h5_policy_subject (subject_type, subject_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_user_skill_grants (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ subject_type ENUM('role', 'user') NOT NULL,
+ subject_id VARCHAR(64) NOT NULL,
+ skill_name VARCHAR(64) NOT NULL,
+ enabled TINYINT(1) NOT NULL DEFAULT 0,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_user_skill_grant (subject_type, subject_id, skill_name),
+ KEY idx_h5_skill_subject (subject_type, subject_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_capability_grants (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ subject_type ENUM('role', 'user') NOT NULL,
+ subject_id VARCHAR(64) NOT NULL,
+ capability_key VARCHAR(64) NOT NULL,
+ allowed TINYINT(1) NOT NULL DEFAULT 0,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_capability_grant (subject_type, subject_id, capability_key),
+ KEY idx_h5_capability_subject (subject_type, subject_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_login_sessions (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ token_hash CHAR(64) NOT NULL,
+ expires_at BIGINT NOT NULL,
+ created_at BIGINT NOT NULL,
+ revoked_at BIGINT NULL,
+ UNIQUE KEY uq_h5_login_token_hash (token_hash),
+ KEY idx_h5_login_session_user (user_id),
+ KEY idx_h5_login_session_expires (expires_at),
+ CONSTRAINT fk_h5_login_session_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_mindspace_audit_logs (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ action VARCHAR(64) NOT NULL,
+ object_type VARCHAR(32) NOT NULL,
+ object_id CHAR(36) NOT NULL,
+ ip VARCHAR(64) NULL,
+ result VARCHAR(32) NOT NULL,
+ risk_level VARCHAR(16) NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_h5_mindspace_audit_user_time (user_id, created_at),
+ CONSTRAINT fk_h5_mindspace_audit_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_billing_ledger (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ type ENUM('recharge', 'deduct', 'refund', 'adjust') NOT NULL,
+ amount_cents BIGINT NOT NULL,
+ tokens BIGINT NOT NULL DEFAULT 0,
+ session_id VARCHAR(128) NULL,
+ note VARCHAR(512) NULL,
+ operator_id CHAR(36) NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_h5_ledger_user (user_id, created_at),
+ CONSTRAINT fk_h5_ledger_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_llm_provider_keys (
+ id CHAR(36) PRIMARY KEY,
+ provider_id VARCHAR(64) NOT NULL,
+ provider_kind ENUM('builtin', 'custom') NOT NULL DEFAULT 'builtin',
+ api_url VARCHAR(512) NULL,
+ base_path VARCHAR(256) NULL,
+ models_json TEXT NULL,
+ goosed_provider_id VARCHAR(64) NULL,
+ engine VARCHAR(32) NOT NULL DEFAULT 'openai',
+ relay_provider VARCHAR(64) NULL,
+ name VARCHAR(128) NOT NULL,
+ api_key_ciphertext TEXT NOT NULL,
+ api_key_iv VARCHAR(24) NOT NULL,
+ api_key_tag VARCHAR(24) NOT NULL,
+ default_model VARCHAR(128) NOT NULL,
+ status ENUM('active', 'disabled') NOT NULL DEFAULT 'active',
+ is_selected TINYINT(1) NOT NULL DEFAULT 0,
+ is_vision_selected TINYINT(1) NOT NULL DEFAULT 0,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_llm_key_name (name),
+ KEY idx_h5_llm_key_provider (provider_id),
+ KEY idx_h5_llm_key_selected (is_selected),
+ KEY idx_h5_llm_key_vision (is_vision_selected)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_llm_executor_bindings (
+ id CHAR(36) PRIMARY KEY,
+ executor ENUM('goose', 'aider', 'openhands') NOT NULL,
+ purpose VARCHAR(32) NOT NULL DEFAULT 'default',
+ provider_key_id CHAR(36) NULL,
+ model VARCHAR(128) NOT NULL,
+ enabled TINYINT(1) NOT NULL DEFAULT 1,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_llm_executor_binding (executor, purpose),
+ KEY idx_h5_llm_executor_provider (provider_key_id),
+ 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;
+
+CREATE TABLE IF NOT EXISTS h5_payment_orders (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ amount_cents BIGINT NOT NULL,
+ channel ENUM('wechat') NOT NULL DEFAULT 'wechat',
+ status ENUM('pending', 'paid', 'failed', 'expired', 'refunded') NOT NULL DEFAULT 'pending',
+ pay_mode ENUM('native', 'h5', 'jsapi') NOT NULL DEFAULT 'native',
+ out_trade_no VARCHAR(64) NOT NULL,
+ provider_txn VARCHAR(128) NULL,
+ code_url VARCHAR(512) NULL,
+ h5_url VARCHAR(1024) NULL,
+ client_ip VARCHAR(64) NULL,
+ expire_at BIGINT NOT NULL,
+ paid_at BIGINT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_payment_out_trade_no (out_trade_no),
+ KEY idx_h5_payment_user_time (user_id, created_at),
+ KEY idx_h5_payment_status_expire (status, expire_at),
+ CONSTRAINT fk_h5_payment_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS plaza_categories (
+ id CHAR(36) PRIMARY KEY,
+ name VARCHAR(50) NOT NULL,
+ slug VARCHAR(50) NOT NULL,
+ icon VARCHAR(10) NOT NULL DEFAULT '',
+ description VARCHAR(200) NOT NULL DEFAULT '',
+ sort_order INT NOT NULL DEFAULT 0,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ created_at BIGINT NOT NULL,
+ UNIQUE KEY uq_plaza_category_slug (slug),
+ KEY idx_plaza_category_sort (is_active, sort_order)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS plaza_posts (
+ id CHAR(36) PRIMARY KEY,
+ publication_id CHAR(36) NOT NULL,
+ user_id CHAR(36) NOT NULL,
+ title VARCHAR(200) NOT NULL,
+ summary VARCHAR(500) NOT NULL DEFAULT '',
+ cover_url VARCHAR(500) NOT NULL DEFAULT '',
+ user_slug VARCHAR(100) NOT NULL,
+ user_display_name VARCHAR(100) NOT NULL,
+ user_avatar_url VARCHAR(500) NOT NULL DEFAULT '',
+ category_id CHAR(36) NOT NULL,
+ tags JSON NOT NULL,
+ status ENUM('pending_review', 'published', 'hidden', 'rejected') NOT NULL DEFAULT 'pending_review',
+ view_count INT UNSIGNED NOT NULL DEFAULT 0,
+ like_count INT UNSIGNED NOT NULL DEFAULT 0,
+ collect_count INT UNSIGNED NOT NULL DEFAULT 0,
+ comment_count INT UNSIGNED NOT NULL DEFAULT 0,
+ share_count INT UNSIGNED NOT NULL DEFAULT 0,
+ hot_score DOUBLE NOT NULL DEFAULT 0,
+ hot_updated_at BIGINT NULL,
+ allow_comment TINYINT(1) NOT NULL DEFAULT 1,
+ published_at BIGINT NOT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_plaza_post_publication (publication_id),
+ KEY idx_plaza_post_user_status (user_id, status),
+ KEY idx_plaza_post_category_hot (category_id, status, hot_score),
+ KEY idx_plaza_post_published_at (published_at),
+ KEY idx_plaza_post_status_hot (status, hot_score),
+ CONSTRAINT fk_plaza_post_publication FOREIGN KEY (publication_id) REFERENCES h5_publish_records(id) ON DELETE CASCADE,
+ CONSTRAINT fk_plaza_post_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_plaza_post_category FOREIGN KEY (category_id) REFERENCES plaza_categories(id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS plaza_reactions (
+ id CHAR(36) PRIMARY KEY,
+ post_id CHAR(36) NOT NULL,
+ user_id CHAR(36) NOT NULL,
+ type ENUM('like', 'collect', 'share') NOT NULL,
+ created_at BIGINT NOT NULL,
+ UNIQUE KEY uq_plaza_reaction (post_id, user_id, type),
+ KEY idx_plaza_reaction_post (post_id, type),
+ KEY idx_plaza_reaction_user (user_id, type),
+ CONSTRAINT fk_plaza_reaction_post FOREIGN KEY (post_id) REFERENCES plaza_posts(id) ON DELETE CASCADE,
+ CONSTRAINT fk_plaza_reaction_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS plaza_comments (
+ id CHAR(36) PRIMARY KEY,
+ post_id CHAR(36) NOT NULL,
+ user_id CHAR(36) NOT NULL,
+ parent_id CHAR(36) NULL,
+ content VARCHAR(500) NOT NULL,
+ status ENUM('visible', 'deleted', 'flagged', 'hidden') NOT NULL DEFAULT 'visible',
+ like_count INT UNSIGNED NOT NULL DEFAULT 0,
+ reply_count INT UNSIGNED NOT NULL DEFAULT 0,
+ deleted_by ENUM('user', 'ops') NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ KEY idx_plaza_comment_post (post_id, status, created_at),
+ KEY idx_plaza_comment_parent (parent_id),
+ KEY idx_plaza_comment_user (user_id, created_at),
+ CONSTRAINT fk_plaza_comment_post FOREIGN KEY (post_id) REFERENCES plaza_posts(id) ON DELETE CASCADE,
+ CONSTRAINT fk_plaza_comment_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS plaza_comment_reactions (
+ id CHAR(36) PRIMARY KEY,
+ comment_id CHAR(36) NOT NULL,
+ user_id CHAR(36) NOT NULL,
+ created_at BIGINT NOT NULL,
+ UNIQUE KEY uq_plaza_comment_reaction (comment_id, user_id),
+ KEY idx_plaza_comment_reaction_comment (comment_id),
+ CONSTRAINT fk_plaza_comment_reaction_comment FOREIGN KEY (comment_id) REFERENCES plaza_comments(id) ON DELETE CASCADE,
+ CONSTRAINT fk_plaza_comment_reaction_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS plaza_follows (
+ follower_id CHAR(36) NOT NULL,
+ followee_id CHAR(36) NOT NULL,
+ created_at BIGINT NOT NULL,
+ PRIMARY KEY (follower_id, followee_id),
+ KEY idx_plaza_follow_followee (followee_id, created_at),
+ CONSTRAINT fk_plaza_follow_follower FOREIGN KEY (follower_id) REFERENCES h5_users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_plaza_follow_followee FOREIGN KEY (followee_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS plaza_algorithm_config (
+ `key` VARCHAR(100) NOT NULL,
+ value DOUBLE NOT NULL,
+ description VARCHAR(200) NOT NULL DEFAULT '',
+ updated_at BIGINT NOT NULL,
+ PRIMARY KEY (`key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS plaza_attribution_events (
+ id CHAR(36) PRIMARY KEY,
+ event_type ENUM('landing', 'signup') NOT NULL,
+ utm_source VARCHAR(100) NOT NULL,
+ utm_medium VARCHAR(100) NOT NULL DEFAULT '',
+ utm_campaign VARCHAR(100) NOT NULL DEFAULT '',
+ ref_id VARCHAR(200) NOT NULL DEFAULT '',
+ user_id CHAR(36) NULL,
+ ip_hash VARCHAR(64) NOT NULL DEFAULT '',
+ created_at BIGINT NOT NULL,
+ KEY idx_plaza_attr_created (created_at),
+ KEY idx_plaza_attr_campaign (utm_source, utm_campaign, created_at),
+ CONSTRAINT fk_plaza_attr_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS plaza_reports (
+ id CHAR(36) PRIMARY KEY,
+ target_type ENUM('post', 'comment') NOT NULL,
+ target_id CHAR(36) NOT NULL,
+ reporter_id CHAR(36) NOT NULL,
+ reason ENUM('spam', 'violence', 'porn', 'political', 'privacy', 'other') NOT NULL,
+ detail VARCHAR(500) NOT NULL DEFAULT '',
+ status ENUM('pending', 'processed', 'dismissed') NOT NULL DEFAULT 'pending',
+ processed_by CHAR(36) NULL,
+ processed_at BIGINT NULL,
+ action_taken VARCHAR(200) NOT NULL DEFAULT '',
+ created_at BIGINT NOT NULL,
+ KEY idx_plaza_report_status (status, target_type, created_at),
+ KEY idx_plaza_report_target (target_type, target_id),
+ CONSTRAINT fk_plaza_report_reporter FOREIGN KEY (reporter_id) REFERENCES h5_users(id) ON DELETE CASCADE,
+ CONSTRAINT fk_plaza_report_processor FOREIGN KEY (processed_by) REFERENCES h5_users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+-- Session snapshot cache: stores userVisible messages for fast history reload.
+-- Rollback: DROP TABLE h5_session_snapshots (data-only, no dependants).
+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 '',
+ working_dir VARCHAR(1024) NOT NULL DEFAULT '',
+ created_at_str VARCHAR(64) NOT NULL DEFAULT '',
+ updated_at_str VARCHAR(64) NOT NULL DEFAULT '',
+ user_set_name TINYINT(1) NOT NULL DEFAULT 0,
+ recipe_json TEXT NULL,
+ synced_msg_count INT NOT NULL DEFAULT 0,
+ source_updated_at VARCHAR(64) NOT NULL DEFAULT '',
+ messages_json LONGTEXT NOT NULL,
+ synced_at BIGINT NOT NULL,
+ KEY idx_h5_snapshots_user (user_id),
+ KEY idx_h5_snapshots_synced (synced_at),
+ 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;
+
+CREATE TABLE IF NOT EXISTS plaza_featured (
+ id CHAR(36) PRIMARY KEY,
+ post_id CHAR(36) NOT NULL,
+ position VARCHAR(50) NOT NULL,
+ sort_order INT NOT NULL DEFAULT 0,
+ starts_at BIGINT NOT NULL,
+ expires_at BIGINT NULL,
+ created_by CHAR(36) NOT NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_plaza_featured_position (position, starts_at, expires_at),
+ CONSTRAINT fk_plaza_featured_post FOREIGN KEY (post_id) REFERENCES plaza_posts(id) ON DELETE CASCADE,
+ CONSTRAINT fk_plaza_featured_creator FOREIGN KEY (created_by) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS plaza_user_events (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NULL,
+ session_id VARCHAR(64) NOT NULL,
+ post_id CHAR(36) NOT NULL,
+ event_type ENUM(
+ 'impression', 'click', 'view', 'dwell',
+ 'like', 'collect', 'comment', 'share',
+ 'dislike', 'hide'
+ ) NOT NULL,
+ dwell_ms INT UNSIGNED NULL,
+ feed_sort VARCHAR(32) NOT NULL DEFAULT '',
+ feed_category VARCHAR(64) NOT NULL DEFAULT '',
+ position INT UNSIGNED NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_plaza_event_user_time (user_id, created_at),
+ KEY idx_plaza_event_session_time (session_id, created_at),
+ KEY idx_plaza_event_post (post_id, event_type, created_at),
+ CONSTRAINT fk_plaza_event_post FOREIGN KEY (post_id) REFERENCES plaza_posts(id) ON DELETE CASCADE,
+ CONSTRAINT fk_plaza_event_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE SET NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS ops_audit_log (
+ id CHAR(36) PRIMARY KEY,
+ operator_id CHAR(36) NOT NULL,
+ action VARCHAR(100) NOT NULL,
+ target_type VARCHAR(50) NOT NULL,
+ target_id CHAR(36) NOT NULL,
+ detail JSON NOT NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_ops_audit_operator (operator_id, created_at),
+ KEY idx_ops_audit_target (target_type, target_id, created_at),
+ CONSTRAINT fk_ops_audit_operator FOREIGN KEY (operator_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_user_wechat_identities (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ app_id VARCHAR(32) NOT NULL,
+ openid VARCHAR(64) NOT NULL,
+ unionid VARCHAR(64) NULL,
+ nickname VARCHAR(128) NULL,
+ avatar_url VARCHAR(512) NULL,
+ last_login_at BIGINT NOT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_wechat_openid (app_id, openid),
+ UNIQUE KEY uq_wechat_user (user_id, app_id),
+ KEY idx_wechat_unionid (unionid),
+ CONSTRAINT fk_wechat_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_wechat_oauth_states (
+ state VARCHAR(64) PRIMARY KEY,
+ return_to VARCHAR(512) NULL,
+ utm_source VARCHAR(64) NULL,
+ utm_medium VARCHAR(64) NULL,
+ utm_campaign VARCHAR(64) NULL,
+ intent VARCHAR(16) NOT NULL DEFAULT 'login',
+ bind_user_id CHAR(36) NULL,
+ auth_mode VARCHAR(8) NOT NULL DEFAULT 'mp',
+ status VARCHAR(16) NOT NULL DEFAULT 'pending',
+ result_kind VARCHAR(32) NULL,
+ result_token VARCHAR(512) NULL,
+ result_message VARCHAR(255) NULL,
+ expires_at BIGINT NOT NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_wechat_oauth_state_expires (expires_at),
+ KEY idx_wechat_oauth_state_status (status, expires_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_wechat_pending_binds (
+ token VARCHAR(64) PRIMARY KEY,
+ app_id VARCHAR(32) NOT NULL,
+ openid VARCHAR(64) NOT NULL,
+ unionid VARCHAR(64) NULL,
+ nickname VARCHAR(128) NULL,
+ avatar_url VARCHAR(512) NULL,
+ return_to VARCHAR(512) NULL,
+ utm_source VARCHAR(64) NULL,
+ utm_medium VARCHAR(64) NULL,
+ utm_campaign VARCHAR(64) NULL,
+ expires_at BIGINT NOT NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_wechat_pending_expires (expires_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_wechat_agent_routes (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ app_id VARCHAR(32) NOT NULL,
+ openid VARCHAR(64) NOT NULL,
+ agent_session_id VARCHAR(128) NOT NULL,
+ status ENUM('active', 'disabled') NOT NULL DEFAULT 'active',
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_wechat_agent_route (app_id, openid),
+ KEY idx_wechat_agent_route_user (user_id, updated_at),
+ CONSTRAINT fk_wechat_agent_route_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_wechat_mp_messages (
+ app_id VARCHAR(32) NOT NULL,
+ openid VARCHAR(64) NOT NULL,
+ msg_id VARCHAR(128) NOT NULL,
+ status ENUM('processing', 'done', 'failed') NOT NULL DEFAULT 'processing',
+ agent_session_id VARCHAR(128) NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ PRIMARY KEY (app_id, openid, msg_id),
+ KEY idx_wechat_mp_messages_updated (updated_at),
+ KEY idx_wechat_mp_messages_session (agent_session_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_wechat_mp_message_details (
+ id CHAR(36) PRIMARY KEY,
+ app_id VARCHAR(32) NOT NULL,
+ openid VARCHAR(64) NOT NULL,
+ user_id CHAR(36) NULL,
+ msg_id VARCHAR(128) NULL,
+ msg_type VARCHAR(32) NOT NULL,
+ display_text TEXT NULL,
+ agent_text TEXT NULL,
+ media_id VARCHAR(256) NULL,
+ media_url TEXT NULL,
+ media_public_url TEXT NULL,
+ media_format VARCHAR(64) NULL,
+ location_lat DECIMAL(10,7) NULL,
+ location_lng DECIMAL(10,7) NULL,
+ location_label VARCHAR(255) NULL,
+ link_url TEXT NULL,
+ link_title VARCHAR(255) NULL,
+ raw_xml_hash CHAR(40) NULL,
+ raw_json JSON NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_wechat_mp_message_details_user (app_id, openid, created_at),
+ KEY idx_wechat_mp_message_details_msg (msg_id),
+ KEY idx_wechat_mp_message_details_type (msg_type, created_at),
+ 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;
+
+CREATE TABLE IF NOT EXISTS h5_schedule_items (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ kind ENUM('task', 'event') NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ description TEXT NULL,
+ status ENUM('active', 'completed', 'cancelled', 'deleted') NOT NULL DEFAULT 'active',
+ start_at BIGINT NULL,
+ end_at BIGINT NULL,
+ due_at BIGINT NULL,
+ all_day TINYINT(1) NOT NULL DEFAULT 0,
+ timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai',
+ location VARCHAR(255) NULL,
+ source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent',
+ source_session_id VARCHAR(128) NULL,
+ source_message_id VARCHAR(128) NULL,
+ source_text TEXT NULL,
+ metadata_json JSON NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ deleted_at BIGINT NULL,
+ KEY idx_schedule_user_time (user_id, status, start_at, due_at),
+ KEY idx_schedule_user_updated (user_id, updated_at),
+ 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;
+
+CREATE TABLE IF NOT EXISTS h5_schedule_reminders (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ item_id CHAR(36) NOT NULL,
+ remind_at BIGINT NOT NULL,
+ offset_minutes INT NULL,
+ channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat',
+ status ENUM('pending', 'locked', 'sent', 'failed', 'cancelled') NOT NULL DEFAULT 'pending',
+ attempts INT NOT NULL DEFAULT 0,
+ last_error VARCHAR(500) NULL,
+ locked_until BIGINT NULL,
+ sent_at BIGINT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_schedule_item_remind_at (item_id, remind_at, channel),
+ KEY idx_reminder_due (status, remind_at),
+ KEY idx_reminder_user (user_id, status, remind_at),
+ CONSTRAINT fk_schedule_reminder_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
+ 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;
+
+CREATE TABLE IF NOT EXISTS h5_schedule_digest_subscriptions (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ digest_type ENUM('todo_day') NOT NULL DEFAULT 'todo_day',
+ hour TINYINT UNSIGNED NOT NULL,
+ minute TINYINT UNSIGNED NOT NULL DEFAULT 0,
+ timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai',
+ channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat',
+ status ENUM('active', 'locked', 'failed', 'cancelled') NOT NULL DEFAULT 'active',
+ next_run_at BIGINT NOT NULL,
+ last_run_at BIGINT NULL,
+ attempts INT NOT NULL DEFAULT 0,
+ locked_until BIGINT NULL,
+ last_error VARCHAR(500) NULL,
+ source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent',
+ source_session_id VARCHAR(128) NULL,
+ source_message_id VARCHAR(128) NULL,
+ source_text TEXT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_schedule_digest_user_type_channel (user_id, digest_type, channel),
+ KEY idx_schedule_digest_due (status, next_run_at),
+ 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;
+
+CREATE TABLE IF NOT EXISTS h5_balance_alert_subscriptions (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ threshold_cents BIGINT NOT NULL,
+ channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat',
+ status ENUM('active', 'locked', 'failed', 'cancelled') NOT NULL DEFAULT 'active',
+ next_run_at BIGINT NOT NULL,
+ last_run_at BIGINT NULL,
+ last_notified_balance_cents BIGINT NULL,
+ attempts INT NOT NULL DEFAULT 0,
+ locked_until BIGINT NULL,
+ last_error VARCHAR(500) NULL,
+ source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent',
+ source_session_id VARCHAR(128) NULL,
+ source_message_id VARCHAR(128) NULL,
+ source_text TEXT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_balance_alert_user_channel (user_id, channel),
+ KEY idx_balance_alert_due (status, next_run_at),
+ KEY idx_balance_alert_user (user_id, status, next_run_at),
+ 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;
+
+CREATE TABLE IF NOT EXISTS h5_schedule_delivery_logs (
+ id CHAR(36) PRIMARY KEY,
+ reminder_id CHAR(36) NULL,
+ subscription_id CHAR(36) NULL,
+ user_id CHAR(36) NOT NULL,
+ channel ENUM('wechat', 'in_app') NOT NULL,
+ status ENUM('success', 'failed') NOT NULL,
+ provider_message_id VARCHAR(128) NULL,
+ error_code VARCHAR(64) NULL,
+ error_message VARCHAR(500) NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_delivery_reminder (reminder_id, created_at),
+ KEY idx_delivery_subscription (subscription_id, created_at),
+ KEY idx_delivery_user (user_id, created_at),
+ CONSTRAINT fk_schedule_delivery_reminder FOREIGN KEY (reminder_id) REFERENCES h5_schedule_reminders(id) ON DELETE CASCADE,
+ CONSTRAINT fk_schedule_delivery_subscription FOREIGN KEY (subscription_id) REFERENCES h5_schedule_digest_subscriptions(id) ON DELETE CASCADE,
+ 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;
+
+CREATE TABLE IF NOT EXISTS h5_user_notifications (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ channel ENUM('web', 'wechat') NOT NULL DEFAULT 'web',
+ notification_type VARCHAR(64) NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ body TEXT NOT NULL,
+ data_json JSON NULL,
+ status ENUM('unread', 'read') NOT NULL DEFAULT 'unread',
+ read_at BIGINT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ KEY idx_user_notifications_user_status_created (user_id, status, created_at),
+ KEY idx_user_notifications_user_created (user_id, created_at),
+ 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;
+
+CREATE TABLE IF NOT EXISTS h5_plan_catalog (
+ plan_type VARCHAR(32) PRIMARY KEY,
+ name VARCHAR(64) NOT NULL,
+ price_cents INT NOT NULL DEFAULT 0,
+ period_days INT NOT NULL DEFAULT 30,
+ period_tokens BIGINT NOT NULL DEFAULT 0,
+ period_images INT NOT NULL DEFAULT 0,
+ model_tier VARCHAR(32) NOT NULL DEFAULT 'basic',
+ overage_rate DECIMAL(4,2) NOT NULL DEFAULT 1.00,
+ sort_order INT NOT NULL DEFAULT 0,
+ is_active TINYINT(1) NOT NULL DEFAULT 1,
+ description VARCHAR(512) NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE TABLE IF NOT EXISTS h5_subscriptions (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ plan_type VARCHAR(32) NOT NULL DEFAULT 'free',
+ status ENUM('active', 'expired', 'cancelled') NOT NULL DEFAULT 'active',
+ period_tokens_limit BIGINT NOT NULL DEFAULT 0,
+ period_tokens_used BIGINT NOT NULL DEFAULT 0,
+ period_images_limit INT NOT NULL DEFAULT 0,
+ period_images_used INT NOT NULL DEFAULT 0,
+ period_start BIGINT NOT NULL,
+ period_end BIGINT NOT NULL,
+ expires_at BIGINT NOT NULL,
+ overage_rate DECIMAL(4,2) NOT NULL DEFAULT 1.00,
+ auto_renew TINYINT(1) NOT NULL DEFAULT 0,
+ operator_id CHAR(36) NULL,
+ note VARCHAR(512) NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ KEY idx_h5_sub_user_status (user_id, status),
+ KEY idx_h5_sub_expires (expires_at, status),
+ 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;
+
+CREATE TABLE IF NOT EXISTS h5_blocked_words (
+ id CHAR(36) PRIMARY KEY,
+ word VARCHAR(200) NOT NULL,
+ replacement VARCHAR(200) NOT NULL DEFAULT '***',
+ note VARCHAR(500) NULL,
+ status ENUM('active', 'disabled') NOT NULL DEFAULT 'active',
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uk_blocked_word (word)
+) 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
new file mode 100755
index 0000000..9ee3f83
--- /dev/null
+++ b/.runtime/portal/scripts/run-memind-portal-prod.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+if [[ -f "${ROOT}/.env" ]]; then
+ set -a
+ # shellcheck disable=SC1091
+ source "${ROOT}/.env"
+ set +a
+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_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}"
+
+NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@24/bin/node}"
+if [[ ! -x "${NODE_BIN}" ]]; then
+ NODE_BIN="$(command -v node)"
+fi
+
+exec "${NODE_BIN}" "${ROOT}/server.mjs"
diff --git a/.runtime/portal/server.mjs b/.runtime/portal/server.mjs
new file mode 100644
index 0000000..b5adb9a
--- /dev/null
+++ b/.runtime/portal/server.mjs
@@ -0,0 +1,26897 @@
+import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);
+
+// server.mjs
+import express2 from "express";
+import crypto29 from "node:crypto";
+import fs23 from "node:fs";
+import { createProxyMiddleware } from "http-proxy-middleware";
+import path22 from "node:path";
+import { fileURLToPath as fileURLToPath6 } from "node:url";
+
+// auth.mjs
+import crypto from "node:crypto";
+var AUTH_COOKIE = "tkmind_h5_session";
+function parseCookies(header = "") {
+ return Object.fromEntries(
+ header.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
+ const separator = part.indexOf("=");
+ if (separator < 0) return [part, ""];
+ return [
+ decodeURIComponent(part.slice(0, separator)),
+ decodeURIComponent(part.slice(separator + 1))
+ ];
+ })
+ );
+}
+function safeEqual(left, right) {
+ const leftBuffer = Buffer.from(left);
+ const rightBuffer = Buffer.from(right);
+ return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer);
+}
+function createAuthManager({
+ password,
+ ttlMs = 7 * 24 * 60 * 60 * 1e3,
+ maxFailures = 5,
+ failureWindowMs = 5 * 60 * 1e3
+}) {
+ if (!password) {
+ throw new Error("H5_ACCESS_PASSWORD is required");
+ }
+ const sessions = /* @__PURE__ */ new Map();
+ const failures = /* @__PURE__ */ new Map();
+ const prune = (now = Date.now()) => {
+ for (const [token, expiresAt] of sessions) {
+ if (expiresAt <= now) sessions.delete(token);
+ }
+ for (const [ip, state] of failures) {
+ if (state.resetAt <= now) failures.delete(ip);
+ }
+ };
+ const login = (candidate, ip = "unknown", now = Date.now()) => {
+ prune(now);
+ const failure = failures.get(ip);
+ if (failure && failure.count >= maxFailures && failure.resetAt > now) {
+ return { ok: false, retryAfterMs: failure.resetAt - now };
+ }
+ if (!safeEqual(candidate, password)) {
+ const current = failure && failure.resetAt > now ? failure : { count: 0, resetAt: now + failureWindowMs };
+ current.count += 1;
+ failures.set(ip, current);
+ return { ok: false, retryAfterMs: 0 };
+ }
+ failures.delete(ip);
+ const token = crypto.randomBytes(32).toString("base64url");
+ sessions.set(token, now + ttlMs);
+ return { ok: true, token };
+ };
+ const verify = (token, now = Date.now()) => {
+ if (!token) return false;
+ prune(now);
+ const expiresAt = sessions.get(token);
+ if (!expiresAt || expiresAt <= now) {
+ sessions.delete(token);
+ return false;
+ }
+ sessions.set(token, now + ttlMs);
+ return true;
+ };
+ const revoke = (token) => {
+ if (token) sessions.delete(token);
+ };
+ return { login, verify, revoke };
+}
+function sessionCookie(token, secure) {
+ const parts = [
+ `${AUTH_COOKIE}=${encodeURIComponent(token)}`,
+ "Path=/",
+ "HttpOnly",
+ "SameSite=Lax",
+ "Max-Age=604800"
+ ];
+ if (secure) parts.push("Secure");
+ return parts.join("; ");
+}
+function clearSessionCookie(secure) {
+ const parts = [
+ `${AUTH_COOKIE}=`,
+ "Path=/",
+ "HttpOnly",
+ "SameSite=Lax",
+ "Max-Age=0"
+ ];
+ if (secure) parts.push("Secure");
+ return parts.join("; ");
+}
+
+// db.mjs
+import fs2 from "node:fs";
+import mysql from "mysql2/promise";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+// mindspace.mjs
+import crypto2 from "node:crypto";
+
+// mindspace-config.mjs
+var PUBLIC_PAGE_LIMIT_KEY = "public_page_limit";
+function asPositiveInteger(value, fallback) {
+ const parsed = Number(value);
+ if (!Number.isFinite(parsed) || parsed < 1) return fallback;
+ return Math.floor(parsed);
+}
+async function ensureConfigTable(pool) {
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS mindspace_config (
+ \`key\` VARCHAR(64) PRIMARY KEY,
+ value TEXT NOT NULL,
+ description VARCHAR(255) NULL,
+ updated_at BIGINT NOT NULL
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ `);
+}
+function defaultMindSpaceConfig(env = process.env) {
+ return {
+ publicPageLimit: asPositiveInteger(env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5, 5)
+ };
+}
+async function readConfigRows(pool) {
+ const [rows] = await pool.query(
+ "SELECT `key`, value FROM mindspace_config"
+ );
+ return rows;
+}
+async function ensureMindSpaceConfig(pool, { env = process.env, seedDefault = true } = {}) {
+ await ensureConfigTable(pool);
+ if (!seedDefault) return;
+ const [rows] = await pool.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(
+ `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]
+ );
+}
+async function loadMindSpaceConfig(pool, { env = process.env } = {}) {
+ const config = defaultMindSpaceConfig(env);
+ try {
+ const rows = await readConfigRows(pool);
+ for (const row of rows) {
+ if (row.key === PUBLIC_PAGE_LIMIT_KEY) {
+ config.publicPageLimit = asPositiveInteger(row.value, config.publicPageLimit);
+ }
+ }
+ } catch (error) {
+ if (error?.code === "ER_NO_SUCH_TABLE") return config;
+ throw error;
+ }
+ return config;
+}
+
+// mindspace.mjs
+var DEFAULT_SPACE_QUOTA_BYTES = 5 * 1024 * 1024;
+var DEFAULT_MAX_FILE_BYTES = 5 * 1024 * 1024;
+var SYSTEM_CATEGORIES = Object.freeze([
+ {
+ code: "oa",
+ name: "OA \u5DE5\u4F5C\u533A",
+ visibilityPolicy: "private",
+ aiAccessPolicy: "selected_assets",
+ 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
+ },
+ {
+ code: "draft",
+ name: "\u9875\u9762\u8349\u7A3F",
+ visibilityPolicy: "private",
+ aiAccessPolicy: "job_output",
+ publishPolicy: "security_scan_required",
+ sortOrder: 40
+ },
+ {
+ code: "archive",
+ name: "\u5F52\u6863\u533A",
+ visibilityPolicy: "private",
+ aiAccessPolicy: "none",
+ publishPolicy: "forbidden",
+ sortOrder: 50
+ }
+]);
+function asNumber(value) {
+ return Number(value ?? 0);
+}
+function categoryResponse(row) {
+ return {
+ id: row.id,
+ code: row.category_code,
+ name: row.category_name,
+ visibilityPolicy: row.visibility_policy,
+ aiAccessPolicy: row.ai_access_policy,
+ publishPolicy: row.publish_policy,
+ isSystem: Boolean(row.is_system),
+ sortOrder: asNumber(row.sort_order),
+ itemCount: asNumber(row.item_count)
+ };
+}
+async function initializeDefaultSpace(db, userId, {
+ quotaBytes = DEFAULT_SPACE_QUOTA_BYTES,
+ spaceName = "\u6211\u7684\u7A7A\u95F4",
+ now = Date.now(),
+ idFactory = () => crypto2.randomUUID()
+} = {}) {
+ const spaceId = idFactory();
+ await db.query(
+ `INSERT INTO h5_user_spaces
+ (id, user_id, space_name, quota_bytes, used_bytes, reserved_bytes, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 0, 0, 'active', ?, ?)
+ ON DUPLICATE KEY UPDATE user_id = user_id`,
+ [spaceId, userId, spaceName, quotaBytes, now, now]
+ );
+ const [spaces] = await db.query(
+ `SELECT id FROM h5_user_spaces WHERE user_id = ? LIMIT 1`,
+ [userId]
+ );
+ const resolvedSpaceId = spaces[0]?.id;
+ if (!resolvedSpaceId) {
+ throw new Error("\u7528\u6237\u7A7A\u95F4\u521D\u59CB\u5316\u5931\u8D25");
+ }
+ for (const category of SYSTEM_CATEGORIES) {
+ await db.query(
+ `INSERT INTO h5_space_categories
+ (id, user_id, space_id, category_code, category_name, visibility_policy,
+ ai_access_policy, publish_policy, is_system, sort_order, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ category_name = VALUES(category_name),
+ visibility_policy = VALUES(visibility_policy),
+ ai_access_policy = VALUES(ai_access_policy),
+ publish_policy = VALUES(publish_policy),
+ is_system = 1,
+ sort_order = VALUES(sort_order),
+ updated_at = VALUES(updated_at)`,
+ [
+ idFactory(),
+ userId,
+ resolvedSpaceId,
+ category.code,
+ category.name,
+ category.visibilityPolicy,
+ category.aiAccessPolicy,
+ category.publishPolicy,
+ category.sortOrder,
+ now,
+ now
+ ]
+ );
+ }
+ return resolvedSpaceId;
+}
+async function ensureDefaultSpaces(pool, options = {}) {
+ const [users] = await pool.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);
+ }
+ return users.length;
+}
+function createMindSpaceService(pool, options = {}) {
+ const maxFileBytes = Number(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES);
+ const aiDailyLimit = Number(options.aiDailyLimit ?? 10);
+ const publicPageLimitFallback = Number(options.publicPageLimit ?? 5);
+ const monthlyViewLimit = Number(options.monthlyViewLimit ?? 1e3);
+ const scheduleService2 = options.scheduleService ?? null;
+ const resolvePublicPageLimit = async () => {
+ try {
+ const config = await loadMindSpaceConfig(pool);
+ return Number(config.publicPageLimit ?? publicPageLimitFallback);
+ } catch {
+ return publicPageLimitFallback;
+ }
+ };
+ const getSpace = async (userId) => {
+ const [spaces] = await pool.query(
+ `SELECT id, user_id, space_name, quota_bytes, used_bytes, reserved_bytes, status,
+ created_at, updated_at
+ FROM h5_user_spaces
+ WHERE user_id = ?
+ LIMIT 1`,
+ [userId]
+ );
+ const row = spaces[0];
+ if (!row) return null;
+ const [categories] = await pool.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
+ WHEN c.category_code = 'draft' THEN COUNT(DISTINCT p.id)
+ WHEN c.category_code = 'public' THEN COUNT(DISTINCT pr.page_id)
+ ELSE COUNT(DISTINCT a.id)
+ END AS item_count
+ FROM h5_space_categories c
+ LEFT JOIN h5_assets a
+ ON a.category_id = c.id
+ AND a.user_id = c.user_id
+ AND a.status <> 'deleted'
+ 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
+ ON pr.user_id = c.user_id AND pr.status = 'online'
+ WHERE c.user_id = ? AND c.space_id = ?
+ GROUP BY c.id, c.category_code, c.category_name, c.visibility_policy,
+ c.ai_access_policy, c.publish_policy, c.is_system, c.sort_order
+ ORDER BY c.sort_order, c.category_name`,
+ [userId, row.id]
+ );
+ const quotaBytes = asNumber(row.quota_bytes);
+ const usedBytes = asNumber(row.used_bytes);
+ const reservedBytes = asNumber(row.reserved_bytes);
+ const [publicationUsage] = await pool.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 = ?`,
+ [Date.now() - 30 * 24 * 60 * 60 * 1e3, userId]
+ );
+ const publicPageLimit = await resolvePublicPageLimit();
+ let schedule = null;
+ if (scheduleService2) {
+ try {
+ const [todayTodoItems, digestSubscriptions] = await Promise.all([
+ typeof scheduleService2.listTodayTodoItems === "function" ? scheduleService2.listTodayTodoItems({ userId }) : Promise.resolve([]),
+ typeof scheduleService2.listDigestSubscriptions === "function" ? scheduleService2.listDigestSubscriptions({
+ userId,
+ digestType: "todo_day",
+ status: ["active", "locked"],
+ limit: 10
+ }) : Promise.resolve([])
+ ]);
+ schedule = { todayTodoItems, digestSubscriptions };
+ } catch {
+ schedule = null;
+ }
+ }
+ return {
+ id: row.id,
+ userId: row.user_id,
+ name: row.space_name,
+ status: row.status,
+ quota: {
+ quotaBytes,
+ usedBytes,
+ reservedBytes,
+ availableBytes: Math.max(0, quotaBytes - usedBytes - reservedBytes),
+ maxFileBytes,
+ publicPageLimit,
+ publicPageUsed: asNumber(publicationUsage[0]?.public_page_used),
+ aiDailyLimit,
+ aiDailyUsed: 0,
+ monthlyViewLimit,
+ monthlyViewUsed: asNumber(publicationUsage[0]?.monthly_view_used)
+ },
+ categories: categories.map(categoryResponse),
+ createdAt: asNumber(row.created_at),
+ updatedAt: asNumber(row.updated_at),
+ schedule
+ };
+ };
+ const getQuota = async (userId) => {
+ const space = await getSpace(userId);
+ return space?.quota ?? null;
+ };
+ const listCategories = async (userId) => {
+ const space = await getSpace(userId);
+ return space?.categories ?? null;
+ };
+ return {
+ getSpace,
+ getQuota,
+ listCategories
+ };
+}
+
+// db.mjs
+var __dirname = path.dirname(fileURLToPath(import.meta.url));
+function isDatabaseConfigured() {
+ return Boolean(
+ process.env.DATABASE_URL || process.env.MYSQL_HOST && process.env.MYSQL_DATABASE
+ );
+}
+function createDbPool() {
+ if (!isDatabaseConfigured()) {
+ throw new Error("MySQL \u672A\u914D\u7F6E\uFF0C\u8BF7\u8BBE\u7F6E DATABASE_URL \u6216 MYSQL_* \u73AF\u5883\u53D8\u91CF");
+ }
+ if (process.env.DATABASE_URL) {
+ return mysql.createPool(process.env.DATABASE_URL);
+ }
+ return mysql.createPool({
+ host: process.env.MYSQL_HOST ?? "localhost",
+ port: Number(process.env.MYSQL_PORT ?? 3306),
+ user: process.env.MYSQL_USER ?? "boot",
+ password: process.env.MYSQL_PASSWORD ?? "",
+ database: process.env.MYSQL_DATABASE ?? "tkmind",
+ waitForConnections: true,
+ connectionLimit: 10
+ });
+}
+async function columnExists(pool, table, column) {
+ const [rows] = await pool.query(
+ `SELECT 1 FROM information_schema.COLUMNS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?
+ LIMIT 1`,
+ [table, column]
+ );
+ return rows.length > 0;
+}
+async function indexExists(pool, table, index) {
+ const [rows] = await pool.query(
+ `SELECT 1 FROM information_schema.STATISTICS
+ WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?
+ LIMIT 1`,
+ [table, index]
+ );
+ return rows.length > 0;
+}
+async function foreignKeyDeleteRule(pool, table, constraint) {
+ const [rows] = await pool.query(
+ `SELECT DELETE_RULE
+ FROM information_schema.REFERENTIAL_CONSTRAINTS
+ WHERE CONSTRAINT_SCHEMA = DATABASE()
+ AND TABLE_NAME = ?
+ AND CONSTRAINT_NAME = ?
+ LIMIT 1`,
+ [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);
+ if (currentRule === deleteRule) return;
+ if (currentRule) {
+ await pool.query(`ALTER TABLE \`${table}\` DROP FOREIGN KEY \`${constraint}\``);
+ }
+ await pool.query(
+ `ALTER TABLE \`${table}\`
+ ADD CONSTRAINT \`${constraint}\`
+ FOREIGN KEY (\`${column}\`) REFERENCES \`${referencedTable}\`(\`${referencedColumn}\`)
+ ON DELETE ${deleteRule}`
+ );
+}
+async function migrateSchema(pool) {
+ 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(
+ `ALTER TABLE \`${table}\` CHANGE \`${oldCol}\` \`${newCol}\` VARCHAR(128) NOT NULL`
+ );
+ }
+ }
+ const userColumns = [
+ ["slug", "VARCHAR(64) NULL AFTER username"],
+ ["email", "VARCHAR(255) NULL AFTER slug"],
+ [
+ "password_algorithm",
+ "VARCHAR(32) NOT NULL DEFAULT 'pbkdf2-sha512' AFTER password_hash"
+ ],
+ ["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}`);
+ }
+ }
+ 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)`);
+ }
+ 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)`);
+ }
+ const assetForeignKeys = [
+ {
+ table: "h5_assets",
+ constraint: "fk_h5_asset_category",
+ column: "category_id",
+ referencedTable: "h5_space_categories",
+ deleteRule: "CASCADE"
+ },
+ {
+ table: "h5_assets",
+ constraint: "fk_h5_asset_parent",
+ column: "parent_id",
+ referencedTable: "h5_assets",
+ deleteRule: "SET NULL"
+ },
+ {
+ table: "h5_asset_versions",
+ constraint: "fk_h5_asset_version_user",
+ column: "created_by",
+ referencedTable: "h5_users",
+ deleteRule: "CASCADE"
+ },
+ {
+ table: "h5_upload_sessions",
+ constraint: "fk_h5_upload_category",
+ column: "category_id",
+ referencedTable: "h5_space_categories",
+ deleteRule: "CASCADE"
+ },
+ {
+ table: "h5_page_versions",
+ constraint: "fk_h5_page_version_content",
+ column: "content_asset_id",
+ referencedTable: "h5_assets",
+ deleteRule: "CASCADE"
+ }
+ ];
+ for (const foreignKey of assetForeignKeys) {
+ await ensureForeignKeyDeleteRule(pool, foreignKey);
+ }
+ await pool.query(
+ `ALTER TABLE h5_assets
+ MODIFY source_type ENUM('upload', 'chat', 'agent', 'template', 'generated', 'workspace')
+ NOT NULL DEFAULT 'upload'`
+ );
+ const llmColumns = [
+ ["provider_kind", "ENUM('builtin','custom') NOT NULL DEFAULT 'builtin' AFTER provider_id"],
+ ["api_url", "VARCHAR(512) NULL AFTER provider_kind"],
+ ["base_path", "VARCHAR(256) NULL AFTER api_url"],
+ ["models_json", "TEXT NULL AFTER base_path"],
+ ["goosed_provider_id", "VARCHAR(64) NULL AFTER models_json"],
+ ["engine", "VARCHAR(32) NOT NULL DEFAULT 'openai' AFTER goosed_provider_id"],
+ ["relay_provider", "VARCHAR(64) NULL AFTER engine"],
+ ["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}`);
+ }
+ }
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS h5_llm_executor_bindings (
+ id CHAR(36) PRIMARY KEY,
+ executor ENUM('goose', 'aider', 'openhands') NOT NULL,
+ purpose VARCHAR(32) NOT NULL DEFAULT 'default',
+ provider_key_id CHAR(36) NULL,
+ model VARCHAR(128) NOT NULL,
+ enabled TINYINT(1) NOT NULL DEFAULT 1,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_h5_llm_executor_binding (executor, purpose),
+ KEY idx_h5_llm_executor_provider (provider_key_id),
+ 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
+ `);
+ const publishColumns = [
+ ["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}`);
+ }
+ }
+ const plazaUserColumns = [
+ ["plaza_post_count", "INT UNSIGNED NOT NULL DEFAULT 0"],
+ ["plaza_follower_count", "INT UNSIGNED NOT NULL DEFAULT 0"],
+ ["plaza_following_count", "INT UNSIGNED NOT NULL DEFAULT 0"],
+ ["plaza_verified", "TINYINT(1) NOT NULL DEFAULT 0"],
+ ["plaza_post_banned", "TINYINT(1) NOT NULL DEFAULT 0"],
+ ["plaza_comment_banned", "TINYINT(1) NOT NULL DEFAULT 0"],
+ [
+ "ops_role",
+ "ENUM('none','reviewer','editor','ops_admin') NOT NULL DEFAULT 'none'"
+ ]
+ ];
+ 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(pool, "h5_users", "signup_source")) {
+ await pool.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(
+ `ALTER TABLE h5_user_sessions ADD COLUMN goosed_node TINYINT UNSIGNED NOT NULL DEFAULT 0`
+ );
+ }
+ const oauthStateColumns = [
+ ["intent", "VARCHAR(16) NOT NULL DEFAULT 'login' AFTER utm_campaign"],
+ ["bind_user_id", "CHAR(36) NULL AFTER intent"],
+ ["auth_mode", "VARCHAR(8) NOT NULL DEFAULT 'mp' AFTER bind_user_id"],
+ ["status", "VARCHAR(16) NOT NULL DEFAULT 'pending' AFTER auth_mode"],
+ ["result_kind", "VARCHAR(32) NULL AFTER status"],
+ ["result_token", "VARCHAR(512) NULL AFTER result_kind"],
+ ["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(
+ `ALTER TABLE h5_wechat_oauth_states ADD COLUMN \`${column}\` ${definition}`
+ );
+ }
+ }
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS h5_wechat_pending_binds (
+ token VARCHAR(64) PRIMARY KEY,
+ app_id VARCHAR(32) NOT NULL,
+ openid VARCHAR(64) NOT NULL,
+ unionid VARCHAR(64) NULL,
+ nickname VARCHAR(128) NULL,
+ avatar_url VARCHAR(512) NULL,
+ return_to VARCHAR(512) NULL,
+ utm_source VARCHAR(64) NULL,
+ utm_medium VARCHAR(64) NULL,
+ utm_campaign VARCHAR(64) NULL,
+ expires_at BIGINT NOT NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_wechat_pending_expires (expires_at)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ `);
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS h5_wechat_mp_messages (
+ app_id VARCHAR(32) NOT NULL,
+ openid VARCHAR(64) NOT NULL,
+ msg_id VARCHAR(128) NOT NULL,
+ status ENUM('processing', 'done', 'failed') NOT NULL DEFAULT 'processing',
+ agent_session_id VARCHAR(128) NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ PRIMARY KEY (app_id, openid, msg_id),
+ KEY idx_wechat_mp_messages_updated (updated_at),
+ KEY idx_wechat_mp_messages_session (agent_session_id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ `);
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS h5_wechat_mp_message_details (
+ id CHAR(36) PRIMARY KEY,
+ app_id VARCHAR(32) NOT NULL,
+ openid VARCHAR(64) NOT NULL,
+ user_id CHAR(36) NULL,
+ msg_id VARCHAR(128) NULL,
+ msg_type VARCHAR(32) NOT NULL,
+ display_text TEXT NULL,
+ agent_text TEXT NULL,
+ media_id VARCHAR(256) NULL,
+ media_url TEXT NULL,
+ media_public_url TEXT NULL,
+ media_format VARCHAR(64) NULL,
+ location_lat DECIMAL(10,7) NULL,
+ location_lng DECIMAL(10,7) NULL,
+ location_label VARCHAR(255) NULL,
+ link_url TEXT NULL,
+ link_title VARCHAR(255) NULL,
+ raw_xml_hash CHAR(40) NULL,
+ raw_json JSON NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_wechat_mp_message_details_user (app_id, openid, created_at),
+ KEY idx_wechat_mp_message_details_msg (msg_id),
+ KEY idx_wechat_mp_message_details_type (msg_type, created_at),
+ 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(`
+ CREATE TABLE IF NOT EXISTS h5_schedule_items (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ kind ENUM('task', 'event') NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ description TEXT NULL,
+ status ENUM('active', 'completed', 'cancelled', 'deleted') NOT NULL DEFAULT 'active',
+ start_at BIGINT NULL,
+ end_at BIGINT NULL,
+ due_at BIGINT NULL,
+ all_day TINYINT(1) NOT NULL DEFAULT 0,
+ timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai',
+ location VARCHAR(255) NULL,
+ source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent',
+ source_session_id VARCHAR(128) NULL,
+ source_message_id VARCHAR(128) NULL,
+ source_text TEXT NULL,
+ metadata_json JSON NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ deleted_at BIGINT NULL,
+ KEY idx_schedule_user_time (user_id, status, start_at, due_at),
+ KEY idx_schedule_user_updated (user_id, updated_at),
+ 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(`
+ CREATE TABLE IF NOT EXISTS h5_schedule_reminders (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ item_id CHAR(36) NOT NULL,
+ remind_at BIGINT NOT NULL,
+ offset_minutes INT NULL,
+ channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat',
+ status ENUM('pending', 'locked', 'sent', 'failed', 'cancelled') NOT NULL DEFAULT 'pending',
+ attempts INT NOT NULL DEFAULT 0,
+ last_error VARCHAR(500) NULL,
+ locked_until BIGINT NULL,
+ sent_at BIGINT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_schedule_item_remind_at (item_id, remind_at, channel),
+ KEY idx_reminder_due (status, remind_at),
+ KEY idx_reminder_user (user_id, status, remind_at),
+ CONSTRAINT fk_schedule_reminder_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
+ 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(`
+ CREATE TABLE IF NOT EXISTS h5_schedule_digest_subscriptions (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ digest_type ENUM('todo_day') NOT NULL DEFAULT 'todo_day',
+ hour TINYINT UNSIGNED NOT NULL,
+ minute TINYINT UNSIGNED NOT NULL DEFAULT 0,
+ timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai',
+ channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat',
+ status ENUM('active', 'locked', 'failed', 'cancelled') NOT NULL DEFAULT 'active',
+ next_run_at BIGINT NOT NULL,
+ last_run_at BIGINT NULL,
+ attempts INT NOT NULL DEFAULT 0,
+ locked_until BIGINT NULL,
+ last_error VARCHAR(500) NULL,
+ source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent',
+ source_session_id VARCHAR(128) NULL,
+ source_message_id VARCHAR(128) NULL,
+ source_text TEXT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_schedule_digest_user_type_channel (user_id, digest_type, channel),
+ KEY idx_schedule_digest_due (status, next_run_at),
+ 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(`
+ CREATE TABLE IF NOT EXISTS h5_balance_alert_subscriptions (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ threshold_cents BIGINT NOT NULL,
+ channel ENUM('wechat', 'in_app') NOT NULL DEFAULT 'wechat',
+ status ENUM('active', 'locked', 'failed', 'cancelled') NOT NULL DEFAULT 'active',
+ next_run_at BIGINT NOT NULL,
+ last_run_at BIGINT NULL,
+ last_notified_balance_cents BIGINT NULL,
+ attempts INT NOT NULL DEFAULT 0,
+ locked_until BIGINT NULL,
+ last_error VARCHAR(500) NULL,
+ source_channel ENUM('h5', 'wechat', 'agent', 'api') NOT NULL DEFAULT 'agent',
+ source_session_id VARCHAR(128) NULL,
+ source_message_id VARCHAR(128) NULL,
+ source_text TEXT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ UNIQUE KEY uq_balance_alert_user_channel (user_id, channel),
+ KEY idx_balance_alert_due (status, next_run_at),
+ KEY idx_balance_alert_user (user_id, status, next_run_at),
+ 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(`
+ CREATE TABLE IF NOT EXISTS h5_schedule_delivery_logs (
+ id CHAR(36) PRIMARY KEY,
+ reminder_id CHAR(36) NULL,
+ subscription_id CHAR(36) NULL,
+ user_id CHAR(36) NOT NULL,
+ channel ENUM('wechat', 'in_app') NOT NULL,
+ status ENUM('success', 'failed') NOT NULL,
+ provider_message_id VARCHAR(128) NULL,
+ error_code VARCHAR(64) NULL,
+ error_message VARCHAR(500) NULL,
+ created_at BIGINT NOT NULL,
+ KEY idx_delivery_reminder (reminder_id, created_at),
+ KEY idx_delivery_subscription (subscription_id, created_at),
+ KEY idx_delivery_user (user_id, created_at),
+ CONSTRAINT fk_schedule_delivery_reminder FOREIGN KEY (reminder_id) REFERENCES h5_schedule_reminders(id) ON DELETE CASCADE,
+ CONSTRAINT fk_schedule_delivery_subscription FOREIGN KEY (subscription_id) REFERENCES h5_schedule_digest_subscriptions(id) ON DELETE CASCADE,
+ 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(`
+ CREATE TABLE IF NOT EXISTS h5_user_notifications (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ channel ENUM('web', 'wechat') NOT NULL DEFAULT 'web',
+ notification_type VARCHAR(64) NOT NULL,
+ title VARCHAR(255) NOT NULL,
+ body TEXT NOT NULL,
+ data_json JSON NULL,
+ status ENUM('unread', 'read') NOT NULL DEFAULT 'unread',
+ read_at BIGINT NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ KEY idx_user_notifications_user_status_created (user_id, status, created_at),
+ KEY idx_user_notifications_user_created (user_id, created_at),
+ 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(
+ `ALTER TABLE h5_payment_orders
+ MODIFY pay_mode ENUM('native', 'h5', 'jsapi') NOT NULL DEFAULT 'native'`
+ );
+ await pool.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 '',
+ working_dir VARCHAR(1024) NOT NULL DEFAULT '',
+ created_at_str VARCHAR(64) NOT NULL DEFAULT '',
+ updated_at_str VARCHAR(64) NOT NULL DEFAULT '',
+ user_set_name TINYINT(1) NOT NULL DEFAULT 0,
+ recipe_json TEXT NULL,
+ synced_msg_count INT NOT NULL DEFAULT 0,
+ source_updated_at VARCHAR(64) NOT NULL DEFAULT '',
+ messages_json LONGTEXT NOT NULL,
+ synced_at BIGINT NOT NULL,
+ KEY idx_h5_snapshots_user (user_id),
+ KEY idx_h5_snapshots_synced (synced_at),
+ 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(`
+ CREATE TABLE IF NOT EXISTS h5_subscriptions (
+ id CHAR(36) PRIMARY KEY,
+ user_id CHAR(36) NOT NULL,
+ plan_type VARCHAR(32) NOT NULL DEFAULT 'free',
+ status ENUM('active', 'expired', 'cancelled') NOT NULL DEFAULT 'active',
+ period_tokens_limit BIGINT NOT NULL DEFAULT 0,
+ period_tokens_used BIGINT NOT NULL DEFAULT 0,
+ period_start BIGINT NOT NULL,
+ period_end BIGINT NOT NULL,
+ expires_at BIGINT NOT NULL,
+ overage_rate DECIMAL(4,2) NOT NULL DEFAULT 1.00,
+ operator_id CHAR(36) NULL,
+ note VARCHAR(512) NULL,
+ created_at BIGINT NOT NULL,
+ updated_at BIGINT NOT NULL,
+ KEY idx_h5_sub_user_status (user_id, status),
+ KEY idx_h5_sub_expires (expires_at, status),
+ 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(`
+ INSERT INTO h5_subscriptions
+ (id, user_id, plan_type, status,
+ period_tokens_limit, period_tokens_used,
+ period_start, period_end, expires_at, overage_rate,
+ operator_id, note, created_at, updated_at)
+ SELECT
+ UUID(),
+ u.id,
+ 'free',
+ 'active',
+ 150000, 0,
+ UNIX_TIMESTAMP() * 1000,
+ (UNIX_TIMESTAMP() + 30 * 86400) * 1000,
+ (UNIX_TIMESTAMP() + 30 * 86400) * 1000,
+ 1.00,
+ NULL,
+ '\u7CFB\u7EDF\u8FC1\u79FB\u8865\u5EFA\u514D\u8D39\u5957\u9910',
+ UNIX_TIMESTAMP() * 1000,
+ UNIX_TIMESTAMP() * 1000
+ FROM h5_users u
+ WHERE NOT EXISTS (
+ SELECT 1 FROM h5_subscriptions s
+ WHERE s.user_id = u.id AND s.status = 'active'
+ )
+ `);
+}
+async function initSchema(pool) {
+ const schemaPath = path.join(__dirname, "schema.sql");
+ const sql = fs2.readFileSync(schemaPath, "utf8");
+ for (const statement of sql.split(";")) {
+ const trimmed = statement.trim();
+ if (trimmed) {
+ await pool.query(trimmed);
+ }
+ }
+ await migrateSchema(pool);
+ await ensureDefaultSpaces(pool, {
+ quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024)
+ });
+}
+
+// tkmind-proxy.mjs
+import { Readable } from "node:stream";
+import { Agent, fetch as undiciFetch } from "undici";
+
+// sse-billing.mjs
+import { Transform } from "node:stream";
+function parseSseChunk(chunk) {
+ const events = [];
+ const blocks = chunk.split("\n\n");
+ for (const block of blocks) {
+ if (!block.trim()) continue;
+ let data;
+ for (const line of block.split("\n")) {
+ if (line.startsWith("data:")) {
+ data = line.slice(5).trim();
+ }
+ }
+ if (!data) continue;
+ try {
+ events.push(JSON.parse(data));
+ } catch {
+ }
+ }
+ return events;
+}
+function createSseBillingTransform({ onFinish }) {
+ let buffer = "";
+ return new Transform({
+ transform(chunk, _encoding, callback) {
+ buffer += chunk.toString("utf8");
+ const parts = buffer.split("\n\n");
+ buffer = parts.pop() ?? "";
+ for (const block of parts) {
+ if (!block.trim()) continue;
+ let dataLine;
+ for (const line of block.split("\n")) {
+ if (line.startsWith("data:")) dataLine = line.slice(5).trim();
+ }
+ if (!dataLine) continue;
+ try {
+ const event = JSON.parse(dataLine);
+ if (event?.type === "Finish" && event.token_state) {
+ void onFinish(event).catch(() => {
+ });
+ }
+ } catch {
+ }
+ }
+ callback(null, chunk);
+ },
+ flush(callback) {
+ if (buffer.trim()) {
+ for (const event of parseSseChunk(`${buffer}
+
+`)) {
+ if (event?.type === "Finish" && event.token_state) {
+ void onFinish(event).catch(() => {
+ });
+ }
+ }
+ }
+ callback();
+ }
+ });
+}
+function appendBalanceEvent(payload) {
+ const data = typeof payload === "number" ? { balanceCents: payload } : payload && typeof payload === "object" ? payload : { balanceCents: 0 };
+ return `event: balance
+data: ${JSON.stringify(data)}
+
+`;
+}
+
+// capabilities.mjs
+import path2 from "node:path";
+import { fileURLToPath as fileURLToPath2 } from "node:url";
+
+// policies.mjs
+var POLICY_CATALOG = [
+ {
+ key: "goose_mode",
+ label: "TKMind \u6A21\u5F0F",
+ description: "H5 \u7EC8\u7AEF\u7528\u6237\u4E0D\u5728\u804A\u5929\u91CC\u70B9\u300C\u5141\u8BB8/\u62D2\u7EDD\u300D\uFF1B\u53EF\u7528\u5DE5\u5177\u7531\u300C\u80FD\u529B\u6743\u9650\u300D\u51B3\u5B9A\u3002chat \u4E0D\u8C03\u7528\u5DE5\u5177\uFF0Cauto \u5728\u6388\u6743\u8303\u56F4\u5185\u81EA\u52A8\u6267\u884C\u3002",
+ type: "select",
+ options: [
+ { value: "chat", label: "\u4EC5\u804A\u5929 (chat)" },
+ { value: "auto", label: "\u81EA\u52A8\u6267\u884C (auto)" }
+ ],
+ defaultValue: "chat",
+ category: "goose",
+ risk: "high"
+ },
+ {
+ key: "workspace_access",
+ label: "\u5DE5\u4F5C\u533A\u8BBF\u95EE",
+ description: "readonly \u65F6\u7981\u6B62 shell \u4E0E\u6587\u4EF6\u5199\u5165\uFF0C\u4EC5\u5141\u8BB8\u6D4F\u89C8\u4E0E\u5206\u6790",
+ type: "select",
+ options: [
+ { value: "readwrite", label: "\u8BFB\u5199" },
+ { value: "readonly", label: "\u53EA\u8BFB" }
+ ],
+ defaultValue: "readwrite",
+ category: "workspace",
+ risk: "medium"
+ },
+ {
+ key: "network_egress",
+ label: "\u7F51\u7EDC\u51FA\u7AD9",
+ description: "deny \u65F6\u7981\u7528 shell\u3001\u7535\u8111\u63A7\u5236\u3001\u6C99\u7BB1\u811A\u672C\u7B49\u53EF\u80FD\u8BBF\u95EE\u7F51\u7EDC\u7684\u6269\u5C55",
+ type: "select",
+ options: [
+ { value: "deny", label: "\u7981\u6B62\uFF08\u63A8\u8350\uFF09" },
+ { value: "allow", label: "\u5141\u8BB8\uFF08\u9700\u540C\u65F6\u5F00\u542F\u5BF9\u5E94\u80FD\u529B\uFF09" }
+ ],
+ defaultValue: "deny",
+ category: "network",
+ risk: "high"
+ },
+ {
+ key: "api_lockdown",
+ label: "API \u4EE3\u7406\u9501\u5B9A",
+ description: "\u5F00\u542F\u540E\u4EC5\u5141\u8BB8\u804A\u5929\u76F8\u5173 API\uFF0C\u62E6\u622A\u6539\u6A21\u5F0F\u3001\u52A0\u6269\u5C55\u3001\u5199\u914D\u7F6E\u7B49\u5371\u9669\u8BF7\u6C42",
+ type: "boolean",
+ defaultValue: true,
+ category: "proxy",
+ risk: "medium"
+ },
+ {
+ key: "code_delegate_executor",
+ label: "\u4EE3\u7801\u59D4\u6258\u6267\u884C\u5668",
+ description: "\u63A7\u5236 Goose \u5728\u591A\u6587\u4EF6\u7F16\u7801\u3001\u4FEE\u590D\u4E0E\u91CD\u6784\u4EFB\u52A1\u4E2D\u4F18\u5148\u59D4\u6258\u7ED9\u8C01\u3002auto \u7531 Goose \u7ED3\u5408\u5F53\u524D\u53EF\u7528\u6269\u5C55\u81EA\u884C\u5224\u65AD\uFF1Baider / openhands \u5219\u4F18\u5148\u4F7F\u7528\u6307\u5B9A\u6267\u884C\u5668\u3002",
+ type: "select",
+ options: [
+ { value: "auto", label: "\u81EA\u52A8\u9009\u62E9 (auto)" },
+ { value: "aider", label: "\u4F18\u5148 Aider" },
+ { value: "openhands", label: "\u4F18\u5148 OpenHands" }
+ ],
+ defaultValue: "auto",
+ category: "routing",
+ risk: "medium"
+ },
+ {
+ key: "code_task_routing",
+ label: "\u4EE3\u7801\u4EFB\u52A1\u8DEF\u7531\u7B56\u7565",
+ description: "\u5B9A\u4E49\u4EE3\u7801\u4EFB\u52A1\u5982\u4F55\u5728 Aider \u4E0E OpenHands \u4E4B\u95F4\u5206\u6D41\u3002balanced \u7531 Goose \u6309\u4EFB\u52A1\u590D\u6742\u5EA6\u5224\u65AD\uFF1Bsplit \u5EFA\u8BAE\u5C0F\u6539\u52A8\u8D70 Aider\u3001\u590D\u6742\u4EFB\u52A1\u8D70 OpenHands\uFF1Bforce_* \u5219\u5F3A\u5236\u4F18\u5148\u5355\u4E00\u8DEF\u5F84\u3002",
+ type: "select",
+ options: [
+ { value: "balanced", label: "\u5E73\u8861\u8DEF\u7531 (balanced)" },
+ { value: "split", label: "\u5C0F\u6539\u52A8 Aider\uFF0C\u590D\u6742\u4EFB\u52A1 OpenHands" },
+ { value: "force_aider", label: "\u5C3D\u91CF\u7EDF\u4E00\u8D70 Aider" },
+ { value: "force_openhands", label: "\u5C3D\u91CF\u7EDF\u4E00\u8D70 OpenHands" }
+ ],
+ defaultValue: "balanced",
+ category: "routing",
+ risk: "medium"
+ }
+];
+var DEFAULT_USER_POLICIES = Object.fromEntries(
+ POLICY_CATALOG.map((item) => [item.key, item.defaultValue])
+);
+var POLICY_KEYS = new Set(POLICY_CATALOG.map((item) => item.key));
+var GOOSE_MODES = /* @__PURE__ */ new Set(["auto", "approve", "smart_approve", "chat"]);
+var WORKSPACE_ACCESS = /* @__PURE__ */ new Set(["readwrite", "readonly"]);
+var NETWORK_EGRESS = /* @__PURE__ */ new Set(["allow", "deny"]);
+var CODE_DELEGATE_EXECUTORS = /* @__PURE__ */ new Set(["auto", "aider", "openhands"]);
+var CODE_TASK_ROUTINGS = /* @__PURE__ */ new Set(["balanced", "split", "force_aider", "force_openhands"]);
+function policyKeys() {
+ return [...POLICY_KEYS];
+}
+function isValidPolicyKey(key) {
+ return POLICY_KEYS.has(key);
+}
+function normalizePolicyPatch(patch) {
+ const normalized = {};
+ for (const [key, raw] of Object.entries(patch ?? {})) {
+ if (!isValidPolicyKey(key)) continue;
+ const def = POLICY_CATALOG.find((item) => item.key === key);
+ if (!def) continue;
+ if (def.type === "boolean") {
+ normalized[key] = raw === true || raw === "true" || raw === 1 || raw === "1";
+ continue;
+ }
+ const value = String(raw ?? "").trim();
+ if (def.key === "goose_mode" && GOOSE_MODES.has(value)) normalized[key] = value;
+ if (def.key === "workspace_access" && WORKSPACE_ACCESS.has(value)) normalized[key] = value;
+ if (def.key === "network_egress" && NETWORK_EGRESS.has(value)) normalized[key] = value;
+ if (def.key === "code_delegate_executor" && CODE_DELEGATE_EXECUTORS.has(value)) {
+ normalized[key] = value;
+ }
+ if (def.key === "code_task_routing" && CODE_TASK_ROUTINGS.has(value)) {
+ normalized[key] = value;
+ }
+ }
+ return normalized;
+}
+function resolvePolicies(rolePolicies, userOverrides) {
+ const resolved = { ...DEFAULT_USER_POLICIES };
+ for (const [key, value] of Object.entries(rolePolicies ?? {})) {
+ if (isValidPolicyKey(key)) resolved[key] = value;
+ }
+ for (const [key, value] of Object.entries(userOverrides ?? {})) {
+ if (isValidPolicyKey(key)) resolved[key] = value;
+ }
+ if (resolved.goose_mode === "approve" || resolved.goose_mode === "smart_approve") {
+ resolved.goose_mode = "auto";
+ }
+ return resolved;
+}
+function hasExecutableTools(capabilities) {
+ return Boolean(
+ capabilities?.static_publish || capabilities?.shell || capabilities?.filesystem || capabilities?.code_browse || capabilities?.subagent || capabilities?.code_sandbox || capabilities?.computer || capabilities?.charts || capabilities?.aider || capabilities?.openhands || capabilities?.apps || capabilities?.todo || capabilities?.skills || capabilities?.chat_recall
+ );
+}
+function resolveAgentGooseMode(capabilities, policies) {
+ const requested = policies?.goose_mode ?? DEFAULT_USER_POLICIES.goose_mode;
+ if (!hasExecutableTools(capabilities)) {
+ return "chat";
+ }
+ if (requested === "chat" || requested === "approve" || requested === "smart_approve") {
+ return "auto";
+ }
+ return "auto";
+}
+function applyPoliciesToCapabilities(capabilities, policies) {
+ const effective = { ...capabilities };
+ if (policies.workspace_access === "readonly") {
+ effective.shell = false;
+ effective.filesystem = false;
+ effective.static_publish = false;
+ effective.aider = false;
+ effective.openhands = false;
+ effective.code_sandbox = false;
+ effective.image_read = false;
+ }
+ if (policies.network_egress === "deny") {
+ if (!effective.static_publish) {
+ effective.shell = false;
+ }
+ effective.computer = false;
+ effective.code_sandbox = false;
+ }
+ return effective;
+}
+var USER_API_ALLOWLIST = [
+ { method: "GET", pattern: /^\/status$/ },
+ { method: "POST", pattern: /^\/agent\/start$/ },
+ { method: "POST", pattern: /^\/agent\/resume$/ },
+ { method: "GET", pattern: /^\/sessions$/ },
+ { method: "GET", pattern: /^\/sessions\/[^/]+$/ },
+ { method: "DELETE", pattern: /^\/sessions\/[^/]+$/ },
+ { method: "GET", pattern: /^\/sessions\/[^/]+\/events$/ },
+ { method: "POST", pattern: /^\/sessions\/[^/]+\/reply$/ },
+ { method: "POST", pattern: /^\/sessions\/[^/]+\/cancel$/ },
+ { method: "POST", pattern: /^\/action-required\/tool-confirmation$/ },
+ { method: "POST", pattern: /^\/agent\/update_provider$/ },
+ { method: "POST", pattern: /^\/config\/read$/ },
+ { method: "POST", pattern: /^\/agent\/harness_bootstrap$/ },
+ { method: "POST", pattern: /^\/agent\/harness_remember$/ }
+];
+function isNativeH5ApiPath(pathname) {
+ const path23 = String(pathname ?? "");
+ return path23.startsWith("/mindspace/") || path23.startsWith("/plaza/");
+}
+function evaluateProxyRequest(method, pathname, policies, { unrestricted = false } = {}) {
+ if (unrestricted) return { allowed: true };
+ if (!policies.api_lockdown) return { allowed: true };
+ if (isNativeH5ApiPath(pathname)) {
+ return {
+ allowed: false,
+ reason: `H5 \u672C\u5730\u63A5\u53E3\u4E0D\u5E94\u8D70 Agent \u4EE3\u7406\uFF1A${method.toUpperCase()} ${pathname}`
+ };
+ }
+ const upper = method.toUpperCase();
+ const allowed = USER_API_ALLOWLIST.some(
+ (rule) => rule.method === upper && rule.pattern.test(pathname)
+ );
+ if (allowed) return { allowed: true };
+ return {
+ allowed: false,
+ reason: `\u7B56\u7565\u5DF2\u9501\u5B9A API\uFF1A${upper} ${pathname}`
+ };
+}
+
+// capabilities.mjs
+function resolveSandboxMcpServerPath() {
+ return path2.join(path2.dirname(fileURLToPath2(import.meta.url)), "mindspace-sandbox-mcp.mjs");
+}
+var CAPABILITY_CATALOG = [
+ {
+ key: "shell",
+ label: "Shell \u547D\u4EE4",
+ description: "\u6267\u884C bash/shell \u547D\u4EE4\uFF08developer.shell\uFF09",
+ risk: "high",
+ category: "developer"
+ },
+ {
+ key: "static_publish",
+ label: "\u7528\u6237\u6C99\u7BB1\u76EE\u5F55",
+ description: "\u5728 MindSpace/<\u7528\u6237ID>/ \u5185\u53EF\u4F7F\u7528 sandbox-fs \u7684 write_file/edit_file/read_file/create_dir\uFF08\u4EE5\u53CA\u6309\u6743\u9650\u5F00\u653E\u7684 list_dir\uFF1B\u4E0D\u53EF\u8D8A\u51FA\u8BE5\u76EE\u5F55\uFF09",
+ risk: "medium",
+ category: "publisher"
+ },
+ {
+ key: "private_data_space",
+ label: "\u7528\u6237\u79C1\u6709\u6570\u636E\u7A7A\u95F4",
+ description: "\u4E3A\u7528\u6237\u63D0\u4F9B\u552F\u4E00\u7684\u79C1\u6709 SQLite \u6570\u636E\u7A7A\u95F4\uFF0C\u4F9B Agent \u521B\u5EFA\u95EE\u5377\u3001\u8868\u5355\u3001\u6E05\u5355\u7B49\u79C1\u6709\u7ED3\u6784\u5316\u6570\u636E\u8868",
+ risk: "medium",
+ category: "publisher"
+ },
+ {
+ key: "filesystem",
+ label: "\u6587\u4EF6\u8BFB\u5199\uFF08\u5168\u76EE\u5F55\uFF09",
+ description: "\u5728\u5DE5\u4F5C\u533A\u5185\u4EFB\u610F\u8BFB\u5199\u6587\u4EF6\uFF08developer.write / edit\uFF09\uFF0C\u4EC5\u9AD8\u7EA7\u7528\u6237",
+ risk: "high",
+ category: "developer"
+ },
+ {
+ key: "code_browse",
+ label: "\u4EE3\u7801\u6D4F\u89C8",
+ description: "\u76EE\u5F55\u6811\u3001\u4EE3\u7801\u7ED3\u6784\u5206\u6790\uFF08developer.tree\u3001analyze\uFF09",
+ risk: "low",
+ category: "developer"
+ },
+ {
+ key: "image_read",
+ label: "\u56FE\u7247\u8BFB\u53D6",
+ description: "\u8BFB\u53D6\u672C\u5730\u6216\u7F51\u7EDC\u56FE\u7247\uFF08developer.read_image\uFF09",
+ risk: "low",
+ category: "developer"
+ },
+ {
+ key: "skills",
+ label: "\u6280\u80FD\u52A0\u8F7D",
+ description: "\u52A0\u8F7D\u6280\u80FD\u4E0E\u77E5\u8BC6\uFF08skills / summon.load\uFF09",
+ risk: "low",
+ category: "knowledge"
+ },
+ {
+ key: "subagent",
+ label: "\u5B50\u4EFB\u52A1\u6D3E\u53D1",
+ description: "delegate \u5B50 Agent \u6267\u884C\u590D\u6742\u4EFB\u52A1\uFF08summon\uFF09",
+ risk: "high",
+ category: "agent"
+ },
+ {
+ key: "code_sandbox",
+ label: "\u6C99\u7BB1\u811A\u672C",
+ description: "execute_typescript \u5728\u6C99\u7BB1\u4E2D\u6279\u91CF\u8C03\u7528\u5DE5\u5177\uFF08code_execution\uFF09",
+ risk: "high",
+ category: "agent"
+ },
+ {
+ key: "chat_recall",
+ label: "\u5BF9\u8BDD\u56DE\u6EAF",
+ description: "\u68C0\u7D22\u5386\u53F2\u4F1A\u8BDD\uFF08chatrecall\uFF09",
+ risk: "low",
+ category: "memory"
+ },
+ {
+ key: "context_memory",
+ label: "\u9879\u76EE\u8BB0\u5FC6",
+ description: "\u4F1A\u8BDD\u7EA7\u9879\u76EE\u8BB0\u5FC6\u6CE8\u5165\uFF08projectmemory\uFF0CH5 \u5F15\u5BFC\u7528\uFF09",
+ risk: "low",
+ category: "memory"
+ },
+ {
+ key: "memory_store",
+ label: "\u957F\u671F\u8BB0\u5FC6",
+ description: "\u8BB0\u4F4F/\u68C0\u7D22\u7528\u6237\u504F\u597D\uFF08memory \u6269\u5C55\uFF09",
+ risk: "medium",
+ category: "memory"
+ },
+ {
+ key: "extension_admin",
+ label: "\u6269\u5C55\u7BA1\u7406",
+ description: "\u641C\u7D22\u3001\u542F\u7528\u3001\u7981\u7528\u6269\u5C55\uFF08extensionmanager\uFF09",
+ risk: "high",
+ category: "admin"
+ },
+ {
+ key: "apps",
+ label: "\u5E94\u7528\u7BA1\u7406",
+ description: "\u521B\u5EFA\u4E0E\u7BA1\u7406 TKMind \u5E94\u7528\uFF08apps\uFF09",
+ risk: "medium",
+ category: "agent"
+ },
+ {
+ key: "todo",
+ label: "\u5F85\u529E\u4E8B\u9879",
+ description: "\u4EFB\u52A1\u5217\u8868\u8DDF\u8E2A\uFF08todo\uFF09",
+ risk: "low",
+ category: "productivity"
+ },
+ {
+ key: "computer",
+ label: "\u7535\u8111\u63A7\u5236",
+ description: "\u81EA\u52A8\u5316\u811A\u672C\u3001\u7F51\u9875\u6293\u53D6\u3001\u6587\u6863\u5904\u7406\uFF08computercontroller\uFF09",
+ risk: "high",
+ category: "automation"
+ },
+ {
+ key: "charts",
+ label: "\u6570\u636E\u53EF\u89C6\u5316",
+ description: "\u56FE\u8868\u4E0E\u53EF\u89C6\u5316\uFF08autovisualiser\uFF09",
+ risk: "low",
+ category: "automation"
+ },
+ {
+ key: "aider",
+ label: "Aider \u7F16\u7801",
+ description: "\u591A\u6587\u4EF6\u7F16\u7801\u59D4\u6258\uFF08aider\uFF09",
+ risk: "high",
+ category: "developer"
+ },
+ {
+ key: "openhands",
+ label: "OpenHands \u7F16\u7801",
+ description: "\u590D\u6742\u591A\u6587\u4EF6\u7F16\u7801\u4E0E\u4ED3\u5E93\u7EA7\u4EFB\u52A1\u59D4\u6258\uFF08openhands\uFF09",
+ risk: "high",
+ category: "developer"
+ }
+];
+var USER_NON_GRANTABLE_CAPABILITIES = /* @__PURE__ */ new Set(["extension_admin"]);
+function clampUserCapabilities(capabilities) {
+ const clamped = { ...capabilities };
+ for (const key of USER_NON_GRANTABLE_CAPABILITIES) {
+ clamped[key] = false;
+ }
+ return clamped;
+}
+var DEFAULT_USER_CAPABILITIES = Object.fromEntries(
+ CAPABILITY_CATALOG.map(({ key }) => {
+ const defaults = {
+ shell: false,
+ static_publish: false,
+ private_data_space: true,
+ filesystem: false,
+ code_browse: false,
+ image_read: true,
+ skills: true,
+ subagent: false,
+ code_sandbox: false,
+ chat_recall: true,
+ context_memory: true,
+ memory_store: true,
+ extension_admin: false,
+ apps: false,
+ todo: false,
+ computer: false,
+ charts: false,
+ aider: false,
+ openhands: false
+ };
+ return [key, defaults[key] ?? false];
+ })
+);
+var CATALOG_KEYS = new Set(CAPABILITY_CATALOG.map((item) => item.key));
+function catalogKeys() {
+ return [...CATALOG_KEYS];
+}
+function isValidCapabilityKey(key) {
+ return CATALOG_KEYS.has(key);
+}
+function makeExtension(type, name, tools = []) {
+ return {
+ type,
+ name,
+ description: "",
+ display_name: name,
+ bundled: true,
+ available_tools: tools
+ };
+}
+function sandboxDeveloperTools(capabilities) {
+ const tools = ["write", "edit"];
+ if (capabilities.shell) {
+ tools.push("shell", "tree");
+ } else if (capabilities.code_browse) {
+ tools.push("tree");
+ }
+ if (capabilities.image_read) tools.push("read_image");
+ return tools;
+}
+function sandboxMcpTools(capabilities) {
+ const tools = [];
+ if (capabilities.static_publish) {
+ tools.push("read_file", "write_file", "edit_file", "create_dir");
+ if (capabilities.shell || capabilities.code_browse) tools.push("list_dir");
+ }
+ if (capabilities.private_data_space) {
+ tools.push(
+ "private_data_info",
+ "private_data_schema",
+ "private_data_query",
+ "private_data_execute",
+ "schedule_create_item",
+ "schedule_create_reminder",
+ "schedule_list_items"
+ );
+ }
+ return tools;
+}
+function developerToolsFromPolicy(sessionPolicy) {
+ const developer = sessionPolicy?.extensionOverrides?.find((ext) => ext.name === "developer");
+ const sandboxFs = sessionPolicy?.extensionOverrides?.find((ext) => ext.name === "sandbox-fs");
+ return (sandboxFs ?? developer)?.available_tools ?? [];
+}
+function mergeDeveloperTools(capabilities) {
+ const tools = [];
+ if (capabilities.shell) tools.push("shell");
+ if (capabilities.filesystem) tools.push("write", "edit");
+ if (capabilities.code_browse) tools.push("tree");
+ if (capabilities.image_read) tools.push("read_image");
+ return tools;
+}
+function sandboxMcpEnvs(sandboxMcp, mcpTools) {
+ const envs = {
+ SANDBOX_ROOT: sandboxMcp.sandboxRoot,
+ ALLOWED_TOOLS: mcpTools.join(",")
+ };
+ if (sandboxMcp.userId) envs.PRIVATE_DATA_USER_ID = sandboxMcp.userId;
+ for (const key of [
+ "DATABASE_URL",
+ "MYSQL_HOST",
+ "MYSQL_PORT",
+ "MYSQL_USER",
+ "MYSQL_PASSWORD",
+ "MYSQL_DATABASE",
+ "PRIVATE_DATA_MAX_BYTES"
+ ]) {
+ if (process.env[key]) envs[key] = process.env[key];
+ }
+ return envs;
+}
+function buildAgentExtensionPolicy(capabilities, { unrestricted = false, policies = null, sandboxMcp = null } = {}) {
+ if (unrestricted) {
+ return { extensionOverrides: null, enableContextMemory: true, gooseMode: "auto" };
+ }
+ const extensions = [];
+ if (capabilities.static_publish || capabilities.private_data_space && sandboxMcp) {
+ if (sandboxMcp?.serverPath && sandboxMcp?.sandboxRoot) {
+ const mcpTools = sandboxMcpTools(capabilities);
+ if (mcpTools.length > 0) {
+ extensions.push({
+ type: "stdio",
+ name: "sandbox-fs",
+ 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,
+ // 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
+ envs: sandboxMcpEnvs(sandboxMcp, mcpTools),
+ available_tools: mcpTools
+ });
+ }
+ if (capabilities.image_read) {
+ extensions.push(makeExtension("platform", "developer", ["read_image"]));
+ }
+ } else if (capabilities.static_publish) {
+ const sandboxTools = sandboxDeveloperTools(capabilities);
+ if (sandboxTools.length > 0) {
+ extensions.push(makeExtension("platform", "developer", sandboxTools));
+ }
+ }
+ if (capabilities.static_publish) {
+ extensions.push(makeExtension("platform", "skills", []));
+ extensions.push(makeExtension("platform", "summon", ["load_skill"]));
+ extensions.push(makeExtension("platform", "projectmemory", []));
+ }
+ } else {
+ const developerTools = mergeDeveloperTools(capabilities);
+ if (developerTools.length > 0) {
+ extensions.push(makeExtension("platform", "developer", developerTools));
+ }
+ if (capabilities.code_browse) {
+ extensions.push(makeExtension("platform", "analyze", []));
+ }
+ if (capabilities.skills) {
+ extensions.push(makeExtension("platform", "skills", []));
+ }
+ if (capabilities.skills || capabilities.subagent) {
+ const summonTools = [];
+ if (capabilities.skills) summonTools.push("load", "load_skill");
+ if (capabilities.subagent) summonTools.push("delegate");
+ extensions.push(makeExtension("platform", "summon", summonTools));
+ }
+ }
+ if (capabilities.code_sandbox) {
+ extensions.push(makeExtension("platform", "code_execution", []));
+ }
+ if (capabilities.chat_recall) {
+ extensions.push(makeExtension("platform", "chatrecall", []));
+ }
+ if (capabilities.context_memory) {
+ extensions.push(makeExtension("platform", "projectmemory", []));
+ }
+ if (capabilities.memory_store) {
+ extensions.push(makeExtension("builtin", "memory", []));
+ }
+ if (capabilities.extension_admin) {
+ extensions.push(makeExtension("platform", "extensionmanager", []));
+ }
+ if (capabilities.apps) {
+ extensions.push(makeExtension("platform", "apps", []));
+ }
+ if (capabilities.todo) {
+ extensions.push(makeExtension("platform", "todo", []));
+ }
+ if (capabilities.computer) {
+ extensions.push(makeExtension("builtin", "computercontroller", []));
+ }
+ if (capabilities.charts) {
+ extensions.push(makeExtension("builtin", "autovisualiser", []));
+ }
+ if (capabilities.aider) {
+ extensions.push(makeExtension("platform", "aider", []));
+ }
+ if (capabilities.openhands) {
+ extensions.push(makeExtension("platform", "openhands", []));
+ }
+ return {
+ extensionOverrides: extensions,
+ enableContextMemory: Boolean(
+ capabilities.context_memory || capabilities.chat_recall || capabilities.static_publish
+ ),
+ gooseMode: resolveAgentGooseMode(capabilities, policies)
+ };
+}
+function buildPageEditAgentPolicy(basePolicy) {
+ if (basePolicy?.unrestricted) {
+ return {
+ ...basePolicy,
+ enableContextMemory: false,
+ gooseMode: "auto"
+ };
+ }
+ const baseDeveloper = basePolicy?.extensionOverrides?.find((ext) => ext.name === "developer");
+ const canShell = baseDeveloper?.available_tools?.includes("shell") || basePolicy?.capabilities?.shell === true;
+ const extensions = canShell ? [makeExtension("platform", "developer", ["shell"])] : [];
+ return {
+ ...basePolicy,
+ extensionOverrides: extensions,
+ enableContextMemory: false,
+ gooseMode: "auto"
+ };
+}
+function normalizeCapabilityPatch(patch) {
+ const normalized = {};
+ for (const [key, value] of Object.entries(patch ?? {})) {
+ if (!isValidCapabilityKey(key)) continue;
+ normalized[key] = Boolean(value);
+ }
+ return normalized;
+}
+
+// user-publish.mjs
+import fs3 from "node:fs";
+import path3 from "node:path";
+import { fileURLToPath as fileURLToPath3 } from "node:url";
+var __dirname2 = path3.dirname(fileURLToPath3(import.meta.url));
+var PUBLISH_SKILL_NAME = "static-page-publish";
+var PUBLISH_ROOT_DIR = "MindSpace";
+var PUBLIC_ZONE_DIR = "public";
+var PUBLISH_SKILL_DIR = path3.join(__dirname2, "skills", PUBLISH_SKILL_NAME);
+var WORKSPACE_HINTS_FILENAME = ".tkmindhints";
+var LEGACY_WORKSPACE_HINTS_FILENAME = ".goosehints";
+function renderBrandingBlock(userAddressName) {
+ const name = userAddressName || "\u7528\u6237";
+ return `## \u54C1\u724C\u4E0E\u79F0\u547C\uFF08\u786C\u6027\uFF09
+
+- \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4ECB\u7ECD\u4EA7\u54C1\u65F6\u7528 TKMind\uFF0C\u4E0D\u8981\u79F0 goose\u3001Goose\u3001goosed
+- \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\u6587\u4EF6\u7BA1\u7406\u4E0E\u9759\u6001\u9875\u9762\u751F\u6210
+`;
+}
+var INTERNAL_ID_PATTERN = /^wx_[a-z0-9_]{4,64}$|^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+function resolveUserAddressName({ displayName, username, slug } = {}) {
+ const preferred = String(displayName ?? "").trim();
+ if (preferred && !INTERNAL_ID_PATTERN.test(preferred)) return preferred;
+ const uname = String(username ?? "").trim();
+ if (uname && !INTERNAL_ID_PATTERN.test(uname)) return uname;
+ const fallback = String(slug ?? "").trim();
+ if (fallback && !INTERNAL_ID_PATTERN.test(fallback)) return fallback;
+ return "\u7528\u6237";
+}
+function resolvePublicBaseUrl(env = process.env) {
+ return (env.H5_PUBLIC_BASE_URL ?? "https://m.tkmind.cn").replace(/\/$/, "");
+}
+var PUBLISH_KEY_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+function resolvePublishKey(user) {
+ if (!user?.id) throw new Error("\u7F3A\u5C11\u7528\u6237 ID");
+ return String(user.id).trim().toLowerCase();
+}
+function resolveUsernameSlug(user) {
+ if (!user?.username) throw new Error("\u7F3A\u5C11\u7528\u6237\u540D");
+ return String(user.username).trim().toLowerCase();
+}
+function resolvePublishDir(h5Root, user) {
+ return path3.join(h5Root, PUBLISH_ROOT_DIR, resolvePublishKey(user));
+}
+function resolveLegacyPublishDir(h5Root, user) {
+ if (!user?.username) return null;
+ return path3.join(h5Root, PUBLISH_ROOT_DIR, resolveUsernameSlug(user));
+}
+function mergePublishTrees(sourceDir, targetDir) {
+ if (!fs3.existsSync(sourceDir)) return;
+ for (const entry of fs3.readdirSync(sourceDir, { withFileTypes: true })) {
+ const from = path3.join(sourceDir, entry.name);
+ const to = path3.join(targetDir, entry.name);
+ if (entry.isDirectory()) {
+ fs3.mkdirSync(to, { recursive: true });
+ mergePublishTrees(from, to);
+ } else if (!fs3.existsSync(to)) {
+ fs3.copyFileSync(from, to);
+ }
+ }
+}
+function migrateUserPublishDir(h5Root, user, legacyUsersRoot = null) {
+ const target = resolvePublishDir(h5Root, user);
+ fs3.mkdirSync(target, { recursive: true });
+ const legacyDirs = [
+ resolveLegacyPublishDir(h5Root, user),
+ legacyUsersRoot ? path3.join(legacyUsersRoot, resolveUsernameSlug(user)) : null
+ ].filter(Boolean);
+ for (const legacyDir of legacyDirs) {
+ if (path3.resolve(legacyDir) === path3.resolve(target)) continue;
+ mergePublishTrees(legacyDir, target);
+ }
+ return target;
+}
+function buildPublicUrl(publicBaseUrl, publishKey, filename = "") {
+ const base = `${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}`;
+ if (!filename) return `${base}/`;
+ const clean = filename.replace(/^\/+/, "");
+ return `${base}/${clean.split("/").map(encodeURIComponent).join("/")}`;
+}
+function buildPublicZonePageUrl(publicBaseUrl, publishKey, filename) {
+ const clean = String(filename ?? "").replace(/^\/+/, "");
+ const relative = clean.startsWith(`${PUBLIC_ZONE_DIR}/`) ? clean : `${PUBLIC_ZONE_DIR}/${clean}`;
+ return buildPublicUrl(publicBaseUrl, publishKey, relative);
+}
+function renderPublishSkill({ slug, username, publicBaseUrl, publishDir, displayName }) {
+ const addressName = resolveUserAddressName({ displayName, username, slug });
+ const exampleUrl = buildPublicZonePageUrl(publicBaseUrl, slug, "report.html");
+ return `---
+name: ${PUBLISH_SKILL_NAME}
+description: \u5728\u4E13\u5C5E MindSpace \u76EE\u5F55\u751F\u6210\u53EF\u516C\u5F00\u8BBF\u95EE\u7684\u9759\u6001 HTML \u62A5\u544A\u4E0E\u9875\u9762\uFF08TKMind H5\uFF09
+---
+
+# \u9759\u6001\u9875\u9762 / \u62A5\u544A\u53D1\u5E03\uFF08TKMind\uFF09
+
+\u5F53\u7528\u6237\u9700\u8981**\u7F51\u9875\u3001HTML \u62A5\u544A\u3001\u53EF\u89C6\u5316\u9875\u9762\u3001\u53EF\u5206\u4EAB\u94FE\u63A5**\u65F6\uFF0C**\u5FC5\u987B\u5148\u52A0\u8F7D\u672C\u6280\u80FD**\u5E76\u6309\u4EE5\u4E0B\u89C4\u5219\u6267\u884C\u3002
+
+## \u786C\u6027\u7EA6\u675F\uFF08\u4E0D\u53EF\u8FDD\u53CD\uFF09
+
+1. **\u552F\u4E00\u53EF\u5199\u76EE\u5F55**\uFF1A\`${publishDir}\`
+2. **\u7981\u6B62**\u4F7F\u7528\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u5982 \`/Users/...\`\u3001\`../\` \u8DF3\u51FA\u76EE\u5F55\uFF09
+3. **\u5141\u8BB8**\u5728\u672C\u76EE\u5F55\u5185\u4F7F\u7528 \`write_file\`\u3001\`edit_file\`\u3001\`read_file\`\u3001\`list_dir\`\uFF1B**\u7981\u6B62**\u8BBF\u95EE\u6B64\u76EE\u5F55\u5916\u7684\u8DEF\u5F84\uFF08\u542B \`../\`\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\u9879\u76EE\u6839\u76EE\u5F55\uFF1B\u7CFB\u7EDF\u4F1A\u5728 OS \u5C42\u62E6\u622A\u8D8A\u754C\u8BBF\u95EE\uFF09
+4. **\u7981\u6B62**\u5B50 Agent\u3001\u6269\u5C55\u7BA1\u7406\u3001\u4FEE\u6539\u5DE5\u4F5C\u533A\u5916\u6587\u4EF6
+5. \u9875\u9762\u9ED8\u8BA4\u5199\u5165 \`public/\` \u5206\u533A\uFF0C\u4F7F\u7528\u76F8\u5BF9\u8DEF\u5F84\u5982 \`public/report.html\`\u3001\`public/assets/chart.png\`
+6. \u751F\u6210 HTML \u540E\uFF0C\u5411\u7528\u6237\u63D0\u4F9B\u53EF\u8BBF\u95EE\u94FE\u63A5\uFF0C\u683C\u5F0F\uFF1A
+ \`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/<\u6587\u4EF6\u540D>\`
+ \uFF08\u5199\u5165 \`public/\u9875\u9762.html\` \u65F6 URL **\u5FC5\u987B**\u542B \`public/\`\uFF1B\u4EC5\u5F53 HTML \u76F4\u63A5\u5199\u5728 workspace \u6839\u76EE\u5F55\u65F6\u624D\u7701\u7565\uFF09
+
+## \u63A8\u8350\u5DE5\u4F5C\u6D41
+
+1. \u786E\u8BA4\u9700\u6C42\uFF08\u6807\u9898\u3001\u7AE0\u8282\u3001\u662F\u5426\u8981\u56FE\u8868/\u6837\u5F0F\uFF09
+2. \u4F7F\u7528 \`write_file\` \u521B\u5EFA \`public/\u9875\u9762.html\`\uFF08\u53EF\u542B\u5185\u8054 CSS\uFF1B\u9700\u8981\u65F6\u5728 \`public/assets/\` \u6216\u5DE5\u4F5C\u533A \`assets/\` \u653E\u8D44\u6E90\uFF09
+3. \u5728 \`\` \u5199\u5165 **mindspace-cover** \u5143\u6570\u636E\uFF08\u89C1\u4E0B\u6587\uFF0C\u5FC5\u987B\u4E0E\u9875\u9762\u4E3B\u9898\u4E00\u81F4\uFF09
+4. \u9875\u9762\u5185\u8D44\u6E90\u4F7F\u7528**\u76F8\u5BF9\u8DEF\u5F84**\uFF08\`assets/foo.png\`\uFF09\uFF0C\u4E0D\u8981\u7528\u78C1\u76D8\u7EDD\u5BF9\u8DEF\u5F84
+5. \u4FDD\u5B58 HTML \u540E\uFF0C\u670D\u52A1\u7AEF\u4F1A**\u7ACB\u5373**\u751F\u6210\u540C\u540D\u9884\u89C8\u56FE \`<\u6587\u4EF6\u540D>.thumbnail.svg\`\uFF08Agent \u4EA4\u4E92\u9636\u6BB5\u5373\u751F\u6548\uFF0C\u65E0\u9700\u7B49\u7528\u6237\u4FDD\u5B58\u5230\u300C\u6211\u7684\u7A7A\u95F4\u300D\uFF09
+6. \u5B8C\u6210\u540E\u6309\u300C\u56DE\u590D\u683C\u5F0F\u300D\u8FD4\u56DE\u53EF\u70B9\u51FB\u94FE\u63A5
+
+## \u56DE\u590D\u683C\u5F0F\uFF08\u5FC5\u987B\uFF09
+
+\u5411\u7528\u6237\u4EA4\u4ED8\u9875\u9762\u65F6\uFF0C**\u5FC5\u987B\u4F7F\u7528 Markdown \u53EF\u70B9\u51FB\u94FE\u63A5**\uFF0C\u8BA9\u7528\u6237\u5728\u804A\u5929\u91CC\u76F4\u63A5\u70B9\u5F00\u9884\u89C8\uFF1A
+
+\`\`\`markdown
+[\u666E\u62C9\u63D0 618 \u6D3B\u52A8\u9875](${exampleUrl})
+\`\`\`
+
+\u8981\u6C42\uFF1A
+- **\u5FC5\u987B**\u4F7F\u7528 \`[\u6807\u9898](URL)\` \u683C\u5F0F\uFF0C\u4E0D\u8981\u53EA\u7ED9\u88F8 URL
+- \u6807\u9898\u7528\u9875\u9762\u771F\u5B9E\u4E3B\u9898\uFF08\u4E0D\u8981\u7528\u300C\u70B9\u51FB\u8FD9\u91CC\u300D\u300C\u94FE\u63A5\u300D\uFF09
+- \u53EF\u540C\u65F6\u7ED9\u51FA\u672C\u5730\u76F8\u5BF9\u8DEF\u5F84\uFF08\u5982 \`public/pilates-618.html\`\uFF09\u4E0E\u516C\u7F51\u94FE\u63A5
+- \u8BF4\u660E\uFF1A\u9759\u6001\u6587\u4EF6\u4FDD\u5B58\u5373\u751F\u6548\uFF0C**\u65E0\u9700\u91CD\u542F**
+
+## \u4FE1\u606F\u6D41\u9884\u89C8\u56FE\uFF08\u5FC5\u987B\uFF09
+
+\u6BCF\u4E2A HTML \u9875\u9762\u90FD\u5FC5\u987B\u5728 \`\` \u5305\u542B\u4E0E**\u9875\u9762\u4E3B\u9898\u4E00\u81F4**\u7684\u9884\u89C8\u5143\u6570\u636E\u3002\u7CFB\u7EDF\u636E\u6B64\u751F\u6210 **\u7CBE\u7F8E\u7684 3:4 \u4FE1\u606F\u6D41\u5C01\u9762**\uFF08\u300C\u6211\u7684\u7A7A\u95F4\u300D\u5361\u7247 + \u5DE5\u4F5C\u533A \`.thumbnail.svg\` + \u4FDD\u5B58\u5F39\u7A97\u9884\u89C8\uFF09\uFF1A
+
+\`\`\`html
+
+
+\`\`\`
+
+| \u5B57\u6BB5 | \u8981\u6C42 |
+|------|------|
+| \`tag\` | \u4E0E\u9875\u9762\u4E3B\u9898\u4E00\u81F4\uFF1A\u65C5\u884C / \u7F8E\u98DF / \u62A5\u544A / **\u8FD0\u52A8** / **\u6D3B\u52A8** \u7B49\uFF1B\u51B3\u5B9A\u9ED8\u8BA4\u914D\u8272 |
+| \`accent\` / \`accent2\` | \u4ECE\u9875\u9762\u4E3B\u8272\u63D0\u53D6\uFF0C\u4E0E hero/\u80CC\u666F\u4E00\u81F4 |
+| \`subtitle\` | \u4E00\u53E5\u8BDD\u5356\u70B9\uFF1B\u672A\u5199\u65F6\u7528 description |
+| \`cover\` / \`image\` | **\u5FC5\u987B**\u6307\u5411\u9AD8\u8D28\u91CF\u4E3B\u56FE\uFF08\u76F8\u5BF9 HTML \u6216 https\uFF09\uFF1B\u89C1\u4E0B\u6587 |
+| \`emoji\` | \u53EF\u9009\uFF1B\u4E5F\u53EF\u5199\u5728 title \u4E2D |
+
+### \u7CBE\u7F8E\u9884\u89C8\u56FE\uFF08\u5FC5\u987B\u8FBE\u6807\uFF09
+
+\u4FDD\u5B58 HTML \u540E\uFF0C\u670D\u52A1\u7AEF\u4F1A**\u7ACB\u5373**\u751F\u6210\u540C\u540D\u9884\u89C8\u56FE \`<\u6587\u4EF6\u540D>.thumbnail.svg\`\uFF08Agent \u4EA4\u4E92\u9636\u6BB5\u5373\u751F\u6548\uFF0C\u65E0\u9700\u7B49\u7528\u6237\u4FDD\u5B58\u5230\u300C\u6211\u7684\u7A7A\u95F4\u300D\uFF09\u3002\u8981\u4EA7\u51FA**\u53EF\u5728\u4FE1\u606F\u6D41\u4E2D\u76F4\u63A5\u5C55\u793A\u7684\u7CBE\u7F8E\u5C01\u9762**\uFF0C\u5FC5\u987B\uFF1A
+
+1. **\u89C6\u89C9\u7C7B\u9875\u9762**\uFF08\u65C5\u884C\u3001\u7F8E\u98DF\u3001\u6D3B\u52A8\u3001\u8FD0\u52A8\u3001\u54C1\u724C\u3001\u4EA7\u54C1\u3001\u4FC3\u9500\u7B49\uFF09**\u5FC5\u987B**\u5728 \`assets/\` \u653E\u7F6E\u9AD8\u8D28\u91CF hero \u4E3B\u56FE\uFF08\u5EFA\u8BAE\u5BBD\u5EA6 \u22651200px\uFF09\uFF0C\u5E76\u5728 \`cover\` \u5B57\u6BB5\u5F15\u7528
+2. **\u7EAF\u6587\u5B57\u62A5\u544A**\u53EF\u4EC5\u7528\u914D\u8272 + tag\uFF0C\u4F46\u4ECD\u987B\u4FDD\u8BC1 \`accent\` / \`subtitle\` \u4E0E\u9875\u9762\u98CE\u683C\u4E00\u81F4
+3. \`tag\`\u3001\`accent\`\u3001\`accent2\`\u3001\`subtitle\` \u5FC5\u987B\u4E0E\u9875\u9762\u5B9E\u9645\u89C6\u89C9\u4E00\u81F4\uFF1B**\u7981\u6B62**\u7701\u7565 mindspace-cover \u6216\u586B\u65E0\u5173\u9ED8\u8BA4\u503C
+4. \u82E5\u7F3A\u5C11 hero \u4E3B\u56FE\uFF0C\u5C01\u9762\u4F1A\u9000\u5316\u4E3A\u7B80\u964B\u9ED8\u8BA4\u56FE\uFF0C**\u89C6\u4E3A\u672A\u8FBE\u6807**
+
+**\u7981\u6B62**\u4F7F\u7528\u4E0E\u9875\u9762\u65E0\u5173\u7684\u901A\u7528\u98CE\u666F\u56FE\u903B\u8F91\uFF1B\u4FC3\u9500/\u8FD0\u52A8/\u54C1\u724C\u9875\u5FC5\u987B\u5199\u660E \`tag\`\u3001\`accent\` \u548C \`cover\`\u3002
+
+## HTML \u6A21\u677F\u5EFA\u8BAE
+
+- \u5B8C\u6574 \`\`\uFF0C\`lang="zh-CN"\`
+- \u79FB\u52A8\u7AEF\u53CB\u597D\uFF1A\`\`
+- \u6DF1\u8272/\u6D45\u8272\u4E0E\u5185\u5BB9\u4E00\u81F4\uFF1B\u4E3B\u8272\u5728 CSS \u4E0E mindspace-cover \u4E2D\u4FDD\u6301\u4E00\u81F4
+
+## \u67E5\u627E\u6587\u4EF6\uFF08CSV\u3001\u6587\u6863\u7B49\uFF09
+
+- \u53EA\u5728**\u5F53\u524D\u5DE5\u4F5C\u533A**\u5185\u4ECE \`.\` \u641C\u7D22\uFF08\u5982 \`list_dir\`\u3001\`list_dir oa\`\uFF0C\u6216 shell \u53EF\u7528\u65F6 \`find . -name '*.csv'\`\uFF09
+- \u7528\u6237\u8BF4\u7684\u300COA \u533A\u300D\u7B49\u89C6\u4E3A\u5DE5\u4F5C\u533A**\u5B50\u76EE\u5F55**\uFF08\u5982 \`oa/\`\uFF09\uFF0C\u7528\u76F8\u5BF9\u8DEF\u5F84\u67E5\u627E
+- **\u7981\u6B62**\u641C\u7D22\u4E0A\u7EA7\u76EE\u5F55\u3001\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\`${PUBLISH_ROOT_DIR}\` \u6839\u76EE\u5F55\u3001\`/Users\`\u3001\u9879\u76EE\u6839\u76EE\u5F55
+- \u627E\u4E0D\u5230\u65F6\u544A\u77E5\u7528\u6237\u4E0A\u4F20\u6216\u63D0\u4F9B\u76F8\u5BF9\u8DEF\u5F84\uFF0C**\u4E0D\u8981**\u6269\u5927\u5230\u5DE5\u4F5C\u533A\u5916
+
+## \u7981\u6B62\u4E8B\u9879
+
+- \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
+
+## \u793A\u4F8B
+
+\u7528\u6237\uFF1A\u300C\u5199\u4E00\u4EFD\u8D8A\u5357\u65C5\u6E38\u6307\u5357\u7F51\u9875\u300D
+
+\u6B63\u786E\u505A\u6CD5\uFF1A
+- \`write_file\` \u2192 \`public/vietnam-guide.html\`\uFF08\u5185\u5BB9\u5B8C\u6574\uFF09
+- \u56DE\u590D\u94FE\u63A5\uFF1A\`${buildPublicZonePageUrl(publicBaseUrl, slug, "vietnam-guide.html")}\`
+`;
+}
+function renderWorkspaceHints({ slug, username, publishDir, displayName }) {
+ const addressName = resolveUserAddressName({ displayName, username, slug });
+ return `# TKMind \u7528\u6237\u5DE5\u4F5C\u533A\u8FB9\u754C
+
+\u4F60\u662F\u7528\u6237 **${addressName}** \u7684\u4E13\u5C5E TKMind \u52A9\u624B\u3002\u5F53\u524D\u4F1A\u8BDD**\u552F\u4E00**\u6587\u4EF6\u6839\u76EE\u5F55\uFF1A
+\`${publishDir}\`
+
+${renderBrandingBlock(addressName)}
+## \u67E5\u627E / \u8BFB\u53D6\u6587\u4EF6\uFF08CSV\u3001\u6587\u6863\u3001OA \u533A\u7B49\uFF09
+
+1. **\u53EA\u80FD**\u5728\u672C\u5DE5\u4F5C\u533A\u5185\u641C\u7D22\uFF0C\u4F8B\u5982 \`list_dir\`\u3001\`list_dir oa\`\uFF0C\u6216 shell \u53EF\u7528\u65F6 \`find . -name '*.csv'\`\u3001\`rg keyword .\`
+2. \u7528\u6237\u8BF4\u7684\u300COA \u533A\u300D\u300C\u6587\u6863\u533A\u300D\u7B49\uFF0C**\u5148\u5F53\u4F5C\u5DE5\u4F5C\u533A\u5185\u7684\u5B50\u76EE\u5F55**\uFF08\u5982 \`oa/\`\u3001\`docs/\`\uFF09\uFF0C\u53EA\u7528\u76F8\u5BF9\u8DEF\u5F84\u67E5\u627E
+3. **\u7EDD\u5BF9\u7981\u6B62**\uFF1A
+ - \u53BB\u4E0A\u7EA7\u76EE\u5F55\u3001\`${PUBLISH_ROOT_DIR}\` \u6839\u76EE\u5F55\u3001\u5176\u5B83\u7528\u6237\u540D\u76EE\u5F55\u3001\u9879\u76EE\u6839\u76EE\u5F55\u641C\u7D22
+ - \u4F7F\u7528 \`/Users\`\u3001\`/home\`\u3001Desktop\u3001Documents\u3001Downloads \u7B49\u4E3B\u673A\u8DEF\u5F84
+ - \u5728\u56DE\u590D\u4E2D\u5EFA\u8BAE\u300C\u5728 MindSpace \u76EE\u5F55\u6216\u9644\u8FD1\u76EE\u5F55\u641C\u7D22\u300D\u2014\u2014\u4F60\u6CA1\u6709\u8FD9\u4E2A\u6743\u9650
+4. \u627E\u4E0D\u5230\u6587\u4EF6\u65F6\uFF1A\u8BF4\u660E\u5728\u5176\u5DE5\u4F5C\u533A\u5185\u672A\u627E\u5230\uFF0C\u8BF7\u7528\u6237\u4E0A\u4F20\u6216\u63D0\u4F9B\u76F8\u5BF9\u8DEF\u5F84\uFF1B**\u4E0D\u5F97**\u6269\u5927\u5230\u5DE5\u4F5C\u533A\u5916
+
+## \u516C\u7F51\u94FE\u63A5 vs \u672C\u5730\u6587\u4EF6
+
+- \u516C\u7F51 URL\uFF08\u5982 \`https://\u2026/MindSpace/<\u7528\u6237>/\`\uFF09**\u4EC5\u4F9B\u7528\u6237\u6D4F\u89C8\u5668\u6253\u5F00\u5DF2\u53D1\u5E03\u7684 HTML \u9875\u9762**
+- **\u7981\u6B62**\u7528\u516C\u7F51 URL\u3001curl\u3001wget \u6216 read_image \u53BB\u300C\u5217\u76EE\u5F55\u300D\u300C\u8BFB CSV\u300D\u2014\u2014\u9759\u6001\u7AD9\u70B9\u4E0D\u63D0\u4F9B\u76EE\u5F55\u7D22\u5F15
+- \u6709 \`shell\` \u65F6\u7528 \`ls oa/\`\u3001\`find .\`\u3001\`cat oa/file.csv\`\uFF1B\u6709 \`tree\` \u65F6\u7528 \`tree oa/\`
+- \u4E0D\u8981\u8BF4\u300C\u6211\u6CA1\u6709 shell\u300D\u2014\u2014\u82E5\u7CFB\u7EDF\u63D0\u793A\u5DF2\u5217\u51FA shell\uFF0C\u5C31\u5FC5\u987B\u7528 shell \u5728\u5DE5\u4F5C\u533A\u5185\u64CD\u4F5C
+`;
+}
+function buildSandboxSessionConstraints({ baseConstraints, developerTools = [] }) {
+ const tools = developerTools.length > 0 ? developerTools : ["write_file", "edit_file"];
+ const hasSandboxMcp = tools.includes("write_file") || tools.includes("read_file");
+ const hasShell = tools.includes("shell");
+ const hasListDir = tools.includes("list_dir") || tools.includes("tree");
+ const lines = [
+ baseConstraints,
+ "",
+ "## \u5F53\u524D\u4F1A\u8BDD\u53EF\u7528\u6587\u4EF6\u5DE5\u5177",
+ `- ${tools.join(", ")}`,
+ hasSandboxMcp ? "- \u6587\u4EF6\u5DE5\u5177\u7531**\u6C99\u7BB1 MCP** \u63D0\u4F9B\uFF1A\u6240\u6709\u8DEF\u5F84\u5728 OS \u5C42\u5F3A\u5236\u9650\u5236\u5728\u5DE5\u4F5C\u533A\u5185\uFF0C\u8D8A\u754C\u8BBF\u95EE\u4F1A\u7ACB\u5373\u62A5\u9519" : ""
+ ].filter((l) => l !== "");
+ if (hasShell) {
+ lines.push(
+ '- **shell \u5DF2\u5F00\u542F**\uFF1A\u53EA\u80FD\u5728\u672C\u5DE5\u4F5C\u533A\u5185\u6267\u884C\uFF08\u5982 `ls oa/`\u3001`find . -name "*.csv"`\u3001`cat oa/\u6587\u4EF6.csv`\uFF09',
+ "- **\u7981\u6B62** curl/wget \u6216\u8BBF\u95EE\u516C\u7F51 URL \u6765\u8BFB\u53D6\u5DE5\u4F5C\u533A\u6587\u4EF6"
+ );
+ } else {
+ lines.push(
+ "- **shell \u672A\u5F00\u542F**\uFF1A\u4E0D\u80FD\u6267\u884C shell\uFF1B\u4E5F\u4E0D\u8981\u7528\u516C\u7F51 URL \u4EE3\u66FF\u672C\u5730\u8BFB\u6587\u4EF6"
+ );
+ }
+ if (hasListDir) {
+ lines.push("- **list_dir \u53EF\u7528**\uFF1A`list_dir` \u6216 `list_dir oa` \u67E5\u770B\u76EE\u5F55\u7ED3\u6784");
+ } else {
+ lines.push("- \u9700\u8981\u5217\u76EE\u5F55\u6216\u8BFB CSV \u65F6\uFF0C\u8BF7\u7528\u6237\u4E0A\u4F20\u6587\u4EF6\u5185\u5BB9\u6216\u5F00\u542F list_dir \u80FD\u529B");
+ }
+ lines.push(
+ "",
+ "## \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**\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"
+ );
+ return lines.join("\n");
+}
+function buildPublishConstraints({ slug, username, publicBaseUrl, publishDir, displayName }) {
+ const addressName = resolveUserAddressName({ displayName, username, slug });
+ return [
+ "## TKMind \u7528\u6237\u7A7A\u95F4\u6C99\u7BB1\uFF08\u786C\u6027\u7EA6\u675F\uFF09",
+ "",
+ "- \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\u7528 **" + addressName + "** \u79F0\u547C\u7528\u6237\uFF0C\u7981\u6B62\u628A\u7528\u6237\u53EB\u4F5C TKMind",
+ "- \u7981\u6B62\u79F0 goose / Goose / goosed \u6216\u300CRust goose \u9879\u76EE\u300D",
+ `- \u5F53\u524D\u7528\u6237 **${addressName}** \u7684 Agent \u5DE5\u4F5C\u533A\uFF08\u552F\u4E00\u6587\u4EF6\u6839\u76EE\u5F55\uFF09\uFF1A\`${publishDir}\``,
+ "- \u7528\u6237\u4E0A\u4F20\u843D\u5728\u5206\u533A\u5B50\u76EE\u5F55\uFF1A`oa/`\u3001`private/`\u3001`public/`\uFF08\u5747\u5728\u4E0A\u8FF0\u5DE5\u4F5C\u533A\u5185\uFF09",
+ `- \u516C\u7F51 HTML \u524D\u7F00\uFF08\u516C\u5F00\u533A\u9875\u9762\uFF09\uFF1A\`${buildPublicUrl(publicBaseUrl, slug, `${PUBLIC_ZONE_DIR}/`)}\``,
+ "- **\u67E5\u627E\u6587\u4EF6**\uFF1A\u5728\u5DE5\u4F5C\u533A\u5185\u5BF9\u5E94\u5206\u533A\u641C\u7D22\uFF08\u5982 `oa/2025-12-06T13-34_export.csv`\uFF09\uFF0C\u4E0D\u8981\u641C\u5176\u5B83\u7528\u6237\u76EE\u5F55\u6216\u516C\u7F51 URL",
+ "- **\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",
+ "- \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");
+}
+function ensureWorkspaceHintsInstalled(publishDir, context) {
+ const hintsPath = path3.join(publishDir, WORKSPACE_HINTS_FILENAME);
+ const legacyPath = path3.join(publishDir, LEGACY_WORKSPACE_HINTS_FILENAME);
+ const content = renderWorkspaceHints(context);
+ fs3.writeFileSync(hintsPath, content, "utf8");
+ if (fs3.existsSync(legacyPath)) {
+ fs3.unlinkSync(legacyPath);
+ }
+ return hintsPath;
+}
+function ensurePublishSkillInstalled(publishDir, context) {
+ const skillRoot = path3.join(publishDir, ".agents", "skills", PUBLISH_SKILL_NAME);
+ fs3.mkdirSync(skillRoot, { recursive: true });
+ const skillPath = path3.join(skillRoot, "SKILL.md");
+ const content = renderPublishSkill(context);
+ const existing = fs3.existsSync(skillPath) ? fs3.readFileSync(skillPath, "utf8") : "";
+ if (existing !== content) {
+ fs3.writeFileSync(skillPath, content, "utf8");
+ }
+ return skillPath;
+}
+function ensureUserPublishLayout({
+ h5Root,
+ publicBaseUrl,
+ user,
+ legacyUsersRoot = null,
+ installWorkspaceHints = false
+}) {
+ migrateUserPublishDir(h5Root, user, legacyUsersRoot);
+ const slug = resolvePublishKey(user);
+ const username = user.username ? resolveUsernameSlug(user) : slug;
+ const displayName = user.displayName ?? user.display_name ?? null;
+ const publishDir = resolvePublishDir(h5Root, user);
+ fs3.mkdirSync(path3.join(publishDir, "assets"), { recursive: true });
+ const context = { slug, username, displayName, publicBaseUrl, publishDir };
+ if (installWorkspaceHints) {
+ ensureWorkspaceHintsInstalled(publishDir, context);
+ ensurePublishSkillInstalled(publishDir, context);
+ }
+ return {
+ slug,
+ username,
+ displayName,
+ publishDir,
+ publicBaseUrl,
+ publicUrl: buildPublicUrl(publicBaseUrl, slug),
+ skillName: PUBLISH_SKILL_NAME,
+ constraints: buildPublishConstraints(context)
+ };
+}
+
+// user-memory-profile.mjs
+import fs4 from "node:fs";
+import path4 from "node:path";
+var USER_MEMORY_PROFILE_FILENAME = ".tkmind-profile.json";
+var USER_MEMORY_PROFILE_VERSION = 1;
+function buildInitialUserMemoryProfile({
+ userId,
+ displayName,
+ username,
+ slug,
+ now = Date.now()
+}) {
+ const addressName = resolveUserAddressName({ displayName, username, slug });
+ return {
+ version: USER_MEMORY_PROFILE_VERSION,
+ userId,
+ displayName: addressName,
+ language: "zh-CN",
+ responseStyle: "balanced",
+ preferences: [],
+ createdAt: now,
+ updatedAt: now
+ };
+}
+function resolveUserMemoryProfilePath(workspaceRoot) {
+ return path4.join(workspaceRoot, USER_MEMORY_PROFILE_FILENAME);
+}
+function loadUserMemoryProfile(workspaceRoot) {
+ const profilePath = resolveUserMemoryProfilePath(workspaceRoot);
+ if (!fs4.existsSync(profilePath)) return null;
+ try {
+ const raw = fs4.readFileSync(profilePath, "utf8");
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== "object") return null;
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+function ensureUserMemoryProfile(workspaceRoot, context) {
+ const profilePath = resolveUserMemoryProfilePath(workspaceRoot);
+ const existing = loadUserMemoryProfile(workspaceRoot);
+ if (existing) {
+ const addressName = resolveUserAddressName(context);
+ const nextDisplayName = addressName || existing.displayName;
+ if (nextDisplayName && nextDisplayName !== existing.displayName) {
+ const updated = {
+ ...existing,
+ displayName: nextDisplayName,
+ updatedAt: Date.now()
+ };
+ fs4.writeFileSync(profilePath, `${JSON.stringify(updated, null, 2)}
+`, "utf8");
+ return updated;
+ }
+ return existing;
+ }
+ const profile = buildInitialUserMemoryProfile({
+ userId: context.userId,
+ displayName: context.displayName,
+ username: context.username,
+ slug: context.slug
+ });
+ fs4.writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}
+`, "utf8");
+ return profile;
+}
+function renderMemoryStoreGuidance({ addressName }) {
+ const name = addressName || "\u7528\u6237";
+ return [
+ "## TKMind \u957F\u671F\u8BB0\u5FC6\uFF08L3 / memory \u6269\u5C55\uFF09",
+ "",
+ `- \u5F53\u524D\u670D\u52A1\u5BF9\u8C61\uFF1A**${name}**\uFF08\u4EC5\u6B64\u7528\u6237\uFF0C\u4E0D\u5F97\u4E0E\u5176\u4ED6\u7528\u6237\u6DF7\u6DC6\uFF09`,
+ "- \u4F7F\u7528 memory \u5DE5\u5177\u8BB0\u5F55\u7528\u6237**\u660E\u786E\u8868\u8FBE**\u7684\u504F\u597D\u3001\u4E60\u60EF\u4E0E\u7A33\u5B9A\u4E8B\u5B9E",
+ "- \u8BB0\u5FC6\u5206\u7C7B\u5EFA\u8BAE\uFF1A",
+ " - `preference`\uFF1A\u56DE\u590D\u98CE\u683C\u3001\u683C\u5F0F\u3001\u8BED\u8A00\u3001\u79F0\u547C\u4E60\u60EF",
+ " - `project`\uFF1A\u5F53\u524D\u9879\u76EE\u76EE\u6807\u3001\u7EA6\u5B9A\u3001\u6280\u672F\u9009\u578B\uFF08\u4E0E L2 \u9879\u76EE\u8BB0\u5FC6\u4E92\u8865\uFF09",
+ " - `fact`\uFF1A\u957F\u671F\u7A33\u5B9A\u3001\u53EF\u590D\u7528\u7684\u4E8B\u5B9E\uFF08\u5982\u90E8\u95E8\u3001\u5E38\u7528\u5DE5\u5177\uFF09",
+ "- \u7528\u6237\u8BF4\u300C\u5FD8\u8BB0\u2026\u300D\u300C\u4E0D\u8981\u518D\u2026\u300D\u300C\u53D6\u6D88\u504F\u597D\u2026\u300D\u65F6\uFF0C\u5FC5\u987B\u66F4\u65B0\u6216\u5220\u9664\u5BF9\u5E94\u8BB0\u5FC6",
+ "- **\u7981\u6B62**\u8BB0\u5F55\u5176\u4ED6\u7528\u6237\u7684\u4FE1\u606F\uFF1B**\u7981\u6B62**\u731C\u6D4B\u672A\u660E\u786E\u8868\u8FBE\u7684\u504F\u597D",
+ "- \u4E0E L2 \u9879\u76EE\u8BB0\u5FC6\uFF08harness\uFF09\u5206\u5DE5\uFF1AL2 \u8BB0\u8FD1\u671F\u5DE5\u4F5C\u4E0E\u51B3\u7B56\u6458\u8981\uFF1BL3 \u8BB0\u53EF\u8DE8\u4F1A\u8BDD\u590D\u7528\u7684\u4E2A\u4EBA\u504F\u597D"
+ ].join("\n");
+}
+function renderPreferenceLines(profile) {
+ const items = Array.isArray(profile?.preferences) ? profile.preferences : [];
+ if (items.length === 0) {
+ return ["- \uFF08\u6682\u65E0\u7ED3\u6784\u5316\u504F\u597D \u2014 \u53EF\u5728\u5BF9\u8BDD\u4E2D\u7528 memory \u5DE5\u5177\u8865\u5145\uFF09"];
+ }
+ return items.map((item) => {
+ if (typeof item === "string") return `- ${item}`;
+ const category = item?.category ? `[${item.category}] ` : "";
+ const label = item?.label ?? item?.key ?? "preference";
+ const value = item?.value ?? item?.content ?? "";
+ return `- ${category}${label}\uFF1A${value}`;
+ });
+}
+function renderUserMemoryProfileForHarness(profile, context = {}) {
+ const addressName = resolveUserAddressName({
+ displayName: profile?.displayName ?? context.displayName,
+ username: context.username,
+ slug: context.slug
+ });
+ const language = profile?.language === "zh-CN" ? "\u7B80\u4F53\u4E2D\u6587" : profile?.language ?? "\u7B80\u4F53\u4E2D\u6587";
+ const styleMap = {
+ concise: "\u7B80\u6D01\u76F4\u63A5\uFF0C\u5C11\u5E9F\u8BDD",
+ detailed: "\u8BE6\u7EC6\u5B8C\u6574\uFF0C\u5FC5\u8981\u65F6\u5C55\u5F00",
+ balanced: "\u7B80\u6D01\u52A1\u5B9E\uFF0C\u5FC5\u8981\u65F6\u8865\u5145\u7EC6\u8282"
+ };
+ const responseStyle = styleMap[profile?.responseStyle] ?? styleMap.balanced;
+ return [
+ "## TKMind \u7528\u6237\u504F\u597D\u753B\u50CF\uFF08L3\uFF09",
+ "",
+ `- \u7528\u6237\u79F0\u547C\uFF1A**${addressName}**`,
+ `- \u754C\u9762\u8BED\u8A00\uFF1A${language}`,
+ `- \u9ED8\u8BA4\u56DE\u590D\u98CE\u683C\uFF1A${responseStyle}`,
+ "",
+ "### \u5DF2\u8BB0\u5F55\u7684\u7ED3\u6784\u5316\u504F\u597D",
+ ...renderPreferenceLines(profile)
+ ].join("\n");
+}
+function buildSessionMemoryEntries({
+ workingDir,
+ sessionPolicy,
+ sandboxConstraints = null,
+ userContext = null
+}) {
+ const entries = [];
+ if (sandboxConstraints?.trim()) {
+ entries.push({
+ title: "TKMind \u7528\u6237\u7A7A\u95F4\u6C99\u7BB1",
+ content: sandboxConstraints.trim()
+ });
+ }
+ const executorGuidance = renderCodeExecutorGuidance(sessionPolicy);
+ if (executorGuidance) {
+ entries.push({
+ title: "TKMind \u4EE3\u7801\u59D4\u6258\u7B56\u7565",
+ content: executorGuidance
+ });
+ }
+ if (!hasMemoryStore(sessionPolicy)) {
+ return entries;
+ }
+ const profile = userContext?.userId ? ensureUserMemoryProfile(workingDir, userContext) : loadUserMemoryProfile(workingDir);
+ const addressName = resolveUserAddressName({
+ displayName: profile?.displayName ?? userContext?.displayName,
+ username: userContext?.username,
+ slug: userContext?.slug
+ });
+ entries.push({
+ title: "TKMind \u957F\u671F\u8BB0\u5FC6\u89C4\u5219",
+ content: renderMemoryStoreGuidance({ addressName })
+ });
+ if (profile) {
+ entries.push({
+ title: "TKMind \u7528\u6237\u504F\u597D\u753B\u50CF",
+ content: renderUserMemoryProfileForHarness(profile, userContext ?? {})
+ });
+ }
+ return entries;
+}
+function hasMemoryStore(sessionPolicy) {
+ return (sessionPolicy?.extensionOverrides ?? []).some((ext) => ext.name === "memory");
+}
+function availableDelegateExecutors(sessionPolicy) {
+ const names = new Set((sessionPolicy?.extensionOverrides ?? []).map((ext) => ext.name));
+ return ["aider", "openhands"].filter((name) => names.has(name));
+}
+function resolveCodeExecutorRouting(sessionPolicy) {
+ const available = availableDelegateExecutors(sessionPolicy);
+ const configuredPreferred = String(
+ sessionPolicy?.policies?.code_delegate_executor ?? "auto"
+ ).trim();
+ const routing = String(sessionPolicy?.policies?.code_task_routing ?? "balanced").trim();
+ const preferred = (configuredPreferred === "aider" || configuredPreferred === "openhands") && available.includes(configuredPreferred) ? configuredPreferred : "auto";
+ const rules = [];
+ if (routing === "split") {
+ rules.push("\u5C0F\u8303\u56F4\u8865\u4E01\u3001\u5C40\u90E8\u4FEE\u590D\u3001\u5C11\u6587\u4EF6\u4FEE\u6539\u4F18\u5148 `aider`\u3002");
+ rules.push("\u590D\u6742\u591A\u6587\u4EF6\u6539\u9020\u3001\u4ED3\u5E93\u63A2\u7D22\u3001\u8F83\u91CD\u7684\u547D\u4EE4\u6267\u884C\u4F18\u5148 `openhands`\u3002");
+ } else if (routing === "force_aider") {
+ rules.push("\u53EA\u8981\u4EFB\u52A1\u80FD\u7531 `aider` \u80DC\u4EFB\uFF0C\u5C31\u4F18\u5148\u7EDF\u4E00\u8D70 `aider`\u3002");
+ rules.push("\u53EA\u6709 `aider` \u660E\u663E\u65E0\u6CD5\u80DC\u4EFB\u65F6\u624D\u56DE\u9000\u5230 `openhands`\u3002");
+ } else if (routing === "force_openhands") {
+ rules.push("\u53EA\u8981\u4EFB\u52A1\u9700\u8981\u4EE3\u7801\u59D4\u6258\uFF0C\u5C31\u4F18\u5148\u7EDF\u4E00\u8D70 `openhands`\u3002");
+ rules.push("\u53EA\u6709\u4EFB\u52A1\u660E\u663E\u66F4\u9002\u5408\u8F7B\u91CF\u8865\u4E01\u65F6\u624D\u56DE\u9000\u5230 `aider`\u3002");
+ } else {
+ rules.push("\u7531 Goose \u6839\u636E\u4EFB\u52A1\u590D\u6742\u5EA6\u3001\u6D89\u53CA\u6587\u4EF6\u6570\u3001\u662F\u5426\u9700\u8981\u4ED3\u5E93\u63A2\u7D22\u548C\u547D\u4EE4\u6267\u884C\uFF0C\u5728 `aider` \u4E0E `openhands` \u4E4B\u95F4\u5E73\u8861\u9009\u62E9\u3002");
+ }
+ if (preferred !== "auto") {
+ rules.unshift(`\u540E\u53F0\u4F18\u5148\u6267\u884C\u5668\uFF1A**${preferred}**\u3002`);
+ } else {
+ rules.unshift("\u540E\u53F0\u4F18\u5148\u6267\u884C\u5668\uFF1A`auto`\uFF0C\u7531 Goose \u7ED3\u5408\u4EFB\u52A1\u7279\u5F81\u51B3\u5B9A\u3002");
+ }
+ return {
+ available,
+ preferred,
+ routing,
+ rules
+ };
+}
+function containsAny(text, patterns) {
+ return patterns.some((pattern) => pattern.test(text));
+}
+function suggestCodeExecutorForTask(taskText, sessionPolicy) {
+ const text = String(taskText ?? "").trim().toLowerCase();
+ if (!text) return null;
+ const routing = resolveCodeExecutorRouting(sessionPolicy);
+ if (routing.available.length === 0) return null;
+ const codingSignals = [
+ /bug|fix|debug|refactor|feature|repo|repository|code|patch|test|compile|build/,
+ /修复|改代码|重构|功能|仓库|代码|补丁|测试|编译|构建|多文件|命令|脚本/
+ ];
+ if (!containsAny(text, codingSignals)) {
+ return null;
+ }
+ let aiderScore = 0;
+ let openhandsScore = 0;
+ if (containsAny(text, [/small|minor|tiny|simple|one file|single file|quick patch/, /小改|微调|简单修复|单文件|一个文件|快速修复/])) {
+ aiderScore += 2;
+ }
+ if (containsAny(text, [/refactor|multi-?file|repo|repository|end-to-end|investigate|explore/, /重构|多文件|仓库级|全链路|排查|探索代码库/])) {
+ openhandsScore += 2;
+ }
+ if (containsAny(text, [/run|command|terminal|shell|build|compile|test suite/, /执行命令|终端|shell|构建|编译|整套测试/])) {
+ openhandsScore += 1;
+ }
+ if (containsAny(text, [/rename|edit|patch|tweak/, /修改一下|补丁|小范围调整|局部编辑/])) {
+ aiderScore += 1;
+ }
+ if (routing.routing === "split") {
+ aiderScore += 1;
+ openhandsScore += 1;
+ } else if (routing.routing === "force_aider") {
+ aiderScore += 3;
+ } else if (routing.routing === "force_openhands") {
+ openhandsScore += 3;
+ }
+ if (routing.preferred === "aider") aiderScore += 2;
+ if (routing.preferred === "openhands") openhandsScore += 2;
+ let suggested = null;
+ if (openhandsScore > aiderScore && routing.available.includes("openhands")) {
+ suggested = "openhands";
+ } else if (aiderScore > openhandsScore && routing.available.includes("aider")) {
+ suggested = "aider";
+ } else if (routing.preferred !== "auto" && routing.available.includes(routing.preferred)) {
+ suggested = routing.preferred;
+ } else if (routing.routing === "split" && routing.available.includes("aider") && routing.available.includes("openhands")) {
+ suggested = openhandsScore >= aiderScore ? "openhands" : "aider";
+ } else {
+ suggested = routing.available[0] ?? null;
+ }
+ if (!suggested) return null;
+ const reason = suggested === "openhands" ? "\u4EFB\u52A1\u770B\u8D77\u6765\u66F4\u50CF\u590D\u6742\u591A\u6587\u4EF6\u6539\u9020\u3001\u4ED3\u5E93\u63A2\u7D22\u6216\u9700\u8981\u66F4\u591A\u547D\u4EE4\u6267\u884C\u3002" : "\u4EFB\u52A1\u770B\u8D77\u6765\u66F4\u50CF\u5C40\u90E8\u8865\u4E01\u3001\u5C0F\u8303\u56F4\u4FEE\u590D\u6216\u8F83\u8F7B\u91CF\u7684\u4EE3\u7801\u4FEE\u6539\u3002";
+ return {
+ suggestedExecutor: suggested,
+ reason,
+ routing
+ };
+}
+function buildTaskRoutingAgentText(taskText, sessionPolicy) {
+ const suggestion = suggestCodeExecutorForTask(taskText, sessionPolicy);
+ if (!suggestion) return String(taskText ?? "").trim();
+ return [
+ "\u3010TKMind \u8DEF\u7531\u63D0\u793A\u3011\u4EE5\u4E0B\u63D0\u793A\u4EC5\u7528\u4E8E\u6267\u884C\u5668\u7F16\u6392\uFF0C\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0\u3002",
+ `\u5F53\u524D\u4EE3\u7801\u4EFB\u52A1\u5EFA\u8BAE\u4F18\u5148\u59D4\u6258\u7ED9\uFF1A${suggestion.suggestedExecutor}\u3002`,
+ `\u539F\u56E0\uFF1A${suggestion.reason}`,
+ "\u82E5\u9996\u9009\u6267\u884C\u5668\u5F53\u524D\u4E0D\u53EF\u7528\u6216\u660E\u663E\u4E0D\u9002\u5408\uFF0C\u53EF\u56DE\u9000\u5230\u53E6\u4E00\u4E2A\u5DF2\u6388\u6743\u6267\u884C\u5668\uFF0C\u5E76\u5728\u6700\u7EC8\u56DE\u590D\u91CC\u7B80\u8FF0\u539F\u56E0\u3002",
+ "",
+ String(taskText ?? "").trim()
+ ].join("\n");
+}
+function renderCodeExecutorGuidance(sessionPolicy) {
+ const { available, preferred, routing, rules } = resolveCodeExecutorRouting(sessionPolicy);
+ if (available.length === 0) return "";
+ const availableText = available.join(" / ");
+ const lines = [
+ "## TKMind \u4EE3\u7801\u59D4\u6258\u6267\u884C\u5668\u8DEF\u7531",
+ "",
+ `- \u5F53\u524D\u53EF\u7528\u7684\u4EE3\u7801\u59D4\u6258\u6267\u884C\u5668\uFF1A${availableText}`
+ ];
+ if (preferred === "aider" || preferred === "openhands") {
+ lines.push(`- \u540E\u53F0\u7B56\u7565\u8981\u6C42\uFF1A\u591A\u6587\u4EF6\u7F16\u7801\u4EFB\u52A1\u4F18\u5148\u4F7F\u7528 **${preferred}**\u3002`);
+ lines.push(
+ "- \u5982\u679C\u9996\u9009\u6267\u884C\u5668\u5F53\u524D\u4E0D\u53EF\u7528\u3001\u65E0\u6CD5\u5B8C\u6210\u4EFB\u52A1\u3001\u6216\u4EFB\u52A1\u660E\u663E\u66F4\u9002\u5408\u53E6\u4E00\u6267\u884C\u5668\uFF0C\u53EF\u56DE\u9000\u5230\u53E6\u4E00\u4E2A\u5DF2\u6388\u6743\u6267\u884C\u5668\uFF0C\u5E76\u5728\u56DE\u590D\u4E2D\u8BF4\u660E\u539F\u56E0\u3002"
+ );
+ } else {
+ lines.push("- \u540E\u53F0\u7B56\u7565\u8981\u6C42\uFF1A\u7531 Goose \u6839\u636E\u4EFB\u52A1\u590D\u6742\u5EA6\u5728\u5DF2\u6388\u6743\u6267\u884C\u5668\u4E2D\u81EA\u52A8\u9009\u62E9\u3002");
+ }
+ lines.push(`- \u4EFB\u52A1\u8DEF\u7531\u6A21\u5F0F\uFF1A\`${routing}\``);
+ for (const rule of rules) {
+ lines.push(`- ${rule}`);
+ }
+ lines.push("- \u82E5\u5F53\u524D\u4EFB\u52A1\u4E0D\u9700\u8981\u59D4\u6258\u7F16\u7801\u6267\u884C\u5668\uFF0C\u53EF\u7EE7\u7EED\u76F4\u63A5\u4F7F\u7528 Goose \u81EA\u8EAB\u5DE5\u5177\u5B8C\u6210\u3002");
+ return lines.join("\n");
+}
+
+// session-reconcile.mjs
+import path5 from "node:path";
+function extensionName(config) {
+ return config?.name ?? null;
+}
+function allowedExtensionNames(extensionOverrides) {
+ if (!extensionOverrides) return null;
+ return new Set(extensionOverrides.map((item) => item.name).filter(Boolean));
+}
+function extensionToolsKey(config) {
+ const tools = config?.available_tools ?? config?.availableTools ?? [];
+ return [...tools].sort().join("\0");
+}
+function extensionConfigsMatch(sessionExt, desiredConfig) {
+ if (extensionName(sessionExt) !== extensionName(desiredConfig)) return false;
+ return extensionToolsKey(sessionExt) === extensionToolsKey(desiredConfig);
+}
+function extensionsNeedingRefresh(currentExtensions, desiredExtensions) {
+ const current = currentExtensions ?? [];
+ const desired = desiredExtensions ?? [];
+ const toRemove = [];
+ const toAdd = [];
+ for (const ext of current) {
+ const name = extensionName(ext);
+ if (!name) continue;
+ const wanted = desired.find((item) => extensionName(item) === name);
+ if (!wanted) {
+ toRemove.push(name);
+ continue;
+ }
+ if (!extensionConfigsMatch(ext, wanted)) {
+ toRemove.push(name);
+ toAdd.push(wanted);
+ }
+ }
+ for (const config of desired) {
+ const name = extensionName(config);
+ if (!name) continue;
+ const exists = current.some((ext) => extensionName(ext) === name);
+ if (!exists) {
+ toAdd.push(config);
+ }
+ }
+ return { toRemove, toAdd };
+}
+function samePath(left, right) {
+ if (!left || !right) return false;
+ return path5.resolve(left) === path5.resolve(right);
+}
+async function readJson(upstream) {
+ const text = await upstream.text();
+ if (!upstream.ok) {
+ throw new Error(text || `upstream ${upstream.status}`);
+ }
+ return text ? JSON.parse(text) : null;
+}
+function isInvalidDirectoryPathError(err) {
+ return err instanceof Error && /Invalid directory path/i.test(err.message);
+}
+async function reconcileAgentSession(apiFetch2, sessionId, {
+ workingDir,
+ sessionPolicy,
+ sandboxConstraints = null,
+ userContext = null,
+ tolerateInvalidWorkingDir = false
+}) {
+ if (sessionPolicy?.unrestricted) return;
+ const session = await readJson(await apiFetch2(`/sessions/${sessionId}`));
+ let needsRestart = false;
+ if (workingDir && !samePath(session?.working_dir, workingDir)) {
+ try {
+ await readJson(
+ await apiFetch2("/agent/update_working_dir", {
+ method: "POST",
+ body: JSON.stringify({ session_id: sessionId, working_dir: workingDir })
+ })
+ );
+ } catch (err) {
+ if (!(tolerateInvalidWorkingDir && isInvalidDirectoryPathError(err))) {
+ throw err;
+ }
+ console.warn(
+ `[session-reconcile] skip invalid working_dir during resume for session ${sessionId}: ${err.message}`
+ );
+ }
+ }
+ if (sessionPolicy?.gooseMode && session?.goose_mode !== sessionPolicy.gooseMode) {
+ await readJson(
+ await apiFetch2("/agent/update_session", {
+ method: "POST",
+ body: JSON.stringify({
+ session_id: sessionId,
+ goose_mode: sessionPolicy.gooseMode
+ })
+ })
+ );
+ needsRestart = true;
+ }
+ const desired = sessionPolicy?.extensionOverrides ?? [];
+ const allowed = allowedExtensionNames(desired);
+ const currentPayload = await readJson(await apiFetch2(`/sessions/${sessionId}/extensions`));
+ const current = currentPayload?.extensions ?? [];
+ let removedAny = false;
+ for (const ext of current) {
+ const name = extensionName(ext);
+ if (!name || allowed.has(name)) continue;
+ await readJson(
+ await apiFetch2("/agent/remove_extension", {
+ method: "POST",
+ body: JSON.stringify({ session_id: sessionId, name })
+ })
+ );
+ removedAny = true;
+ }
+ const refreshed = removedAny ? (await readJson(await apiFetch2(`/sessions/${sessionId}/extensions`)))?.extensions ?? [] : current;
+ const { toRemove, toAdd } = extensionsNeedingRefresh(refreshed, desired);
+ for (const name of toRemove) {
+ await readJson(
+ await apiFetch2("/agent/remove_extension", {
+ method: "POST",
+ body: JSON.stringify({ session_id: sessionId, name })
+ })
+ );
+ needsRestart = true;
+ }
+ for (const config of toAdd) {
+ await readJson(
+ await apiFetch2("/agent/add_extension", {
+ method: "POST",
+ body: JSON.stringify({ session_id: sessionId, config })
+ })
+ );
+ needsRestart = true;
+ }
+ if (needsRestart) {
+ await readJson(
+ await apiFetch2("/agent/restart", {
+ method: "POST",
+ body: JSON.stringify({ session_id: sessionId })
+ })
+ );
+ }
+ const sandboxText = sandboxConstraints?.trim() ? buildSandboxSessionConstraints({
+ baseConstraints: sandboxConstraints,
+ developerTools: developerToolsFromPolicy(sessionPolicy)
+ }) : null;
+ const memoryEntries = buildSessionMemoryEntries({
+ workingDir,
+ sessionPolicy,
+ sandboxConstraints: sandboxText,
+ userContext
+ });
+ if (memoryEntries.length > 0) {
+ for (const entry of memoryEntries) {
+ if (!entry.content?.trim()) continue;
+ await apiFetch2("/agent/harness_remember", {
+ method: "POST",
+ body: JSON.stringify({
+ sessionId,
+ content: entry.content,
+ title: entry.title
+ })
+ });
+ }
+ await apiFetch2("/agent/harness_bootstrap", {
+ method: "POST",
+ body: JSON.stringify({
+ sessionId,
+ force: true
+ })
+ });
+ }
+}
+
+// tkmind-proxy.mjs
+var insecureDispatcher = new Agent({
+ connect: { rejectUnauthorized: false }
+});
+function isHttpsTarget(target) {
+ return target.startsWith("https://");
+}
+function sanitizeUserFacingProxyMessage(message, fallback = "\u540E\u7AEF\u8FDE\u63A5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5") {
+ const normalized = String(message ?? "").trim();
+ if (!normalized) return fallback;
+ if (!/goose|goosed/i.test(normalized)) return normalized;
+ if (/超时|timeout/i.test(normalized)) {
+ return "\u540E\u7AEF\u8FDE\u63A5\u8D85\u65F6\uFF0C\u8BF7\u786E\u8BA4\u540E\u7AEF\u670D\u52A1\u6B63\u5E38\u540E\u91CD\u8BD5";
+ }
+ if (/不可用|连接失败|failed to fetch|networkerror|fetch failed|upstream|econn|enotfound/i.test(normalized)) {
+ return fallback;
+ }
+ return normalized.replace(/\bgoosed\b/gi, "\u540E\u7AEF\u670D\u52A1").replace(/\bgoose\b/gi, "\u540E\u7AEF");
+}
+async function apiFetch(target, apiSecret, pathname, init = {}) {
+ const url = new URL(pathname, target);
+ const headers = {
+ ...init.headers ?? {},
+ "X-Secret-Key": apiSecret
+ };
+ if (init.body && !headers["Content-Type"]) {
+ headers["Content-Type"] = "application/json";
+ }
+ return undiciFetch(url, {
+ ...init,
+ headers,
+ dispatcher: isHttpsTarget(target) ? insecureDispatcher : void 0
+ });
+}
+async function readJsonBody(req) {
+ if (req.method === "GET" || req.method === "HEAD") return null;
+ const chunks = [];
+ for await (const chunk of req) {
+ chunks.push(chunk);
+ }
+ if (chunks.length === 0) return null;
+ const raw = Buffer.concat(chunks).toString("utf8");
+ if (!raw.trim()) return null;
+ return JSON.parse(raw);
+}
+function sendProxyResponse(res, upstream) {
+ res.status(upstream.status);
+ upstream.headers.forEach((value, key) => {
+ if (key === "transfer-encoding") return;
+ res.setHeader(key, value);
+ });
+ if (!upstream.body) {
+ res.end();
+ return;
+ }
+ Readable.fromWeb(upstream.body).pipe(res);
+}
+function extractSessionId(req, body) {
+ const fromParams = req.params?.sessionId ?? req.params?.id;
+ if (fromParams) return fromParams;
+ if (body?.session_id) return body.session_id;
+ if (body?.sessionId) return body.sessionId;
+ return null;
+}
+function firstUserText(message) {
+ return message?.content?.find?.((item) => item?.type === "text" && typeof item.text === "string")?.text ?? "";
+}
+var IMAGE_URL_LINE_RE = /^\[图片\d+]:\s*(\S.+)$/;
+function extractImageUrlsFromMessage(userMessage) {
+ const urls = [];
+ const imageUrls = userMessage?.metadata?.imageUrls;
+ if (Array.isArray(imageUrls)) {
+ urls.push(...imageUrls);
+ }
+ const content = userMessage?.content;
+ if (Array.isArray(content)) {
+ for (const item of content) {
+ if (item?.type === "image_url" && item.image_url?.url) {
+ urls.push(item.image_url.url);
+ continue;
+ }
+ if (item?.type !== "text" || typeof item.text !== "string") continue;
+ for (const line of item.text.split("\n")) {
+ const match = line.trim().match(IMAGE_URL_LINE_RE);
+ if (match?.[1]) urls.push(match[1].trim());
+ }
+ }
+ }
+ return [...new Set(urls.filter((url) => typeof url === "string" && url.trim()))];
+}
+function messageHasImages(userMessage) {
+ return extractImageUrlsFromMessage(userMessage).length > 0;
+}
+function injectTaskRoutingHint(body, sessionPolicy) {
+ const originalText = firstUserText(body?.user_message);
+ if (!originalText?.trim()) return body;
+ const agentText = buildTaskRoutingAgentText(originalText, sessionPolicy);
+ if (!agentText || agentText === originalText) return body;
+ const userMessage = body.user_message ?? {};
+ const content = Array.isArray(userMessage.content) ? [...userMessage.content] : [];
+ const firstTextIndex = content.findIndex(
+ (item) => item?.type === "text" && typeof item.text === "string"
+ );
+ if (firstTextIndex >= 0) {
+ content[firstTextIndex] = { ...content[firstTextIndex], text: agentText };
+ } else {
+ content.unshift({ type: "text", text: agentText });
+ }
+ return {
+ ...body,
+ user_message: {
+ ...userMessage,
+ content,
+ metadata: {
+ ...userMessage.metadata ?? {},
+ displayText: userMessage.metadata?.displayText && String(userMessage.metadata.displayText).trim() ? userMessage.metadata.displayText : originalText
+ }
+ }
+ };
+}
+async function buildVisionPayload({
+ userMessage,
+ userId,
+ publishLayout,
+ localFetchAsset,
+ llmProviderService: llmProviderService2
+}) {
+ if (!localFetchAsset || !llmProviderService2 || !userId) return null;
+ const rawImageUrls = extractImageUrlsFromMessage(userMessage);
+ if (rawImageUrls.length === 0) return null;
+ 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);
+ let relativePath = rawUrl;
+ try {
+ const parsed = new URL(rawUrl);
+ relativePath = parsed.pathname + parsed.search;
+ } catch {
+ }
+ imageItems.push({ mimeType, data: buffer.toString("base64"), relativePath, rawUrl });
+ } 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}:
`).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
+${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";
+ 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);
+ return updated === c.text ? c : { ...c, text: updated };
+ });
+ }
+ const lastTextIdx = updatedContent.reduceRight(
+ (found, item, i) => found === -1 && item?.type === "text" ? i : found,
+ -1
+ );
+ if (lastTextIdx >= 0) {
+ updatedContent[lastTextIdx] = {
+ ...updatedContent[lastTextIdx],
+ text: updatedContent[lastTextIdx].text + injectedNote
+ };
+ } else {
+ updatedContent.push({ type: "text", text: injectedNote });
+ }
+ return {
+ userMessage: { ...userMessage, content: updatedContent },
+ billableImageCount: visionDescription ? 1 : 0
+ };
+}
+function createTkmindProxy({ apiTarget, apiTargets, apiSecret, userAuth: userAuth2, llmProviderService: llmProviderService2, localFetchAsset, subscriptionService: subscriptionService2 }) {
+ const targets = apiTargets?.length ? apiTargets : apiTarget ? [apiTarget] : [];
+ const primaryTarget = targets[0] ?? apiTarget ?? "";
+ let rrIdx = 0;
+ async function targetHealthy(target) {
+ try {
+ const upstream = await apiFetch(target, apiSecret, "/status", {
+ method: "GET",
+ signal: AbortSignal.timeout(1500)
+ });
+ return upstream.ok;
+ } catch {
+ return false;
+ }
+ }
+ async function pickTarget() {
+ if (targets.length <= 1) return primaryTarget;
+ for (let i = 0; i < targets.length; i += 1) {
+ const target = targets[rrIdx];
+ rrIdx = (rrIdx + 1) % targets.length;
+ if (await targetHealthy(target)) return target;
+ }
+ return primaryTarget;
+ }
+ async function resolveTarget(sessionId) {
+ if (targets.length <= 1 || !sessionId) return primaryTarget;
+ try {
+ const node = await userAuth2.getSessionNode(sessionId);
+ return targets[node] ?? primaryTarget;
+ } catch {
+ return primaryTarget;
+ }
+ }
+ async function applySessionLlmProvider(sessionId) {
+ if (!llmProviderService2 || !sessionId) return null;
+ try {
+ const target = await resolveTarget(sessionId);
+ return await llmProviderService2.applyBestProviderForSession(
+ sessionId,
+ (url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init)
+ );
+ } catch (err) {
+ console.warn(
+ "LLM provider apply skipped:",
+ err instanceof Error ? err.message : err
+ );
+ return null;
+ }
+ }
+ async function applyLocalFallbackForSession(sessionId) {
+ if (!llmProviderService2 || !sessionId) return null;
+ const target = await resolveTarget(sessionId);
+ return llmProviderService2.applyLocalFallbackForSession(
+ sessionId,
+ (url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init)
+ );
+ }
+ const visionActiveSessions = /* @__PURE__ */ new Set();
+ async function applyVisionProviderForSession(sessionId) {
+ if (!llmProviderService2 || !sessionId) return null;
+ const target = await resolveTarget(sessionId);
+ return llmProviderService2.applyVisionProviderForSession(
+ sessionId,
+ (url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init)
+ );
+ }
+ async function buildVisionBody(userMessage, userId, publishLayout) {
+ return buildVisionPayload({
+ userMessage,
+ userId,
+ publishLayout,
+ localFetchAsset,
+ llmProviderService: llmProviderService2
+ });
+ }
+ async function reconcileSessionPolicyForUser(userId, sessionId) {
+ if (!userId || !sessionId) return;
+ const target = await resolveTarget(sessionId);
+ const workingDir = await userAuth2.resolveWorkingDir(userId);
+ const sessionPolicy = await userAuth2.getAgentSessionPolicy(userId);
+ const publishLayout = await userAuth2.getUserPublishLayout(userId);
+ await reconcileAgentSession(
+ (pathname, init) => apiFetch(target, apiSecret, pathname, init),
+ sessionId,
+ {
+ workingDir,
+ sessionPolicy,
+ sandboxConstraints: publishLayout?.constraints ?? null,
+ tolerateInvalidWorkingDir: true,
+ userContext: publishLayout ? {
+ userId,
+ displayName: publishLayout.displayName,
+ username: publishLayout.username,
+ slug: publishLayout.slug
+ } : null
+ }
+ );
+ }
+ const requireUser = async (req, res, next) => {
+ try {
+ const session = req.userSession;
+ if (!session) {
+ res.status(401).json({ message: "\u672A\u767B\u5F55" });
+ return;
+ }
+ const me = await userAuth2.getMe(req.userToken);
+ if (!me) {
+ res.status(401).json({ message: "\u767B\u5F55\u5DF2\u8FC7\u671F" });
+ return;
+ }
+ req.currentUser = me;
+ next();
+ } catch (err) {
+ res.status(500).json({ message: err instanceof Error ? err.message : "\u8BA4\u8BC1\u5931\u8D25" });
+ }
+ };
+ const ensureChatAllowed = async (req, res, next) => {
+ const gate = await userAuth2.canUseChat(req.currentUser.id);
+ if (!gate.ok) {
+ res.status(402).json({
+ message: gate.message,
+ code: gate.code,
+ balanceCents: gate.balanceCents,
+ minRechargeCents: gate.minRechargeCents,
+ suggestedTiers: gate.suggestedTiers
+ });
+ return;
+ }
+ next();
+ };
+ const handlers = {
+ "POST /agent/start": [
+ requireUser,
+ ensureChatAllowed,
+ async (req, res) => {
+ try {
+ const workingDir = await userAuth2.resolveWorkingDir(req.currentUser.id);
+ const sessionPolicy = await userAuth2.getAgentSessionPolicy(req.currentUser.id);
+ const startTarget = await pickTarget();
+ const upstream = await apiFetch(startTarget, apiSecret, "/agent/start", {
+ method: "POST",
+ body: JSON.stringify({
+ working_dir: workingDir,
+ enable_context_memory: sessionPolicy.enableContextMemory,
+ ...sessionPolicy.extensionOverrides ? { extension_overrides: sessionPolicy.extensionOverrides } : {},
+ ...req.body?.recipe ? { recipe: req.body.recipe } : {}
+ })
+ });
+ const text = await upstream.text();
+ if (!upstream.ok) {
+ res.status(upstream.status).send(text);
+ return;
+ }
+ const session = JSON.parse(text);
+ if (session?.id) {
+ await userAuth2.registerAgentSession(
+ req.currentUser.id,
+ session.id,
+ Math.max(0, targets.indexOf(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(() => "");
+ res.status(modeRes.status).send(modeText || "\u8BBE\u7F6E\u4F1A\u8BDD\u6A21\u5F0F\u5931\u8D25");
+ return;
+ }
+ }
+ const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser.id);
+ const api2 = (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init);
+ try {
+ await reconcileAgentSession(api2, session.id, {
+ workingDir,
+ sessionPolicy,
+ sandboxConstraints: publishLayout?.constraints ?? null,
+ userContext: publishLayout ? {
+ userId: req.currentUser.id,
+ displayName: publishLayout.displayName,
+ username: publishLayout.username,
+ slug: publishLayout.slug
+ } : null
+ });
+ } catch (reconcileErr) {
+ res.status(500).json({
+ message: reconcileErr instanceof Error ? `\u4F1A\u8BDD\u7B56\u7565\u540C\u6B65\u5931\u8D25\uFF1A${reconcileErr.message}` : "\u4F1A\u8BDD\u7B56\u7565\u540C\u6B65\u5931\u8D25"
+ });
+ return;
+ }
+ await applySessionLlmProvider(session.id);
+ }
+ res.status(upstream.status).json(session);
+ } catch (err) {
+ res.status(500).json({ message: err instanceof Error ? err.message : "\u542F\u52A8\u4F1A\u8BDD\u5931\u8D25" });
+ }
+ }
+ ],
+ "POST /agent/resume": [
+ requireUser,
+ ensureChatAllowed,
+ async (req, res) => {
+ try {
+ const sessionId = req.body?.session_id;
+ if (!sessionId) {
+ res.status(400).json({ message: "\u7F3A\u5C11 session_id" });
+ return;
+ }
+ const owns = await userAuth2.ownsSession(req.currentUser.id, sessionId);
+ if (!owns) {
+ res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" });
+ return;
+ }
+ const resumeTarget = await resolveTarget(sessionId);
+ const upstream = await apiFetch(resumeTarget, apiSecret, "/agent/resume", {
+ method: "POST",
+ body: JSON.stringify(req.body ?? {})
+ });
+ const text = await upstream.text();
+ if (!upstream.ok) {
+ res.status(upstream.status).send(text);
+ return;
+ }
+ const payload = JSON.parse(text);
+ const skipReconcile = req.body?.skip_reconcile === true;
+ if (!skipReconcile) {
+ const workingDir = await userAuth2.resolveWorkingDir(req.currentUser.id);
+ const sessionPolicy = await userAuth2.getAgentSessionPolicy(req.currentUser.id);
+ const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser.id);
+ try {
+ await reconcileAgentSession(
+ (pathname, init) => apiFetch(resumeTarget, apiSecret, pathname, init),
+ sessionId,
+ {
+ workingDir,
+ sessionPolicy,
+ sandboxConstraints: publishLayout?.constraints ?? null,
+ tolerateInvalidWorkingDir: true,
+ userContext: publishLayout ? {
+ userId: req.currentUser.id,
+ displayName: publishLayout.displayName,
+ username: publishLayout.username,
+ slug: publishLayout.slug
+ } : null
+ }
+ );
+ } catch (reconcileErr) {
+ res.status(500).json({
+ message: reconcileErr instanceof Error ? `\u4F1A\u8BDD\u6062\u590D\u540E\u7B56\u7565\u540C\u6B65\u5931\u8D25\uFF1A${reconcileErr.message}` : "\u4F1A\u8BDD\u6062\u590D\u540E\u7B56\u7565\u540C\u6B65\u5931\u8D25"
+ });
+ return;
+ }
+ }
+ await applySessionLlmProvider(sessionId);
+ res.status(upstream.status).json(payload);
+ } catch (err) {
+ res.status(500).json({
+ message: sanitizeUserFacingProxyMessage(
+ err instanceof Error ? err.message : "\u6062\u590D\u4F1A\u8BDD\u5931\u8D25",
+ "\u6062\u590D\u4F1A\u8BDD\u5931\u8D25"
+ )
+ });
+ }
+ }
+ ],
+ "GET /sessions": [
+ requireUser,
+ async (req, res) => {
+ try {
+ const owned = await userAuth2.listOwnedSessionIds(req.currentUser.id);
+ const sessionsById = /* @__PURE__ */ new Map();
+ let healthyTargets = 0;
+ let lastFailure = null;
+ for (const target of targets) {
+ try {
+ const upstream = await apiFetch(target, apiSecret, "/sessions", {
+ method: "GET",
+ signal: AbortSignal.timeout(3e3)
+ });
+ const text = await upstream.text();
+ if (!upstream.ok) {
+ lastFailure = text || `upstream ${upstream.status}`;
+ continue;
+ }
+ healthyTargets += 1;
+ const payload = JSON.parse(text);
+ for (const item of payload.sessions ?? []) {
+ if (owned.has(item.id)) sessionsById.set(item.id, item);
+ }
+ } catch (err) {
+ lastFailure = sanitizeUserFacingProxyMessage(
+ err instanceof Error ? err.message : "\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25",
+ "\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25"
+ );
+ }
+ }
+ if (healthyTargets === 0) {
+ res.status(502).json({ message: lastFailure ?? "\u540E\u7AEF\u8FDE\u63A5\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5" });
+ return;
+ }
+ if (healthyTargets < targets.length) {
+ res.setHeader("X-TKMind-Degraded", "1");
+ }
+ res.json({ sessions: [...sessionsById.values()] });
+ } catch (err) {
+ res.status(500).json({
+ message: sanitizeUserFacingProxyMessage(
+ err instanceof Error ? err.message : "\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25",
+ "\u8BFB\u53D6\u4F1A\u8BDD\u5931\u8D25"
+ )
+ });
+ }
+ }
+ ]
+ };
+ const sessionScoped = (build) => [
+ requireUser,
+ async (req, res, next) => {
+ try {
+ const body = req.body ?? await readJsonBody(req);
+ req.body = body;
+ const sessionId = extractSessionId(req, body);
+ if (!sessionId) {
+ res.status(400).json({ message: "\u7F3A\u5C11 session_id" });
+ return;
+ }
+ const owns = await userAuth2.ownsSession(req.currentUser.id, sessionId);
+ if (!owns) {
+ res.status(403).json({ message: "\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD" });
+ return;
+ }
+ req.agentSessionId = sessionId;
+ await build(req, res, next);
+ } catch (err) {
+ res.status(500).json({
+ message: sanitizeUserFacingProxyMessage(
+ err instanceof Error ? err.message : "\u8BF7\u6C42\u5931\u8D25",
+ "\u8BF7\u6C42\u5931\u8D25"
+ )
+ });
+ }
+ }
+ ];
+ const proxySessionEvents = async (req, res, sessionId, { onAfterFinish } = {}) => {
+ try {
+ const pathname = `/sessions/${sessionId}/events`;
+ const sessionTarget = await resolveTarget(sessionId);
+ const upstream = await apiFetch(sessionTarget, apiSecret, pathname, {
+ method: "GET",
+ headers: {
+ Accept: "text/event-stream",
+ "Last-Event-ID": req.get("last-event-id") ?? ""
+ }
+ });
+ if (!upstream.ok || !upstream.body) {
+ const text = await upstream.text().catch(() => "");
+ res.status(upstream.status).send(text);
+ return;
+ }
+ res.status(upstream.status);
+ res.setHeader("Content-Type", "text/event-stream");
+ res.setHeader("Cache-Control", "no-cache");
+ res.setHeader("Connection", "keep-alive");
+ let pendingBalance = null;
+ const billingTransform = createSseBillingTransform({
+ onFinish: async (event) => {
+ const result = await userAuth2.billSessionUsage(
+ req.currentUser.id,
+ sessionId,
+ event.token_state,
+ null
+ );
+ if (result.ok && result.costCents > 0 && result.balanceCents != null) {
+ pendingBalance = {
+ balanceCents: result.balanceCents,
+ tokensUsed: result.tokensUsed ?? void 0,
+ lastUsage: {
+ inputTokens: result.deltaInputTokens ?? 0,
+ outputTokens: result.deltaOutputTokens ?? 0,
+ costCents: result.costCents
+ }
+ };
+ }
+ if (typeof onAfterFinish === "function") {
+ void Promise.resolve().then(() => onAfterFinish(sessionId, req.currentUser.id)).catch(() => {
+ });
+ }
+ }
+ });
+ const source = Readable.fromWeb(upstream.body);
+ billingTransform.on("data", (chunk) => {
+ res.write(chunk);
+ if (pendingBalance != null) {
+ res.write(appendBalanceEvent(pendingBalance));
+ pendingBalance = null;
+ }
+ });
+ billingTransform.on("end", () => res.end());
+ billingTransform.on("error", () => res.end());
+ source.on("error", () => res.end());
+ source.pipe(billingTransform);
+ } catch (err) {
+ res.status(502).json({
+ message: sanitizeUserFacingProxyMessage(
+ err instanceof Error ? err.message : "SSE \u4EE3\u7406\u5931\u8D25",
+ "SSE \u4EE3\u7406\u5931\u8D25"
+ )
+ });
+ }
+ };
+ const proxyFallback = async (req, res) => {
+ try {
+ const pathname = req.originalUrl.replace(/^\/api/, "") || "/";
+ if (isNativeH5ApiPath(pathname)) {
+ res.status(404).json({
+ message: "H5 \u672C\u5730\u63A5\u53E3\u672A\u627E\u5230\uFF0C\u8BF7\u786E\u8BA4\u670D\u52A1\u7AEF\u5DF2\u66F4\u65B0\u5E76\u91CD\u542F",
+ code: "not_found"
+ });
+ return;
+ }
+ const policyState = await userAuth2.resolveUserPolicies(req.currentUser);
+ const capabilityState = await userAuth2.resolveUserCapabilities(req.currentUser);
+ const gate = evaluateProxyRequest(req.method, pathname, policyState.policies, {
+ unrestricted: policyState.unrestricted
+ });
+ if (!gate.allowed) {
+ res.status(403).json({ message: gate.reason ?? "\u8BE5 API \u5DF2\u88AB\u7B56\u7565\u7981\u6B62" });
+ return;
+ }
+ if (/^\/agent\/harness_(bootstrap|remember)$/.test(pathname) && !capabilityState.unrestricted && !capabilityState.capabilities.context_memory) {
+ res.status(403).json({ message: "\u5F53\u524D\u8D26\u6237\u672A\u5F00\u901A\u9879\u76EE\u8BB0\u5FC6\uFF0C\u65E0\u6CD5\u8BBF\u95EE\u8BE5 API" });
+ return;
+ }
+ const isReplyPath = pathname.match(/^\/sessions\/[^/]+\/reply$/) && req.method === "POST";
+ const sessionMatch = pathname.match(/^\/sessions\/([^/]+)/);
+ let baseBody = req.body;
+ if (isReplyPath && !policyState.unrestricted) {
+ baseBody = injectTaskRoutingHint(
+ req.body,
+ await userAuth2.getAgentSessionPolicy(req.currentUser.id)
+ );
+ }
+ if (isReplyPath && llmProviderService2) {
+ const sessionId = sessionMatch?.[1];
+ if (sessionId) {
+ const hasImages = messageHasImages(req.body?.user_message);
+ if (hasImages && await llmProviderService2.hasVisionKey()) {
+ const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser?.id).catch(() => null);
+ const visionResult = await buildVisionBody(
+ baseBody?.user_message ?? req.body?.user_message,
+ req.currentUser?.id,
+ publishLayout
+ ).catch(() => null);
+ if (visionResult?.userMessage) {
+ baseBody = { ...baseBody ?? req.body, user_message: visionResult.userMessage };
+ }
+ if (visionResult?.billableImageCount > 0 && subscriptionService2) {
+ await subscriptionService2.consumeImageQuota(
+ req.currentUser.id,
+ visionResult.billableImageCount
+ ).catch((err) => {
+ console.warn(
+ "Subscription image quota consume skipped:",
+ err instanceof Error ? err.message : err
+ );
+ });
+ }
+ }
+ }
+ }
+ const body = req.method === "GET" || req.method === "HEAD" ? void 0 : baseBody ? JSON.stringify(baseBody) : void 0;
+ const fallbackTarget = req.goosedTarget ?? (sessionMatch ? await resolveTarget(sessionMatch[1]) : primaryTarget);
+ const upstream = await apiFetch(fallbackTarget, apiSecret, pathname, {
+ method: req.method,
+ body,
+ headers: {
+ Accept: req.get("accept") ?? "*/*",
+ "Last-Event-ID": req.get("last-event-id") ?? ""
+ }
+ });
+ sendProxyResponse(res, upstream);
+ } catch (err) {
+ res.status(502).json({
+ message: sanitizeUserFacingProxyMessage(
+ err instanceof Error ? err.message : "\u4EE3\u7406\u5931\u8D25",
+ "\u4EE3\u7406\u5931\u8D25"
+ )
+ });
+ }
+ };
+ return {
+ requireUser,
+ ensureChatAllowed,
+ applySessionLlmProvider,
+ applyLocalFallbackForSession,
+ applyVisionProviderForSession,
+ reconcileSessionPolicyForUser,
+ handlers,
+ sessionScoped,
+ proxyFallback,
+ proxySessionEvents,
+ resolveTarget,
+ 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 net from "node:net";
+import path8 from "node:path";
+import { Algorithm as Argon2Algorithm, hashRawSync as argon2HashRawSync } from "@node-rs/argon2";
+
+// billing.mjs
+function loadBillingConfig() {
+ const useBackendCost = process.env.H5_USE_BACKEND_COST === "1";
+ const marginMultiplier = Number(process.env.H5_MARGIN_MULTIPLIER ?? 1);
+ return {
+ useBackendCost,
+ usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2),
+ marginMultiplier: Number.isFinite(marginMultiplier) && marginMultiplier > 0 ? marginMultiplier : 1,
+ inputCentsPer1k: Number(process.env.H5_BILL_INPUT_CENTS_PER_1K ?? 2),
+ outputCentsPer1k: Number(process.env.H5_BILL_OUTPUT_CENTS_PER_1K ?? 6),
+ minBillCents: Number(process.env.H5_MIN_BILL_CENTS ?? 1)
+ };
+}
+function normalizeTokenState(raw) {
+ if (!raw || typeof raw !== "object") {
+ return {
+ inputTokens: 0,
+ outputTokens: 0,
+ totalTokens: 0,
+ accumulatedInputTokens: 0,
+ accumulatedOutputTokens: 0,
+ accumulatedTotalTokens: 0,
+ accumulatedCost: null
+ };
+ }
+ return {
+ inputTokens: Number(raw.inputTokens ?? raw.input_tokens ?? 0),
+ outputTokens: Number(raw.outputTokens ?? raw.output_tokens ?? 0),
+ totalTokens: Number(raw.totalTokens ?? raw.total_tokens ?? 0),
+ accumulatedInputTokens: Number(
+ raw.accumulatedInputTokens ?? raw.accumulated_input_tokens ?? raw.inputTokens ?? 0
+ ),
+ accumulatedOutputTokens: Number(
+ raw.accumulatedOutputTokens ?? raw.accumulated_output_tokens ?? raw.outputTokens ?? 0
+ ),
+ accumulatedTotalTokens: Number(
+ raw.accumulatedTotalTokens ?? raw.accumulated_total_tokens ?? raw.totalTokens ?? 0
+ ),
+ accumulatedCost: raw.accumulatedCost ?? raw.accumulated_cost ?? null
+ };
+}
+function computeDeltaCostCents(previous, current, config = loadBillingConfig()) {
+ const prevCost = previous?.lastAccumulatedCost == null ? null : Number(previous.lastAccumulatedCost);
+ const currCost = current.accumulatedCost == null ? null : Number(current.accumulatedCost);
+ const margin = config.marginMultiplier ?? 1;
+ if (config.useBackendCost && prevCost != null && currCost != null && currCost >= prevCost) {
+ const deltaUsd = currCost - prevCost;
+ if (deltaUsd <= 0) return 0;
+ return Math.max(config.minBillCents, Math.ceil(deltaUsd * config.usdCnyRate * 100 * margin));
+ }
+ if (config.useBackendCost && prevCost == null && currCost != null && currCost > 0) {
+ return Math.max(config.minBillCents, Math.ceil(currCost * config.usdCnyRate * 100 * margin));
+ }
+ const prevIn = Number(previous?.lastInputTokens ?? 0);
+ const prevOut = Number(previous?.lastOutputTokens ?? 0);
+ const deltaIn = Math.max(0, current.accumulatedInputTokens - prevIn);
+ const deltaOut = Math.max(0, current.accumulatedOutputTokens - prevOut);
+ if (deltaIn === 0 && deltaOut === 0) return 0;
+ const raw = deltaIn / 1e3 * config.inputCentsPer1k + deltaOut / 1e3 * config.outputCentsPer1k;
+ if (raw <= 0) return 0;
+ return Math.max(config.minBillCents, Math.ceil(raw));
+}
+
+// billing-recharge.mjs
+import crypto3 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 {
+ tiersCents: tiers.length ? tiers : [500, 1e3, 3e3, 5e3, 1e4, 2e4],
+ minRechargeCents: Number(process.env.H5_MIN_RECHARGE_CENTS ?? 500),
+ orderTtlMs: Number(process.env.H5_RECHARGE_ORDER_TTL_MS ?? 15 * 60 * 1e3),
+ maxPendingOrders: Number(process.env.H5_RECHARGE_MAX_PENDING ?? 3),
+ dailyLimitCents: Number(process.env.H5_RECHARGE_DAILY_LIMIT_CENTS ?? 2e5)
+ };
+}
+function isAllowedRechargeAmount(amountCents, config = loadRechargeConfig()) {
+ return config.tiersCents.includes(Number(amountCents));
+}
+function buildInsufficientBalancePayload(balanceCents, config = loadRechargeConfig()) {
+ return {
+ code: "INSUFFICIENT_BALANCE",
+ message: "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u540E\u7EE7\u7EED\u4F7F\u7528",
+ balanceCents: Number(balanceCents ?? 0),
+ minRechargeCents: config.minRechargeCents,
+ suggestedTiers: config.tiersCents
+ };
+}
+function createOutTradeNo() {
+ const stamp = Date.now().toString(36).toUpperCase();
+ const rand = crypto3.randomBytes(4).toString("hex").toUpperCase();
+ return `TK${stamp}${rand}`.slice(0, 32);
+}
+function mapOrderRow(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ userId: row.user_id,
+ amountCents: Number(row.amount_cents),
+ channel: row.channel,
+ status: row.status,
+ payMode: row.pay_mode,
+ outTradeNo: row.out_trade_no,
+ providerTxn: row.provider_txn,
+ paidAt: row.paid_at == null ? null : Number(row.paid_at),
+ expireAt: Number(row.expire_at),
+ createdAt: Number(row.created_at),
+ codeUrl: row.code_url ?? null,
+ h5Url: row.h5_url ?? null
+ };
+}
+function createRechargeService(pool, { userAuth: userAuth2, wechatPay, config = loadRechargeConfig() } = {}) {
+ const expireStaleOrders = async (userId) => {
+ const now = Date.now();
+ await pool.query(
+ `UPDATE h5_payment_orders
+ SET status = 'expired', updated_at = ?
+ WHERE user_id = ? AND status = 'pending' AND expire_at <= ?`,
+ [now, userId, now]
+ );
+ };
+ const countPendingOrders = async (userId) => {
+ const now = Date.now();
+ const [rows] = await pool.query(
+ `SELECT COUNT(*) AS total
+ FROM h5_payment_orders
+ WHERE user_id = ? AND status = 'pending' AND expire_at > ?`,
+ [userId, now]
+ );
+ return Number(rows[0]?.total ?? 0);
+ };
+ const sumPaidToday = async (userId) => {
+ const start = /* @__PURE__ */ new Date();
+ start.setHours(0, 0, 0, 0);
+ const [rows] = await pool.query(
+ `SELECT COALESCE(SUM(amount_cents), 0) AS total
+ FROM h5_payment_orders
+ WHERE user_id = ? AND status = 'paid' AND paid_at >= ?`,
+ [userId, start.getTime()]
+ );
+ return Number(rows[0]?.total ?? 0);
+ };
+ const getOrderById = async (orderId) => {
+ const [rows] = await pool.query(`SELECT * FROM h5_payment_orders WHERE id = ? LIMIT 1`, [
+ orderId
+ ]);
+ return mapOrderRow(rows[0]);
+ };
+ const getOrderByOutTradeNo = async (outTradeNo) => {
+ const [rows] = await pool.query(
+ `SELECT * FROM h5_payment_orders WHERE out_trade_no = ? LIMIT 1`,
+ [outTradeNo]
+ );
+ return mapOrderRow(rows[0]);
+ };
+ const getBillingConfig = async (userId) => {
+ const user = await userAuth2.getUserById(userId);
+ return {
+ wechatEnabled: Boolean(wechatPay?.enabled),
+ tiersCents: config.tiersCents,
+ minRechargeCents: config.minRechargeCents,
+ balanceCents: user ? Number(user.balance_cents ?? 0) : 0
+ };
+ };
+ const createOrder = async ({ userId, amountCents, payScene, clientIp }) => {
+ const amount = Number(amountCents);
+ if (!isAllowedRechargeAmount(amount, config)) {
+ return { ok: false, message: "\u5145\u503C\u91D1\u989D\u4E0D\u5728\u5141\u8BB8\u6863\u4F4D\u5185" };
+ }
+ if (!wechatPay?.enabled) {
+ return { ok: false, message: "\u5FAE\u4FE1\u652F\u4ED8\u5C1A\u672A\u914D\u7F6E\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" };
+ }
+ const user = await userAuth2.getUserById(userId);
+ if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
+ if (user.status === "disabled") return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528" };
+ await expireStaleOrders(userId);
+ const pending = await countPendingOrders(userId);
+ if (pending >= config.maxPendingOrders) {
+ return { ok: false, message: "\u5F85\u652F\u4ED8\u8BA2\u5355\u8FC7\u591A\uFF0C\u8BF7\u5148\u5B8C\u6210\u6216\u7B49\u5F85\u8FC7\u671F\u540E\u518D\u8BD5" };
+ }
+ const paidToday = await sumPaidToday(userId);
+ if (paidToday + amount > config.dailyLimitCents) {
+ 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 outTradeNo = createOutTradeNo();
+ const expireAt = now + config.orderTtlMs;
+ const description = `TKMind\u8D26\u6237\u5145\u503C\xA5${(amount / 100).toFixed(2)}`;
+ const mode = payScene === "jsapi" ? "jsapi" : payScene === "h5" ? "h5" : "native";
+ let codeUrl = null;
+ let h5Url = null;
+ let jsapiParams = null;
+ try {
+ if (mode === "jsapi") {
+ const appId = wechatPay.appId;
+ if (!appId) {
+ return { ok: false, message: "\u5FAE\u4FE1\u652F\u4ED8 AppID \u672A\u914D\u7F6E" };
+ }
+ const openid = await userAuth2.getWechatOpenidForUser(userId, appId);
+ if (!openid) {
+ return {
+ ok: false,
+ message: "\u8BF7\u5148\u7528\u5FAE\u4FE1\u767B\u5F55\u5E76\u7ED1\u5B9A\u8D26\u53F7\u540E\u518D\u5145\u503C"
+ };
+ }
+ const result = await wechatPay.createJsapiOrder({
+ outTradeNo,
+ description,
+ amountCents: amount,
+ clientIp,
+ openid
+ });
+ jsapiParams = result.jsapiParams;
+ } else if (mode === "h5") {
+ const result = await wechatPay.createH5Order({
+ outTradeNo,
+ description,
+ amountCents: amount,
+ clientIp
+ });
+ h5Url = result.h5Url;
+ } else {
+ const result = await wechatPay.createNativeOrder({
+ outTradeNo,
+ description,
+ amountCents: amount,
+ clientIp
+ });
+ codeUrl = result.codeUrl;
+ }
+ } catch (err) {
+ return {
+ ok: false,
+ message: err instanceof Error ? err.message : "\u521B\u5EFA\u652F\u4ED8\u8BA2\u5355\u5931\u8D25"
+ };
+ }
+ await pool.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)
+ VALUES (?, ?, ?, 'wechat', 'pending', ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ orderId,
+ userId,
+ amount,
+ mode,
+ outTradeNo,
+ codeUrl,
+ h5Url,
+ expireAt,
+ clientIp ?? null,
+ now,
+ now
+ ]
+ );
+ return {
+ ok: true,
+ order: {
+ id: orderId,
+ amountCents: amount,
+ status: "pending",
+ payMode: mode,
+ expireAt,
+ codeUrl,
+ h5Url,
+ jsapiParams
+ }
+ };
+ };
+ const fulfillOrder = async (order, transaction) => {
+ if (!order || order.status === "paid") {
+ return { ok: true, alreadyPaid: true };
+ }
+ if (order.status !== "pending") {
+ return { ok: false, message: "\u8BA2\u5355\u72B6\u6001\u4E0D\u53EF\u652F\u4ED8" };
+ }
+ const tradeState = transaction?.trade_state ?? (transaction?.result_code === "SUCCESS" ? "SUCCESS" : transaction?.result_code) ?? transaction?.trade_state_desc;
+ if (tradeState && tradeState !== "SUCCESS") {
+ return { ok: false, message: `\u652F\u4ED8\u672A\u6210\u529F: ${tradeState}` };
+ }
+ const paidAmount = Number(
+ transaction?.amount?.total ?? transaction?.amount?.payer_total ?? transaction?.total_fee ?? 0
+ );
+ if (paidAmount !== order.amountCents) {
+ return { ok: false, message: "\u652F\u4ED8\u91D1\u989D\u4E0E\u8BA2\u5355\u4E0D\u4E00\u81F4" };
+ }
+ const now = Date.now();
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ const [rows] = await conn.query(
+ `SELECT * FROM h5_payment_orders WHERE id = ? FOR UPDATE`,
+ [order.id]
+ );
+ const locked = mapOrderRow(rows[0]);
+ if (!locked) {
+ await conn.rollback();
+ return { ok: false, message: "\u8BA2\u5355\u4E0D\u5B58\u5728" };
+ }
+ if (locked.status === "paid") {
+ await conn.commit();
+ return { ok: true, alreadyPaid: true };
+ }
+ if (locked.status !== "pending") {
+ await conn.rollback();
+ return { ok: false, message: "\u8BA2\u5355\u72B6\u6001\u4E0D\u53EF\u652F\u4ED8" };
+ }
+ const recharge = await userAuth2.recharge(locked.userId, locked.amountCents, null, "\u7528\u6237\u81EA\u52A9\u5145\u503C", {
+ paymentOrderId: locked.id,
+ conn
+ });
+ if (!recharge.ok) {
+ await conn.rollback();
+ return recharge;
+ }
+ await conn.query(
+ `UPDATE h5_payment_orders
+ SET status = 'paid', provider_txn = ?, paid_at = ?, updated_at = ?
+ WHERE id = ?`,
+ [transaction.transaction_id ?? transaction.out_trade_no ?? null, now, now, locked.id]
+ );
+ await conn.commit();
+ return { ok: true, user: recharge.user, balanceCents: recharge.user.balanceCents };
+ } catch (err) {
+ await conn.rollback();
+ throw err;
+ } finally {
+ conn.release();
+ }
+ };
+ const handleWechatNotify = async ({ headers, body }) => {
+ const { transaction } = wechatPay.verifyNotify({ headers, body });
+ const outTradeNo = transaction?.out_trade_no;
+ if (!outTradeNo) {
+ throw new Error("\u56DE\u8C03\u7F3A\u5C11\u5546\u6237\u8BA2\u5355\u53F7");
+ }
+ const order = await getOrderByOutTradeNo(outTradeNo);
+ if (!order) {
+ throw new Error("\u8BA2\u5355\u4E0D\u5B58\u5728");
+ }
+ return fulfillOrder(order, transaction);
+ };
+ const getOrderForUser = async (userId, orderId) => {
+ const order = await getOrderById(orderId);
+ if (!order || order.userId !== userId) return null;
+ if (order.status === "pending" && order.expireAt <= Date.now()) {
+ await pool.query(
+ `UPDATE h5_payment_orders SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'pending'`,
+ [Date.now(), orderId]
+ );
+ return { ...order, status: "expired" };
+ }
+ return order;
+ };
+ return {
+ config,
+ getBillingConfig,
+ createOrder,
+ getOrderForUser,
+ handleWechatNotify,
+ fulfillOrder,
+ getOrderByOutTradeNo
+ };
+}
+
+// user-space.mjs
+import fs5 from "node:fs";
+import path6 from "node:path";
+var UPLOAD_ZONE_CODES = ["oa", "private", "public"];
+function resolveMindspaceStorageRoot(h5Root, env = process.env) {
+ return path6.resolve(env.MINDSPACE_STORAGE_ROOT ?? path6.join(h5Root, "data", "mindspace"));
+}
+function resolveUserWorkspaceRoot(h5Root, user) {
+ return resolvePublishDir(h5Root, user);
+}
+var WORKSPACE_HINTS_FILENAME2 = ".tkmindhints";
+var LEGACY_WORKSPACE_HINTS_FILENAME2 = ".goosehints";
+function renderUserSpaceBrandingBlock(userAddressName) {
+ const name = userAddressName || "\u7528\u6237";
+ return `## \u54C1\u724C\u4E0E\u79F0\u547C\uFF08\u786C\u6027\uFF09
+
+- \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4ECB\u7ECD\u4EA7\u54C1\u65F6\u7528 TKMind\uFF0C\u4E0D\u8981\u79F0 goose\u3001Goose\u3001goosed
+- \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
+`;
+}
+function resolveZoneDir(workspaceRoot, categoryCode) {
+ return path6.join(workspaceRoot, categoryCode);
+}
+function resolveZoneFilePath(workspaceRoot, categoryCode, filename) {
+ return path6.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 });
+ }
+}
+function mirrorAssetToZone2({ workspaceRoot, categoryCode, filename, sourcePath }) {
+ if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return null;
+ if (!sourcePath || !fs5.existsSync(sourcePath)) return null;
+ ensureUserZoneDirs(workspaceRoot);
+ const dest = resolveZoneFilePath(workspaceRoot, categoryCode, filename);
+ fs5.mkdirSync(path6.dirname(dest), { recursive: true });
+ fs5.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);
+}
+function isPathInsideUserWorkspace(workspaceRoot, requestedPath) {
+ const base = path6.resolve(workspaceRoot);
+ const resolved = path6.resolve(requestedPath);
+ return resolved === base || resolved.startsWith(`${base}${path6.sep}`);
+}
+function renderUserSpaceHints({ username, workspaceRoot, displayName, slug }) {
+ const addressName = resolveUserAddressName({ displayName, username, slug });
+ const zoneLines = UPLOAD_ZONE_CODES.map(
+ (code) => `- \`${code}/\` \u2014 ${zoneLabel(code)}\uFF08\u7528\u6237\u4E0A\u4F20\u843D\u5728\u6B64\u5206\u533A\uFF09`
+ );
+ return `# TKMind \u7528\u6237\u7A7A\u95F4\u5206\u533A
+
+\u4F60\u662F\u7528\u6237 **${addressName}** \u7684\u4E13\u5C5E TKMind \u52A9\u624B\u3002\u5F53\u524D\u4F1A\u8BDD\u5DE5\u4F5C\u533A\uFF08\u552F\u4E00\u6587\u4EF6\u6839\u76EE\u5F55\uFF09\uFF1A
+\`${workspaceRoot}\`
+
+${renderUserSpaceBrandingBlock(addressName)}
+## \u5206\u533A\u89C4\u5219\uFF08\u786C\u6027\uFF09
+
+\u7528\u6237\u4E0A\u4F20\u7684\u6587\u4EF6**\u53EA\u4F1A**\u51FA\u73B0\u5728\u4E0B\u5217\u5B50\u76EE\u5F55\u4E4B\u4E00\uFF1A
+
+${zoneLines.join("\n")}
+
+## \u67E5\u627E / \u8BFB\u53D6\u6587\u4EF6
+
+1. \u5728\u5DE5\u4F5C\u533A\u5185\u641C\u7D22\uFF0C\u4F8B\u5982 \`tree oa/\`\u3001\`find oa -name '*.csv'\`\u3001\`cat oa/\u6587\u4EF6\u540D.csv\`
+2. \u7528\u6237\u8BF4\u300COA \u533A\u300D\u2192 \`oa/\`\uFF1B\u300C\u79C1\u4EBA\u533A\u300D\u2192 \`private/\`\uFF1B\u300C\u516C\u5F00\u533A\u300D\u2192 \`public/\`
+3. **\u7981\u6B62**\u8BBF\u95EE\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001\`${PUBLISH_ROOT_DIR}/\` \u6839\u76EE\u5F55\uFF08\u975E\u672C\u7528\u6237\uFF09\u3001\`data/mindspace/\`\u3001\u4E3B\u673A\u7EDD\u5BF9\u8DEF\u5F84
+4. **\u7981\u6B62**\u7528\u516C\u7F51 URL \u5217\u76EE\u5F55\u6216\u8BFB CSV\uFF1B\u516C\u7F51\u94FE\u63A5\u4EC5\u7528\u4E8E\u5206\u4EAB\u5DF2\u53D1\u5E03 HTML
+5. \u627E\u4E0D\u5230\u65F6\u8BF4\u660E\u8BE5\u5206\u533A\u5185\u6CA1\u6709\u8BE5\u6587\u4EF6\uFF0C\u8BF7\u7528\u6237\u786E\u8BA4\u4E0A\u4F20\u5230\u4E86\u54EA\u4E2A\u5206\u533A
+
+## \u751F\u6210\u9875\u9762
+
+- \u7528\u6237\u8981\u7F51\u9875 / HTML / \u62A5\u544A / \u5206\u4EAB\u94FE\u63A5\u65F6\uFF1A**\u4F60\u5FC5\u987B\u4EB2\u81EA\u5B8C\u6210**\uFF0C\u4E0D\u8981\u63A8\u7ED9\u7528\u6237\u624B\u52A8\u64CD\u4F5C
+- \u5148 \`load_skill\` \u2192 \`static-page-publish\`\uFF0C\u518D\u4F7F\u7528 \`write_file\` \u521B\u5EFA \`public/\u9875\u9762\u540D.html\`\uFF08\u9700\u8981\u8C03\u6574\u5DF2\u6709\u9875\u9762\u65F6\u7528 \`edit_file\`\uFF09
+- \u5199\u5165 \`\` \u7684 **mindspace-cover** \u5143\u6570\u636E\uFF08\u8BE6\u89C1 \`.agents/skills/static-page-publish/SKILL.md\`\uFF09
+- \u4FDD\u5B58\u540E\u7ACB\u5373\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\`
+- **\u7981\u6B62**\u56DE\u590D\u300C\u8BF7\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E \`write_file\` / \`edit_file\` \u8C03\u7528\u5DF2\u5931\u8D25\u5E76\u8BF4\u660E\u5177\u4F53\u9519\u8BEF
+
+## \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
+- \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
+- \u7528\u6237\u4E0A\u4F20\u7684\u6587\u4EF6\u4ECD\u4EE5\u754C\u9762\u5165\u5E93\u4E3A\u51C6\uFF0C\u5E76\u955C\u50CF\u5230\u4E0A\u8FF0\u5206\u533A
+`;
+}
+function buildUserSpaceConstraints({ username, workspaceRoot, publicBaseUrl, slug, displayName }) {
+ const addressName = resolveUserAddressName({ displayName, username, slug });
+ const zoneList = UPLOAD_ZONE_CODES.map((code) => `\`${code}/\`\uFF08${zoneLabel(code)}\uFF09`).join("\u3001");
+ return [
+ "## TKMind \u7528\u6237\u7A7A\u95F4\u5206\u533A\uFF08\u786C\u6027\u7EA6\u675F\uFF09",
+ "",
+ `- \u4F60\u662F **TKMind** \u52A9\u624B\uFF1B\u4E0E\u7528\u6237\u5BF9\u8BDD\u65F6\u7528 **${addressName}** \u79F0\u547C\u7528\u6237\uFF0C\u7981\u6B62\u628A\u7528\u6237\u53EB\u4F5C TKMind`,
+ "- \u7981\u6B62\u79F0 goose / Goose / goosed \u6216\u300CRust goose \u9879\u76EE\u300D",
+ `- \u7528\u6237 **${addressName}** \u7684 Agent \u5DE5\u4F5C\u533A\uFF1A\`${workspaceRoot}\``,
+ `- \u7528\u6237\u4E0A\u4F20\u843D\u5728\u5206\u533A\u5B50\u76EE\u5F55\uFF1A${zoneList}\uFF08\u4F8B\u5982 OA \u6587\u4EF6\u5728 \`oa/\`\uFF09`,
+ "- **\u67E5\u627E\u6587\u4EF6**\uFF1A\u53EA\u5728\u4E0A\u8FF0\u5DE5\u4F5C\u533A\u5185\u641C\u7D22\uFF08\u5982 `oa/2025-12-06T13-34_export.csv`\uFF09",
+ "- **\u7981\u6B62**\uFF1A\u8BBF\u95EE\u5176\u5B83\u7528\u6237\u76EE\u5F55\u3001MindSpace \u6839\u76EE\u5F55\u3001data/mindspace \u5185\u90E8\u8DEF\u5F84\u3001\u4E3B\u673A\u7EDD\u5BF9\u8DEF\u5F84",
+ "- **\u7981\u6B62**\u7528\u516C\u7F51 URL \u5217\u76EE\u5F55\u6216\u8BFB CSV\uFF1B\u751F\u6210 HTML \u5199\u5165 `public/` \u5E76\u7ED9\u51FA\u516C\u7F51\u94FE\u63A5",
+ "- **\u751F\u6210\u9875\u9762**\uFF1A\u5148 `load_skill` \u2192 `static-page-publish`\uFF0C\u518D\u7528 `write_file` / `edit_file` \u5199\u5165 `public/*.html`\uFF1B**\u7981\u6B62**\u8BA9\u7528\u6237\u624B\u52A8\u4FDD\u5B58\u6587\u4EF6",
+ publicBaseUrl && slug ? `- \u516C\u7F51 HTML \u524D\u7F00\uFF08\u516C\u5F00\u533A\uFF09\uFF1A\`${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${slug}/public/\`\uFF08\u5199\u5165 \`public/\u9875\u9762.html\` \u65F6\u5206\u4EAB\u94FE\u63A5\u5FC5\u987B\u542B \`public/\`\uFF09` : null
+ ].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 content = renderUserSpaceHints({ ...context, workspaceRoot });
+ fs5.writeFileSync(hintsPath, content, "utf8");
+ if (fs5.existsSync(legacyPath)) {
+ fs5.unlinkSync(legacyPath);
+ }
+ return hintsPath;
+}
+async function syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot) {
+ ensureUserZoneDirs(workspaceRoot);
+ const placeholders = UPLOAD_ZONE_CODES.map(() => "?").join(", ");
+ const [rows] = await pool.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
+ JOIN h5_asset_versions v ON v.id = a.current_version_id
+ WHERE a.user_id = ? AND a.status <> 'deleted' AND c.category_code IN (${placeholders})`,
+ [userId, ...UPLOAD_ZONE_CODES]
+ );
+ for (const row of rows) {
+ const sourcePath = path6.join(storageRoot, row.storage_key);
+ mirrorAssetToZone2({
+ workspaceRoot,
+ categoryCode: row.category_code,
+ filename: row.original_filename,
+ sourcePath
+ });
+ }
+ return { workspaceRoot, mirrored: rows.length };
+}
+async function ensureUserSpaceLayout({
+ pool,
+ storageRoot,
+ userId,
+ username,
+ displayName,
+ publicBaseUrl,
+ slug,
+ workspaceRoot
+}) {
+ ensureUserZoneDirs(workspaceRoot);
+ if (pool && userId) {
+ await syncUserZonesFromAssets(pool, storageRoot, userId, workspaceRoot);
+ }
+ const context = {
+ username: username ?? slug,
+ displayName,
+ workspaceRoot,
+ slug,
+ publicBaseUrl
+ };
+ ensureUserSpaceHints(workspaceRoot, context);
+ return {
+ workspaceRoot,
+ zonesRoot: workspaceRoot,
+ zonesPublicDir: resolveZoneDir(workspaceRoot, "public"),
+ constraints: buildUserSpaceConstraints(context),
+ ...context
+ };
+}
+
+// skills-registry.mjs
+import fs6 from "node:fs";
+import path7 from "node:path";
+import { fileURLToPath as fileURLToPath4 } from "node:url";
+var __dirname3 = path7.dirname(fileURLToPath4(import.meta.url));
+var DEFAULT_USER_SKILLS = {
+ web: true,
+ search: true,
+ "schedule-assistant": true,
+ "form-builder": true,
+ "table-viewer": true,
+ "product-campaign-page": true,
+ [PUBLISH_SKILL_NAME]: false
+};
+var USER_ROLE_SKILL_PRESETS = {
+ user: DEFAULT_USER_SKILLS,
+ creator: {
+ ...DEFAULT_USER_SKILLS,
+ [PUBLISH_SKILL_NAME]: true,
+ kanban: true,
+ timeline: true
+ },
+ developer: {
+ ...DEFAULT_USER_SKILLS,
+ git: true,
+ "diff-viewer": true,
+ "code-playground": true,
+ "test-runner": true
+ }
+};
+function parseSkillFrontmatter(content) {
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
+ if (!match) return { name: null, description: "" };
+ const block = match[1];
+ const name = block.match(/^name:\s*(.+)$/m)?.[1]?.trim() ?? null;
+ const description = block.match(/^description:\s*(.+)$/m)?.[1]?.trim() ?? "";
+ return { name, description };
+}
+function listPlatformSkillCatalog(h5Root = __dirname3) {
+ const skillsRoot = path7.join(h5Root, "skills");
+ if (!fs6.existsSync(skillsRoot)) return [];
+ const catalog = [];
+ for (const entry of fs6.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 meta = parseSkillFrontmatter(raw);
+ const name = meta.name || entry.name;
+ catalog.push({
+ name,
+ dirName: entry.name,
+ label: name,
+ description: meta.description || "\u5E73\u53F0\u901A\u7528\u6280\u80FD",
+ category: "platform",
+ requiresPublish: name === PUBLISH_SKILL_NAME
+ });
+ }
+ return catalog.sort((a, b) => a.name.localeCompare(b.name));
+}
+function isValidSkillName(catalog, name) {
+ return catalog.some((item) => item.name === name);
+}
+function normalizeSkillPatch(catalog, patch) {
+ const normalized = {};
+ for (const [key, value] of Object.entries(patch ?? {})) {
+ if (!isValidSkillName(catalog, key)) continue;
+ normalized[key] = Boolean(value);
+ }
+ return normalized;
+}
+function resolveSkillMap(roleGrants, userOverrides, catalog) {
+ const resolved = {};
+ for (const item of catalog) {
+ const key = item.name;
+ if (key in userOverrides) {
+ resolved[key] = userOverrides[key];
+ } else if (key in roleGrants) {
+ resolved[key] = roleGrants[key];
+ } else {
+ resolved[key] = DEFAULT_USER_SKILLS[key] ?? false;
+ }
+ }
+ return resolved;
+}
+function grantedSkillNames(skillMap) {
+ return Object.entries(skillMap).filter(([, enabled]) => enabled).map(([name]) => name);
+}
+function applySkillGrantsToCapabilities(capabilities, skillMap) {
+ const effective = { ...capabilities };
+ const enabled = grantedSkillNames(skillMap);
+ if (enabled.length > 0) {
+ effective.skills = true;
+ }
+ if (enabled.includes(PUBLISH_SKILL_NAME)) {
+ effective.static_publish = true;
+ }
+ 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);
+ if (entry.isDirectory()) {
+ copySkillTree(from, to);
+ } else {
+ fs6.copyFileSync(from, to);
+ }
+ }
+}
+function syncSkillsToWorkspace({
+ h5Root,
+ publishDir,
+ skillMap,
+ catalog,
+ user,
+ publicBaseUrl
+}) {
+ 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 })) {
+ 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 });
+ }
+ }
+ }
+ 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);
+ if (item.name === PUBLISH_SKILL_NAME && user) {
+ ensurePublishSkillInstalled(publishDir, {
+ slug: String(user.id).trim().toLowerCase(),
+ username: user.username ? String(user.username).trim().toLowerCase() : void 0,
+ publicBaseUrl,
+ publishDir
+ });
+ continue;
+ }
+ if (fs6.existsSync(srcDir)) {
+ if (fs6.existsSync(destDir)) fs6.rmSync(destDir, { recursive: true, force: true });
+ copySkillTree(srcDir, destDir);
+ }
+ }
+}
+
+// user-auth.mjs
+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);
+}
+var PASSWORD_ALGORITHM_PBKDF2 = "pbkdf2-sha512";
+var PASSWORD_ALGORITHM_ARGON2ID = "argon2id";
+var ARGON2_MEMORY = 64 * 1024;
+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");
+}
+function hashPasswordArgon2id(password, salt) {
+ return argon2HashRawSync(password, {
+ salt: Buffer.from(salt, "hex"),
+ parallelism: ARGON2_PARALLELISM,
+ outputLen: ARGON2_TAG_LENGTH,
+ memoryCost: ARGON2_MEMORY,
+ timeCost: ARGON2_PASSES,
+ algorithm: Argon2Algorithm.Argon2id
+ }).toString("hex");
+}
+function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) {
+ const salt = crypto4.randomBytes(16).toString("hex");
+ if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) {
+ return {
+ salt,
+ passwordHash: hashPasswordArgon2id(password, salt),
+ passwordAlgorithm: PASSWORD_ALGORITHM_ARGON2ID
+ };
+ }
+ return {
+ salt,
+ passwordHash: hashPasswordPbkdf2(password, salt),
+ passwordAlgorithm: PASSWORD_ALGORITHM_PBKDF2
+ };
+}
+function verifyPassword(password, row) {
+ const algorithm = row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2;
+ if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) {
+ return safeEqual2(hashPasswordArgon2id(password, row.salt), row.password_hash);
+ }
+ return safeEqual2(hashPasswordPbkdf2(password, row.salt), row.password_hash);
+}
+function normalizeUsername(username) {
+ return username.trim().toLowerCase();
+}
+function isValidUsername(username) {
+ return /^[a-z0-9_]{2,32}$/.test(username);
+}
+function isValidEmail(email) {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+}
+function hashSessionToken(token) {
+ return crypto4.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, ".."));
+ const env = options.env ?? process.env;
+ const storageRoot = resolveMindspaceStorageRoot(h5Root, env);
+ const publicBaseUrl = resolvePublicBaseUrl(env);
+ const skillCatalog = listPlatformSkillCatalog(h5Root);
+ const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500);
+ 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);
+ let rechargeNotifier = typeof options.onRechargeNotification === "function" ? options.onRechargeNotification : null;
+ const subscriptionService2 = options.subscriptionService ?? null;
+ const sessions = /* @__PURE__ */ new Map();
+ const loginFailures = /* @__PURE__ */ new Map();
+ const pruneLoginFailures = (now = Date.now()) => {
+ for (const [key, state] of loginFailures) {
+ if (state.resetAt <= now) loginFailures.delete(key);
+ }
+ };
+ const pruneSessions = (now = Date.now()) => {
+ for (const [token, session] of sessions) {
+ if (session.expiresAt <= now) sessions.delete(token);
+ }
+ };
+ const storeSession = async (userId, role, token, now = Date.now()) => {
+ const expiresAt = now + sessionTtlMs;
+ sessions.set(token, { userId, role, expiresAt });
+ if (!persistSessions) return expiresAt;
+ await pool.query(
+ `INSERT INTO h5_login_sessions (id, user_id, token_hash, expires_at, created_at)
+ VALUES (?, ?, ?, ?, ?)`,
+ [crypto4.randomUUID(), userId, hashSessionToken(token), expiresAt, now]
+ );
+ return expiresAt;
+ };
+ const revokeAllSessionsForUser = async (userId, now = Date.now()) => {
+ for (const [token, session] of sessions) {
+ if (session.userId === userId) sessions.delete(token);
+ }
+ if (!persistSessions) return;
+ await pool.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 });
+ };
+ const isAdminRole = (user) => user?.role === "admin";
+ const getUserById = async (userId) => {
+ const [rows] = await pool.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,
+ w.balance_cents, w.tokens_used,
+ (SELECT COALESCE(SUM(ABS(amount_cents)), 0)
+ FROM h5_billing_ledger l
+ WHERE l.user_id = u.id AND l.type = 'deduct') AS spent_cents
+ FROM h5_users u
+ LEFT JOIN h5_user_spaces s ON s.user_id = u.id
+ LEFT JOIN h5_user_wallets w ON w.user_id = u.id
+ WHERE u.id = ?`,
+ [userId]
+ );
+ return rows[0] ?? null;
+ };
+ const publicUser = (row) => {
+ const balanceCents = Number(row.balance_cents ?? 0);
+ const spentCents = Number(row.spent_cents ?? 0);
+ const base = {
+ id: row.id,
+ username: row.username,
+ slug: row.slug ?? row.username,
+ email: row.email ?? null,
+ displayName: row.display_name,
+ role: row.role,
+ status: row.status,
+ planType: row.plan_type ?? "free",
+ workspaceRoot: row.workspace_root,
+ balanceCents,
+ totalCreditCents: balanceCents + spentCents,
+ tokensUsed: Number(row.tokens_used ?? 0),
+ spaceQuotaBytes: Number(row.quota_bytes ?? 0),
+ spaceUsedBytes: Number(row.used_bytes ?? 0),
+ spaceReservedBytes: Number(row.reserved_bytes ?? 0),
+ spaceAvailableBytes: Math.max(
+ 0,
+ Number(row.quota_bytes ?? 0) - Number(row.used_bytes ?? 0) - Number(row.reserved_bytes ?? 0)
+ )
+ };
+ const publishKey = row.id;
+ return {
+ ...base,
+ publishSlug: publishKey,
+ publishUrl: `${publicBaseUrl}/${PUBLISH_ROOT_DIR}/${encodeURIComponent(publishKey)}/`,
+ publishSkillName: PUBLISH_SKILL_NAME
+ };
+ };
+ const publishLayoutFor = async (user, { migrateLegacy = true } = {}) => {
+ const web = ensureUserPublishLayout({
+ h5Root,
+ publicBaseUrl,
+ user,
+ legacyUsersRoot: migrateLegacy ? usersRoot : null
+ });
+ const space = await ensureUserSpaceLayout({
+ pool,
+ storageRoot,
+ userId: user.id,
+ username: user.username ?? web.slug,
+ displayName: user.displayName,
+ publicBaseUrl,
+ slug: web.slug,
+ workspaceRoot: web.publishDir
+ });
+ const hintsContext = {
+ slug: web.slug,
+ username: user.username ?? web.username,
+ displayName: user.displayName,
+ publicBaseUrl,
+ publishDir: web.publishDir
+ };
+ ensurePublishSkillInstalled(web.publishDir, hintsContext);
+ ensureWorkspaceHintsInstalled(web.publishDir, hintsContext);
+ const legacyPublishDir = resolveLegacyPublishDir(h5Root, user);
+ if (legacyPublishDir && legacyPublishDir !== web.publishDir && fs7.existsSync(legacyPublishDir)) {
+ ensureWorkspaceHintsInstalled(legacyPublishDir, { ...hintsContext, publishDir: legacyPublishDir });
+ }
+ ensureUserMemoryProfile(web.publishDir, {
+ userId: user.id,
+ displayName: user.displayName ?? user.display_name,
+ username: user.username ?? web.username,
+ slug: web.slug
+ });
+ return {
+ ...web,
+ ...space,
+ publishDir: web.publishDir,
+ constraints: web.constraints
+ };
+ };
+ const listSkillGrants = async (subjectType, subjectId) => {
+ const [rows] = await pool.query(
+ `SELECT skill_name, enabled
+ FROM h5_user_skill_grants
+ WHERE subject_type = ? AND subject_id = ?`,
+ [subjectType, subjectId]
+ );
+ return Object.fromEntries(rows.map((row) => [row.skill_name, Boolean(row.enabled)]));
+ };
+ const resolveUserSkillMap = async (user) => {
+ if (!user || user.role === "admin") {
+ return Object.fromEntries(skillCatalog.map((item) => [item.name, true]));
+ }
+ const roleDefaults = await listSkillGrants("role", "user");
+ const userOverrides = await listSkillGrants("user", user.id);
+ return resolveSkillMap(roleDefaults, userOverrides, skillCatalog);
+ };
+ const syncUserSkillsForUser = async (user) => {
+ if (!user || user.role === "admin") return;
+ const layout = await syncUserPublishWorkspace(user);
+ const skillMap = await resolveUserSkillMap(user);
+ syncSkillsToWorkspace({
+ h5Root,
+ publishDir: layout?.publishDir,
+ skillMap,
+ catalog: skillCatalog,
+ user,
+ publicBaseUrl
+ });
+ };
+ 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);
+ if (current !== target) {
+ const now = Date.now();
+ await pool.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(
+ `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
+ [user.id, layout.publishDir]
+ );
+ }
+ return layout;
+ };
+ const recordSignupBonus = async (conn, userId, amountCents, now) => {
+ const amount = Number(amountCents);
+ if (!Number.isFinite(amount) || amount <= 0) return;
+ await conn.query(
+ `INSERT INTO h5_billing_ledger
+ (user_id, type, amount_cents, tokens, note, operator_id, created_at)
+ VALUES (?, 'adjust', ?, 0, '\u65B0\u7528\u6237\u8D60\u9001', NULL, ?)`,
+ [userId, amount, now]
+ );
+ };
+ const register = async ({ username, password, displayName, email }) => {
+ const normalized = normalizeUsername(username);
+ if (!isValidUsername(normalized)) {
+ return { ok: false, message: "\u7528\u6237\u540D\u4EC5\u652F\u6301 2-32 \u4F4D\u5C0F\u5199\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u4E0B\u5212\u7EBF" };
+ }
+ if (!password || password.length < 6) {
+ return { ok: false, message: "\u5BC6\u7801\u81F3\u5C11 6 \u4F4D" };
+ }
+ if (!email || !isValidEmail(email.trim())) {
+ return { ok: false, message: "\u8BF7\u8F93\u5165\u6709\u6548\u90AE\u7BB1" };
+ }
+ const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
+ const userId = crypto4.randomUUID();
+ const layout = await publishLayoutFor({ id: userId, username: normalized });
+ const workspaceRoot = layout.publishDir;
+ const now = Date.now();
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ await conn.query(
+ `INSERT INTO h5_users
+ (id, username, slug, email, display_name, salt, password_hash, password_algorithm,
+ role, status, plan_type, workspace_root, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, ?, ?)`,
+ [
+ userId,
+ normalized,
+ normalized,
+ email?.trim().toLowerCase() || null,
+ displayName?.trim() || normalized,
+ salt,
+ passwordHash,
+ passwordAlgorithm,
+ workspaceRoot,
+ now,
+ now
+ ]
+ );
+ await conn.query(
+ `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
+ VALUES (?, ?, 0, ?)`,
+ [userId, defaultSignupBalanceCents, now]
+ );
+ await recordSignupBonus(conn, userId, defaultSignupBalanceCents, now);
+ await conn.query(
+ `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
+ [userId, workspaceRoot]
+ );
+ await initializeDefaultSpace(conn, userId, {
+ quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
+ now
+ });
+ await conn.commit();
+ ensureWorkspace(workspaceRoot);
+ ensureUserMemoryProfile(workspaceRoot, {
+ userId,
+ displayName: displayName?.trim() || normalized,
+ username: normalized,
+ slug: normalized
+ });
+ if (subscriptionService2) {
+ subscriptionService2.grantSubscription(userId, "free", null, null, "\u6CE8\u518C\u8D60\u9001\u514D\u8D39\u5957\u9910").catch(() => {
+ });
+ }
+ const user = await getUserById(userId);
+ return { ok: true, user: publicUser(user) };
+ } catch (err) {
+ await conn.rollback();
+ if (err?.code === "ER_DUP_ENTRY") {
+ return { ok: false, message: "\u7528\u6237\u540D\u3001\u4E3B\u9875\u5730\u5740\u6216\u90AE\u7BB1\u5DF2\u5B58\u5728" };
+ }
+ throw err;
+ } finally {
+ conn.release();
+ }
+ };
+ const login = async ({ username, password, ip = "unknown", now = Date.now() }) => {
+ pruneLoginFailures(now);
+ const normalized = normalizeUsername(username);
+ const failureKey = `${ip}:${normalized}`;
+ const failure = loginFailures.get(failureKey);
+ if (failure && failure.count >= loginMaxFailures && failure.resetAt > now) {
+ return {
+ ok: false,
+ message: "\u5C1D\u8BD5\u6B21\u6570\u8FC7\u591A\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5",
+ retryAfterMs: failure.resetAt - now
+ };
+ }
+ const [rows] = await pool.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
+ FROM h5_users u
+ LEFT JOIN h5_user_wallets w ON w.user_id = u.id
+ WHERE u.username = ?`,
+ [normalized]
+ );
+ const row = rows[0];
+ if (!row) {
+ const current = failure && failure.resetAt > now ? failure : { count: 0, resetAt: now + loginFailureWindowMs };
+ current.count += 1;
+ loginFailures.set(failureKey, current);
+ return { ok: false, message: "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" };
+ }
+ if (!verifyPassword(password, row)) {
+ const current = failure && failure.resetAt > now ? failure : { count: 0, resetAt: now + loginFailureWindowMs };
+ current.count += 1;
+ loginFailures.set(failureKey, current);
+ return { ok: false, message: "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" };
+ }
+ if (row.status === "disabled") {
+ return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" };
+ }
+ if ((row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2) !== PASSWORD_ALGORITHM_ARGON2ID) {
+ const nextPassword = createPasswordRecord(password);
+ await pool.query(
+ `UPDATE h5_users
+ SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ?
+ WHERE id = ?`,
+ [nextPassword.salt, nextPassword.passwordHash, nextPassword.passwordAlgorithm, now, row.id]
+ );
+ row.salt = nextPassword.salt;
+ row.password_hash = nextPassword.passwordHash;
+ row.password_algorithm = nextPassword.passwordAlgorithm;
+ }
+ loginFailures.delete(failureKey);
+ const token = crypto4.randomBytes(32).toString("base64url");
+ await storeSession(row.id, row.role, token, now);
+ return { ok: true, token, user: publicUser(row) };
+ };
+ const resetPassword = async ({ username, email, password }) => {
+ const normalized = normalizeUsername(username);
+ if (!isValidUsername(normalized)) {
+ return { ok: false, message: "\u7528\u6237\u540D\u6216\u90AE\u7BB1\u4E0D\u6B63\u786E" };
+ }
+ if (!email || !isValidEmail(email.trim())) {
+ return { ok: false, message: "\u8BF7\u8F93\u5165\u6709\u6548\u90AE\u7BB1" };
+ }
+ if (!password || password.length < 6) {
+ return { ok: false, message: "\u65B0\u5BC6\u7801\u81F3\u5C11 6 \u4F4D" };
+ }
+ const [rows] = await pool.query(
+ `SELECT id, email, status FROM h5_users WHERE username = ? LIMIT 1`,
+ [normalized]
+ );
+ const row = rows[0];
+ const normalizedEmail = email.trim().toLowerCase();
+ if (!row || (row.email ?? "").toLowerCase() !== normalizedEmail) {
+ return { ok: false, message: "\u7528\u6237\u540D\u6216\u90AE\u7BB1\u4E0D\u6B63\u786E" };
+ }
+ if (row.status === "disabled") {
+ return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" };
+ }
+ const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
+ const now = Date.now();
+ await pool.query(
+ `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`,
+ [salt, passwordHash, passwordAlgorithm, now, row.id]
+ );
+ await revokeAllSessionsForUser(row.id, now);
+ return { ok: true };
+ };
+ const verify = async (token, now = Date.now()) => {
+ if (!token) return null;
+ pruneSessions(now);
+ const cached = sessions.get(token);
+ if (cached) {
+ if (cached.expiresAt <= now) {
+ sessions.delete(token);
+ return null;
+ }
+ const user = await getUserById(cached.userId);
+ if (!user || user.status === "disabled") {
+ await revoke(token, now);
+ return null;
+ }
+ cached.expiresAt = now + sessionTtlMs;
+ if (persistSessions) {
+ await pool.query(
+ `UPDATE h5_login_sessions
+ SET expires_at = ?
+ WHERE token_hash = ? AND revoked_at IS NULL`,
+ [cached.expiresAt, hashSessionToken(token)]
+ );
+ }
+ return cached;
+ }
+ if (!persistSessions) return null;
+ const tokenHash = hashSessionToken(token);
+ const [rows] = await pool.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
+ WHERE s.token_hash = ? AND s.revoked_at IS NULL
+ LIMIT 1`,
+ [tokenHash]
+ );
+ const row = rows[0];
+ if (!row || Number(row.expires_at ?? 0) <= now || row.status === "disabled") {
+ if (row) await revoke(token, now);
+ return null;
+ }
+ const expiresAt = now + sessionTtlMs;
+ await pool.query(
+ `UPDATE h5_login_sessions SET expires_at = ? WHERE token_hash = ? AND revoked_at IS NULL`,
+ [expiresAt, tokenHash]
+ );
+ const session = { userId: row.user_id, role: row.role, expiresAt };
+ sessions.set(token, session);
+ return session;
+ };
+ const revoke = async (token, now = Date.now()) => {
+ if (!token) return;
+ sessions.delete(token);
+ if (!persistSessions) return;
+ await pool.query(
+ `UPDATE h5_login_sessions SET revoked_at = ? WHERE token_hash = ? AND revoked_at IS NULL`,
+ [now, hashSessionToken(token)]
+ );
+ };
+ const getMe = async (token) => {
+ const session = await verify(token);
+ if (!session) return null;
+ const user = await getUserById(session.userId);
+ if (!user) return null;
+ return publicUser(user);
+ };
+ const listPathGrants = async (userId) => {
+ const [rows] = await pool.query(
+ `SELECT path, mode FROM h5_user_path_grants WHERE user_id = ? ORDER BY path`,
+ [userId]
+ );
+ return rows.map((row) => ({ path: row.path, mode: row.mode }));
+ };
+ const resolveWorkingDir = async (userId) => {
+ const user = await getUserById(userId);
+ if (!user) throw new Error("\u7528\u6237\u4E0D\u5B58\u5728");
+ const layout = await syncUserPublishWorkspace(user);
+ return layout.publishDir;
+ };
+ const getUserPublishLayout = async (userId) => {
+ const user = await getUserById(userId);
+ if (!user) return null;
+ return syncUserPublishWorkspace(user);
+ };
+ const isPathAllowed = async (userId, requestedPath) => {
+ const user = await getUserById(userId);
+ if (!user) return false;
+ if (isAdminRole(user)) return true;
+ const layout = await publishLayoutFor(user, { migrateLegacy: false });
+ return isPathInsideUserWorkspace(layout.publishDir, requestedPath);
+ };
+ const repairAllUserPublishDirs = async () => {
+ const [rows] = await pool.query(
+ `SELECT id, username, role, workspace_root FROM h5_users WHERE role = 'user'`
+ );
+ for (const row of rows) {
+ await syncUserSkillsForUser(row);
+ }
+ };
+ const seedRoleSkillDefaults = async () => {
+ const now = Date.now();
+ for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) {
+ await pool.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)`,
+ [name, enabled ? 1 : 0, now]
+ );
+ }
+ };
+ 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 getSessionNode = async (agentSessionId) => {
+ const [rows] = await pool.query(
+ `SELECT goosed_node FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
+ [agentSessionId]
+ );
+ return rows[0]?.goosed_node ?? 0;
+ };
+ const ownsSession = async (userId, agentSessionId) => {
+ const [rows] = await pool.query(
+ `SELECT 1 FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ? LIMIT 1`,
+ [agentSessionId, userId]
+ );
+ return rows.length > 0;
+ };
+ const listOwnedSessionIds = async (userId) => {
+ const [rows] = await pool.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(
+ `DELETE FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ?`,
+ [agentSessionId, userId]
+ );
+ };
+ const canUseChat = async (userId) => {
+ const user = await getUserById(userId);
+ if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
+ if (user.status === "disabled") {
+ return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528" };
+ }
+ if (user.status === "suspended" && !isAdminRole(user)) {
+ return { ok: false, message: "\u8D26\u6237\u5DF2\u6682\u505C" };
+ }
+ if (isAdminRole(user)) {
+ return { ok: true, balanceCents: Number(user.balance_cents ?? 0) };
+ }
+ if (subscriptionService2) {
+ const sub = await subscriptionService2.getActiveSubscription(userId);
+ if (sub) {
+ const unlimited = sub.periodTokensLimit === 0;
+ const hasQuota = unlimited || sub.periodTokensUsed < sub.periodTokensLimit;
+ const balanceCents2 = Number(user.balance_cents ?? 0);
+ if (hasQuota) {
+ return { ok: true, balanceCents: balanceCents2, subscription: sub };
+ }
+ if (balanceCents2 > 0) {
+ return { ok: true, balanceCents: balanceCents2, subscription: sub, overQuota: true };
+ }
+ return {
+ ok: false,
+ message: "\u672C\u6708\u989D\u5EA6\u5DF2\u7528\u5B8C\uFF0C\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u6216\u5347\u7EA7\u5957\u9910",
+ ...buildInsufficientBalancePayload(balanceCents2, loadRechargeConfig())
+ };
+ }
+ }
+ const balanceCents = Number(user.balance_cents ?? 0);
+ if (balanceCents <= 0) {
+ return {
+ ok: false,
+ message: "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u540E\u7EE7\u7EED\u4F7F\u7528",
+ ...buildInsufficientBalancePayload(balanceCents, loadRechargeConfig())
+ };
+ }
+ return { ok: true, balanceCents };
+ };
+ const listUsers = async ({ page = 1, pageSize = 20, search = "", role = "", status = "" } = {}) => {
+ const safePageSize = Math.min(Math.max(Number(pageSize) || 20, 1), 100);
+ const safePage = Math.max(Number(page) || 1, 1);
+ const offset = (safePage - 1) * safePageSize;
+ const params = [];
+ const clauses = [];
+ if (search) {
+ clauses.push("(u.username LIKE ? OR u.display_name LIKE ?)");
+ params.push(`%${search}%`, `%${search}%`);
+ }
+ if (role) {
+ clauses.push("u.role = ?");
+ params.push(role);
+ }
+ if (status) {
+ clauses.push("u.status = ?");
+ params.push(status);
+ }
+ const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
+ const [[{ total }]] = await pool.query(
+ `SELECT COUNT(*) AS total FROM h5_users u ${where}`,
+ params
+ );
+ const [rows] = await pool.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,
+ u.created_at, u.updated_at, w.balance_cents, w.tokens_used
+ FROM h5_users u
+ LEFT JOIN h5_user_spaces s ON s.user_id = u.id
+ LEFT JOIN h5_user_wallets w ON w.user_id = u.id
+ ${where}
+ ORDER BY u.created_at DESC
+ LIMIT ${safePageSize} OFFSET ${offset}`,
+ params
+ );
+ return {
+ users: rows.map((row) => ({ ...publicUser(row), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) })),
+ total: Number(total),
+ page: safePage,
+ pageSize: safePageSize
+ };
+ };
+ const getUserPublic = async (userId) => {
+ const user = await getUserById(userId);
+ return user ? publicUser(user) : null;
+ };
+ const createUser = async ({
+ username,
+ password,
+ displayName,
+ workspaceRoot,
+ balanceCents,
+ role = "user",
+ email
+ }) => {
+ const normalized = normalizeUsername(username);
+ if (!isValidUsername(normalized)) {
+ return { ok: false, message: "\u7528\u6237\u540D\u683C\u5F0F\u65E0\u6548" };
+ }
+ if (!password || password.length < 6) {
+ return { ok: false, message: "\u5BC6\u7801\u81F3\u5C11 6 \u4F4D" };
+ }
+ const isAdmin = role === "admin";
+ const userId = crypto4.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();
+ try {
+ await conn.beginTransaction();
+ await conn.query(
+ `INSERT INTO h5_users
+ (id, username, slug, email, display_name, salt, password_hash, password_algorithm,
+ role, status, plan_type, workspace_root, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, ?)`,
+ [
+ userId,
+ normalized,
+ normalized,
+ email?.trim().toLowerCase() || null,
+ displayName?.trim() || normalized,
+ salt,
+ passwordHash,
+ passwordAlgorithm,
+ isAdmin ? "admin" : "user",
+ root,
+ now,
+ now
+ ]
+ );
+ await conn.query(
+ `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
+ VALUES (?, ?, 0, ?)`,
+ [userId, Number(balanceCents ?? defaultSignupBalanceCents), now]
+ );
+ await conn.query(
+ `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
+ [userId, root]
+ );
+ if (!isAdmin) {
+ await initializeDefaultSpace(conn, userId, {
+ quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
+ now
+ });
+ }
+ await conn.commit();
+ ensureWorkspace(root);
+ if (subscriptionService2 && !isAdmin) {
+ subscriptionService2.grantSubscription(userId, "free", null, null, "\u6CE8\u518C\u8D60\u9001\u514D\u8D39\u5957\u9910").catch(() => {
+ });
+ }
+ const user = await getUserById(userId);
+ return { ok: true, user: publicUser(user) };
+ } catch (err) {
+ await conn.rollback();
+ if (err?.code === "ER_DUP_ENTRY") {
+ return { ok: false, message: "\u7528\u6237\u540D\u5DF2\u5B58\u5728" };
+ }
+ throw err;
+ } finally {
+ conn.release();
+ }
+ };
+ const updateUser = async (userId, patch) => {
+ const user = await getUserById(userId);
+ if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
+ const now = Date.now();
+ const fields = [];
+ const values = [];
+ if (patch.displayName !== void 0) {
+ fields.push("display_name = ?");
+ values.push(patch.displayName.trim() || user.username);
+ }
+ if (patch.status !== void 0) {
+ fields.push("status = ?");
+ values.push(patch.status);
+ }
+ if (patch.workspaceRoot !== void 0) {
+ const root = path8.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(
+ `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
+ [userId, root]
+ );
+ }
+ if (patch.role !== void 0) {
+ fields.push("role = ?");
+ values.push(patch.role === "admin" ? "admin" : "user");
+ }
+ if (fields.length > 0) {
+ fields.push("updated_at = ?");
+ values.push(now, userId);
+ await pool.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(
+ `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)`,
+ [userId, Number(patch.balanceCents), now]
+ );
+ }
+ if (patch.spaceQuotaBytes !== void 0) {
+ const quotaBytes = Math.floor(Number(patch.spaceQuotaBytes));
+ if (!Number.isFinite(quotaBytes) || quotaBytes <= 0) {
+ return { ok: false, message: "\u7A7A\u95F4\u5927\u5C0F\u65E0\u6548" };
+ }
+ const [spaceRows] = await pool.query(
+ `SELECT id, quota_bytes, used_bytes, reserved_bytes
+ FROM h5_user_spaces
+ WHERE user_id = ?
+ LIMIT 1`,
+ [userId]
+ );
+ const currentSpace = spaceRows[0];
+ const occupiedBytes = Number(currentSpace?.used_bytes ?? 0) + Number(currentSpace?.reserved_bytes ?? 0);
+ if (quotaBytes < occupiedBytes) {
+ return {
+ ok: false,
+ message: `\u7A7A\u95F4\u4E0D\u80FD\u5C0F\u4E8E\u5DF2\u4F7F\u7528\u5BB9\u91CF ${Math.ceil(occupiedBytes / 1024 / 1024)} MB`
+ };
+ }
+ if (currentSpace?.id) {
+ await pool.query(
+ `UPDATE h5_user_spaces
+ SET quota_bytes = ?, updated_at = ?
+ WHERE user_id = ?`,
+ [quotaBytes, now, userId]
+ );
+ } else {
+ await initializeDefaultSpace(pool, userId, {
+ quotaBytes,
+ now
+ });
+ }
+ }
+ const updated = await getUserById(userId);
+ return { ok: true, user: publicUser(updated) };
+ };
+ const purchaseSpaceQuota = async (userId, sizeMb) => {
+ const purchaseMb = Math.floor(Number(sizeMb));
+ if (!Number.isFinite(purchaseMb) || purchaseMb <= 0) {
+ return { ok: false, message: "\u6269\u5BB9\u5927\u5C0F\u65E0\u6548" };
+ }
+ const deltaBytes = purchaseMb * 1024 * 1024;
+ const costCents = purchaseMb * 200;
+ const now = Date.now();
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ const [spaceRows] = await conn.query(
+ `SELECT id, quota_bytes, used_bytes, reserved_bytes
+ FROM h5_user_spaces
+ WHERE user_id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [userId]
+ );
+ if (!spaceRows[0]) {
+ await initializeDefaultSpace(conn, userId, { now });
+ }
+ const [walletRows] = await conn.query(
+ `SELECT balance_cents
+ FROM h5_user_wallets
+ WHERE user_id = ?
+ FOR UPDATE`,
+ [userId]
+ );
+ const balanceCents = Number(walletRows[0]?.balance_cents ?? 0);
+ if (balanceCents < costCents) {
+ await conn.rollback();
+ return {
+ ok: false,
+ code: "INSUFFICIENT_BALANCE",
+ message: "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5148\u5145\u503C\u540E\u518D\u8D2D\u4E70\u7A7A\u95F4",
+ balanceCents,
+ minRechargeCents: Math.max(500, costCents - balanceCents),
+ suggestedTiers: loadRechargeConfig().tiersCents
+ };
+ }
+ await conn.query(
+ `UPDATE h5_user_wallets
+ SET balance_cents = balance_cents - ?, updated_at = ?
+ WHERE user_id = ?`,
+ [costCents, now, userId]
+ );
+ await conn.query(
+ `UPDATE h5_user_spaces
+ SET quota_bytes = quota_bytes + ?, updated_at = ?
+ WHERE user_id = ?`,
+ [deltaBytes, now, userId]
+ );
+ await conn.query(
+ `INSERT INTO h5_billing_ledger
+ (user_id, type, amount_cents, tokens, note, operator_id, created_at)
+ VALUES (?, 'deduct', ?, 0, ?, NULL, ?)`,
+ [userId, costCents, `space_purchase:${purchaseMb}MB`, now]
+ );
+ await conn.query(
+ `INSERT INTO h5_user_notifications
+ (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(),
+ userId,
+ "\u7A7A\u95F4\u6269\u5BB9\u6210\u529F",
+ `\u5DF2\u8D2D\u4E70 ${purchaseMb} MB \u7A7A\u95F4\uFF0C\u652F\u4ED8 \xA5${(costCents / 100).toFixed(2)}\u3002`,
+ JSON.stringify({ purchaseMb, deltaBytes, costCents }),
+ now,
+ now
+ ]
+ );
+ await conn.commit();
+ const [updatedSpaceRows] = await pool.query(
+ `SELECT quota_bytes, used_bytes, reserved_bytes
+ FROM h5_user_spaces
+ WHERE user_id = ?
+ LIMIT 1`,
+ [userId]
+ );
+ const updatedSpace = updatedSpaceRows[0] ?? {};
+ const updatedUser = await getUserById(userId);
+ return {
+ ok: true,
+ balanceCents: Number(updatedUser?.balance_cents ?? Math.max(0, balanceCents - costCents)),
+ quota: {
+ quotaBytes: Number(updatedSpace.quota_bytes ?? 0),
+ usedBytes: Number(updatedSpace.used_bytes ?? 0),
+ reservedBytes: Number(updatedSpace.reserved_bytes ?? 0),
+ availableBytes: Math.max(
+ 0,
+ Number(updatedSpace.quota_bytes ?? 0) - Number(updatedSpace.used_bytes ?? 0) - Number(updatedSpace.reserved_bytes ?? 0)
+ )
+ }
+ };
+ } catch (err) {
+ await conn.rollback();
+ throw err;
+ } finally {
+ conn.release();
+ }
+ };
+ const recharge = async (userId, amountCents, operatorId, note = "", options2 = {}) => {
+ const amount = Number(amountCents);
+ if (!Number.isFinite(amount) || amount <= 0) {
+ return { ok: false, message: "\u5145\u503C\u91D1\u989D\u65E0\u6548" };
+ }
+ const user = await getUserById(userId);
+ if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
+ const paymentOrderId = options2.paymentOrderId ?? null;
+ const ledgerNote = paymentOrderId ? `order:${paymentOrderId}` : note || (operatorId ? "\u7BA1\u7406\u5458\u5145\u503C" : "\u8D26\u6237\u5145\u503C");
+ const rechargeType = paymentOrderId ? "self_recharge" : operatorId ? "admin_recharge" : "recharge";
+ const now = Date.now();
+ const ownsConnection = !options2.conn;
+ const conn = options2.conn ?? await pool.getConnection();
+ try {
+ if (ownsConnection) await conn.beginTransaction();
+ await conn.query(
+ `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
+ VALUES (?, ?, 0, ?)
+ ON DUPLICATE KEY UPDATE
+ balance_cents = balance_cents + VALUES(balance_cents),
+ updated_at = VALUES(updated_at)`,
+ [userId, amount, now]
+ );
+ await conn.query(
+ `INSERT INTO h5_billing_ledger
+ (user_id, type, amount_cents, tokens, note, operator_id, created_at)
+ VALUES (?, 'recharge', ?, 0, ?, ?, ?)`,
+ [userId, amount, ledgerNote, operatorId, now]
+ );
+ const title = paymentOrderId ? "\u5145\u503C\u6210\u529F" : operatorId ? "\u7BA1\u7406\u5458\u5DF2\u5145\u503C" : "\u8D26\u6237\u5145\u503C\u6210\u529F";
+ const body = paymentOrderId ? `\u4F60\u5DF2\u6210\u529F\u5145\u503C \xA5${(amount / 100).toFixed(2)}\uFF0C\u4F59\u989D\u5DF2\u66F4\u65B0\u3002` : operatorId ? `\u7BA1\u7406\u5458\u5DF2\u4E3A\u4F60\u5145\u503C \xA5${(amount / 100).toFixed(2)}\uFF0C\u4F59\u989D\u5DF2\u66F4\u65B0\u3002` : `\u4F60\u7684\u8D26\u6237\u5DF2\u5145\u503C \xA5${(amount / 100).toFixed(2)}\uFF0C\u4F59\u989D\u5DF2\u66F4\u65B0\u3002`;
+ await conn.query(
+ `INSERT INTO h5_user_notifications
+ (id, user_id, channel, notification_type, title, body, data_json, status, read_at, created_at, updated_at)
+ VALUES (?, ?, 'web', ?, ?, ?, ?, 'unread', NULL, ?, ?)`,
+ [
+ crypto4.randomUUID(),
+ userId,
+ rechargeType,
+ title,
+ body,
+ JSON.stringify({
+ amountCents: amount,
+ operatorId: operatorId ?? null,
+ paymentOrderId
+ }),
+ now,
+ now
+ ]
+ );
+ if (user.status === "suspended") {
+ await conn.query(`UPDATE h5_users SET status = 'active', updated_at = ? WHERE id = ?`, [
+ now,
+ userId
+ ]);
+ }
+ if (ownsConnection) await conn.commit();
+ const updated = await getUserById(userId);
+ if (rechargeNotifier) {
+ try {
+ await rechargeNotifier({
+ userId,
+ amountCents: amount,
+ operatorId: operatorId ?? null,
+ paymentOrderId,
+ notificationType: rechargeType,
+ title,
+ body,
+ user: publicUser(updated)
+ });
+ } catch (err) {
+ console.warn(
+ "Recharge notifier failed:",
+ err instanceof Error ? err.message : String(err)
+ );
+ }
+ }
+ return { ok: true, user: publicUser(updated) };
+ } catch (err) {
+ if (ownsConnection) await conn.rollback();
+ throw err;
+ } finally {
+ if (ownsConnection) conn.release();
+ }
+ };
+ const getBillingState = async (agentSessionId) => {
+ const [rows] = await pool.query(
+ `SELECT agent_session_id, user_id, last_accumulated_cost, last_input_tokens,
+ last_output_tokens, updated_at
+ FROM h5_session_billing_state
+ WHERE agent_session_id = ?`,
+ [agentSessionId]
+ );
+ const row = rows[0];
+ if (!row) return null;
+ return {
+ agentSessionId: row.agent_session_id,
+ userId: row.user_id,
+ lastAccumulatedCost: row.last_accumulated_cost,
+ lastInputTokens: Number(row.last_input_tokens ?? 0),
+ lastOutputTokens: Number(row.last_output_tokens ?? 0),
+ updatedAt: Number(row.updated_at)
+ };
+ };
+ const billSessionUsage = async (userId, agentSessionId, tokenStateRaw, requestId = null) => {
+ const user = await getUserById(userId);
+ if (isAdminRole(user)) {
+ return {
+ ok: true,
+ costCents: 0,
+ balanceCents: Number(user?.balance_cents ?? 0),
+ tokensUsed: Number(user?.tokens_used ?? 0),
+ deltaInputTokens: 0,
+ deltaOutputTokens: 0
+ };
+ }
+ 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 now = Date.now();
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ 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 (?, ?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ last_accumulated_cost = VALUES(last_accumulated_cost),
+ last_input_tokens = VALUES(last_input_tokens),
+ last_output_tokens = VALUES(last_output_tokens),
+ updated_at = VALUES(updated_at)`,
+ [
+ agentSessionId,
+ userId,
+ tokenState.accumulatedCost,
+ tokenState.accumulatedInputTokens,
+ tokenState.accumulatedOutputTokens,
+ now
+ ]
+ );
+ if (costCents > 0 && subscriptionService2) {
+ const coverage = await subscriptionService2.consumeQuota(userId, deltaTokens, conn);
+ if (coverage.fullyCovers) {
+ costCents = 0;
+ } else if (coverage.overageRate < 1) {
+ costCents = Math.max(1, Math.ceil(costCents * coverage.overageRate));
+ }
+ }
+ let balanceAfter = null;
+ let tokensUsedAfter = null;
+ if (costCents > 0) {
+ const [walletRows] = await conn.query(
+ `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ? FOR UPDATE`,
+ [userId]
+ );
+ const wallet = walletRows[0];
+ if (!wallet) {
+ await conn.rollback();
+ return { ok: false, message: "\u94B1\u5305\u4E0D\u5B58\u5728", costCents: 0 };
+ }
+ const currentBalance = Number(wallet.balance_cents ?? 0);
+ const nextBalance = Math.max(0, currentBalance - costCents);
+ balanceAfter = nextBalance;
+ tokensUsedAfter = Number(wallet.tokens_used ?? 0) + deltaTokens;
+ await conn.query(
+ `UPDATE h5_user_wallets
+ SET balance_cents = ?, tokens_used = tokens_used + ?, updated_at = ?
+ WHERE user_id = ?`,
+ [nextBalance, deltaTokens, now, userId]
+ );
+ await conn.query(
+ `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]
+ );
+ await conn.query(
+ `INSERT INTO h5_billing_ledger
+ (user_id, type, amount_cents, tokens, session_id, note, operator_id, created_at)
+ VALUES (?, 'deduct', ?, ?, ?, ?, NULL, ?)`,
+ [
+ userId,
+ -costCents,
+ deltaTokens,
+ agentSessionId,
+ `\u5BF9\u8BDD\u6263\u8D39 input=${deltaIn} output=${deltaOut}`,
+ now
+ ]
+ );
+ if (nextBalance <= 0) {
+ await conn.query(`UPDATE h5_users SET status = 'suspended', updated_at = ? WHERE id = ?`, [
+ now,
+ userId
+ ]);
+ }
+ } else {
+ const user2 = await getUserById(userId);
+ balanceAfter = user2 ? Number(user2.balance_cents) : null;
+ tokensUsedAfter = user2 ? Number(user2.tokens_used ?? 0) : null;
+ }
+ await conn.commit();
+ return {
+ ok: true,
+ costCents,
+ balanceCents: balanceAfter,
+ tokensUsed: tokensUsedAfter,
+ deltaInputTokens: deltaIn,
+ deltaOutputTokens: deltaOut
+ };
+ } catch (err) {
+ await conn.rollback();
+ throw err;
+ } finally {
+ conn.release();
+ }
+ };
+ const listUsageRecords = async ({ userId = null, page = 1, pageSize = 20, limit = null } = {}) => {
+ if (limit !== null) {
+ const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200);
+ const params2 = [];
+ const where2 = userId ? "WHERE r.user_id = ?" : "";
+ if (userId) params2.push(userId);
+ const [rows2] = await pool.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
+ ${where2} ORDER BY r.created_at DESC LIMIT ${safeLimit}`,
+ params2
+ );
+ return rows2.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) }));
+ }
+ const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
+ const safePage = Math.max(Number(page) || 1, 1);
+ const offset = (safePage - 1) * safePageSize;
+ const params = [];
+ const where = userId ? "WHERE r.user_id = ?" : "";
+ if (userId) params.push(userId);
+ const [[{ total }]] = await pool.query(
+ `SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`,
+ params
+ );
+ const [rows] = await pool.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
+ ${where}
+ ORDER BY r.created_at DESC
+ LIMIT ${safePageSize} OFFSET ${offset}`,
+ params
+ );
+ return {
+ records: rows.map((row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, agentSessionId: row.agent_session_id, requestId: row.request_id, inputTokens: Number(row.input_tokens), outputTokens: Number(row.output_tokens), costCents: Number(row.cost_cents), balanceAfterCents: Number(row.balance_after_cents), createdAt: Number(row.created_at) })),
+ total: Number(total),
+ page: safePage,
+ pageSize: safePageSize
+ };
+ };
+ const listBillingLedger = async ({ userId = null, page = 1, pageSize = 20, limit = null, types = 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);
+ }
+ return clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
+ };
+ const mapRow = (row) => ({ id: Number(row.id), userId: row.user_id, username: row.username, type: row.type, amountCents: Number(row.amount_cents), tokens: Number(row.tokens), sessionId: row.session_id, note: row.note, createdAt: Number(row.created_at) });
+ if (limit !== null) {
+ 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);
+ return rows2.map(mapRow);
+ }
+ const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200);
+ const safePage = Math.max(Number(page) || 1, 1);
+ const offset = (safePage - 1) * safePageSize;
+ const countParams = [];
+ const where = buildWhere(countParams);
+ const [[{ total }]] = await pool.query(
+ `SELECT COUNT(*) AS total FROM h5_billing_ledger l ${where}`,
+ countParams
+ );
+ const dataParams = [];
+ const dataWhere = buildWhere(dataParams);
+ const [rows] = 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
+ ${dataWhere}
+ ORDER BY l.created_at DESC
+ LIMIT ${safePageSize} OFFSET ${offset}`,
+ dataParams
+ );
+ return { entries: rows.map(mapRow), total: Number(total), page: safePage, pageSize: safePageSize };
+ };
+ const getAdminSummary = async () => {
+ const since24h = Date.now() - 24 * 60 * 60 * 1e3;
+ const [userRows] = await pool.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
+ LEFT JOIN h5_user_wallets w ON w.user_id = u.id`
+ );
+ let total = 0;
+ let active = 0;
+ let lowBalance = 0;
+ let totalBalanceCents = 0;
+ const lowBalanceUsers = [];
+ for (const row of userRows) {
+ total += 1;
+ if (row.status === "active") active += 1;
+ const balanceCents = Number(row.balance_cents);
+ totalBalanceCents += balanceCents;
+ if (row.role === "user" && balanceCents <= 0) {
+ lowBalance += 1;
+ if (lowBalanceUsers.length < 8) {
+ lowBalanceUsers.push({
+ id: row.id,
+ username: row.username,
+ displayName: row.display_name,
+ balanceCents
+ });
+ }
+ }
+ }
+ const [[usage24h]] = await pool.query(
+ `SELECT COUNT(*) AS count, COALESCE(SUM(cost_cents), 0) AS cost_cents
+ FROM h5_usage_records
+ WHERE created_at >= ?`,
+ [since24h]
+ );
+ const recentUsage = await listUsageRecords({ limit: 8 });
+ const recentLedger = await listBillingLedger({ limit: 8 });
+ return {
+ users: { total, active, lowBalance, totalBalanceCents },
+ usage24h: {
+ count: Number(usage24h.count),
+ costCents: Number(usage24h.cost_cents)
+ },
+ lowBalanceUsers,
+ recentUsage,
+ recentLedger
+ };
+ };
+ const syncAdminPassword = async () => {
+ const adminUsername = normalizeUsername(process.env.H5_ADMIN_USERNAME ?? "admin");
+ const adminPassword = process.env.H5_ADMIN_PASSWORD;
+ if (!adminPassword) return;
+ const [rows] = await pool.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(
+ `UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`,
+ [salt, passwordHash, passwordAlgorithm, now, rows[0].id]
+ );
+ };
+ const seedRoleCapabilityDefaults = async () => {
+ const now = Date.now();
+ for (const [key, allowed] of Object.entries(DEFAULT_USER_CAPABILITIES)) {
+ await pool.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`,
+ [key, allowed ? 1 : 0, now]
+ );
+ }
+ };
+ const upgradeMemoryStoreCapability = async () => {
+ const now = Date.now();
+ await pool.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)`,
+ [now]
+ );
+ };
+ const upgradeDefaultUserCapabilities = async () => {
+ const now = Date.now();
+ for (const key of ["skills", "chat_recall"]) {
+ if (!DEFAULT_USER_CAPABILITIES[key]) continue;
+ await pool.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)`,
+ [key, now]
+ );
+ }
+ };
+ const upgradeDefaultUserSkills = async () => {
+ const now = Date.now();
+ for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) {
+ if (!enabled) continue;
+ await pool.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)`,
+ [name, now]
+ );
+ }
+ };
+ const serializePolicyValue = (key, value) => {
+ const def = POLICY_CATALOG.find((item) => item.key === key);
+ if (def?.type === "boolean") return value ? "true" : "false";
+ return String(value);
+ };
+ const parsePolicyValue = (key, raw) => {
+ const def = POLICY_CATALOG.find((item) => item.key === key);
+ if (def?.type === "boolean") return raw === "true" || raw === "1";
+ return raw;
+ };
+ const seedRolePolicyDefaults = async () => {
+ const now = Date.now();
+ for (const [key, value] of Object.entries(DEFAULT_USER_POLICIES)) {
+ await pool.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`,
+ [key, serializePolicyValue(key, value), now]
+ );
+ }
+ };
+ const listPolicyEntries = async (subjectType, subjectId) => {
+ const [rows] = await pool.query(
+ `SELECT policy_key, policy_value
+ FROM h5_user_policies
+ WHERE subject_type = ? AND subject_id = ?`,
+ [subjectType, subjectId]
+ );
+ return Object.fromEntries(
+ rows.map((row) => [row.policy_key, parsePolicyValue(row.policy_key, row.policy_value)])
+ );
+ };
+ const resolveUserPolicies = async (user) => {
+ if (!user || user.role === "admin") {
+ return { unrestricted: true, policies: {} };
+ }
+ const roleDefaults = await listPolicyEntries("role", "user");
+ const userOverrides = await listPolicyEntries("user", user.id);
+ return {
+ unrestricted: false,
+ policies: resolvePolicies(roleDefaults, userOverrides)
+ };
+ };
+ const listCapabilityGrants = async (subjectType, subjectId) => {
+ const [rows] = await pool.query(
+ `SELECT capability_key, allowed
+ FROM h5_capability_grants
+ WHERE subject_type = ? AND subject_id = ?`,
+ [subjectType, subjectId]
+ );
+ return Object.fromEntries(
+ rows.map((row) => [row.capability_key, Boolean(row.allowed)])
+ );
+ };
+ const resolveUserCapabilities = async (user) => {
+ if (!user) return { unrestricted: true, capabilities: {} };
+ if (user.role === "admin") {
+ const skillMap2 = Object.fromEntries(skillCatalog.map((item) => [item.name, true]));
+ return {
+ unrestricted: true,
+ capabilities: Object.fromEntries(catalogKeys().map((key) => [key, true])),
+ skills: skillMap2,
+ grantedSkills: grantedSkillNames(skillMap2)
+ };
+ }
+ const roleDefaults = await listCapabilityGrants("role", "user");
+ const userOverrides = await listCapabilityGrants("user", user.id);
+ const capabilities = {};
+ for (const key of catalogKeys()) {
+ if (key in userOverrides) {
+ capabilities[key] = userOverrides[key];
+ } else if (key in roleDefaults) {
+ capabilities[key] = roleDefaults[key];
+ } else {
+ capabilities[key] = DEFAULT_USER_CAPABILITIES[key] ?? false;
+ }
+ }
+ const skillMap = await resolveUserSkillMap(user);
+ const withSkills = applySkillGrantsToCapabilities(capabilities, skillMap);
+ return {
+ unrestricted: false,
+ capabilities: clampUserCapabilities(withSkills),
+ skills: skillMap,
+ grantedSkills: grantedSkillNames(skillMap)
+ };
+ };
+ const getAgentSessionPolicy = async (userId) => {
+ const user = await getUserById(userId);
+ if (!user) throw new Error("\u7528\u6237\u4E0D\u5B58\u5728");
+ const capabilityState = await resolveUserCapabilities(user);
+ const policyState = await resolveUserPolicies(user);
+ await syncUserSkillsForUser(user);
+ if (capabilityState.unrestricted) {
+ return {
+ ...buildAgentExtensionPolicy(capabilityState.capabilities, { unrestricted: true }),
+ policies: {},
+ unrestricted: true
+ };
+ }
+ const effectiveCapabilities = applyPoliciesToCapabilities(
+ capabilityState.capabilities,
+ policyState.policies
+ );
+ let sandboxMcp = null;
+ if (effectiveCapabilities.static_publish || effectiveCapabilities.private_data_space) {
+ try {
+ const layout = await publishLayoutFor(user, { migrateLegacy: false });
+ sandboxMcp = {
+ serverPath: resolveSandboxMcpServerPath(),
+ sandboxRoot: layout.publishDir,
+ userId: user.id
+ };
+ } catch (err) {
+ console.warn("[getAgentSessionPolicy] sandbox MCP setup failed, falling back:", err?.message);
+ }
+ }
+ return {
+ ...buildAgentExtensionPolicy(effectiveCapabilities, {
+ unrestricted: false,
+ policies: policyState.policies,
+ sandboxMcp
+ }),
+ capabilities: effectiveCapabilities,
+ policies: policyState.policies,
+ unrestricted: false
+ };
+ };
+ const getRoleCapabilities = async (role = "user") => {
+ const roleDefaults = await listCapabilityGrants("role", role);
+ const capabilities = {};
+ for (const key of catalogKeys()) {
+ capabilities[key] = key in roleDefaults ? roleDefaults[key] : DEFAULT_USER_CAPABILITIES[key] ?? false;
+ }
+ return { role, capabilities: clampUserCapabilities(capabilities) };
+ };
+ const setRoleCapabilities = async (role, patch) => {
+ if (role !== "user") {
+ return { ok: false, message: "\u4EC5\u652F\u6301\u914D\u7F6E\u666E\u901A\u7528\u6237\u89D2\u8272\u9ED8\u8BA4\u6743\u9650" };
+ }
+ const normalized = normalizeCapabilityPatch(patch);
+ 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(
+ `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)`,
+ [role, key, effectiveAllowed ? 1 : 0, now]
+ );
+ }
+ return { ok: true, ...await getRoleCapabilities(role) };
+ };
+ const getUserCapabilities = async (userId) => {
+ const user = await getUserById(userId);
+ if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
+ const resolved = await resolveUserCapabilities(user);
+ const overrides = await listCapabilityGrants("user", userId);
+ return {
+ ok: true,
+ userId,
+ role: user.role,
+ unrestricted: resolved.unrestricted,
+ capabilities: resolved.capabilities,
+ overrides
+ };
+ };
+ const setUserCapabilities = async (userId, patch) => {
+ const user = await getUserById(userId);
+ if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
+ if (user.role === "admin") {
+ return { ok: false, message: "\u7BA1\u7406\u5458\u4E0D\u53D7\u80FD\u529B\u9650\u5236" };
+ }
+ const normalized = normalizeCapabilityPatch(patch);
+ const now = Date.now();
+ 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(
+ `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)`,
+ [userId, key, effectiveAllowed ? 1 : 0, now]
+ );
+ }
+ return getUserCapabilities(userId);
+ };
+ const clearUserCapabilityOverrides = async (userId) => {
+ await pool.query(
+ `DELETE FROM h5_capability_grants WHERE subject_type = 'user' AND subject_id = ?`,
+ [userId]
+ );
+ return getUserCapabilities(userId);
+ };
+ const getRolePolicies = async (role = "user") => {
+ const roleDefaults = await listPolicyEntries("role", role);
+ const policies = resolvePolicies(roleDefaults, {});
+ return { role, policies };
+ };
+ const setRolePolicies = async (role, patch) => {
+ if (role !== "user") {
+ return { ok: false, message: "\u4EC5\u652F\u6301\u914D\u7F6E\u666E\u901A\u7528\u6237\u89D2\u8272\u9ED8\u8BA4\u7B56\u7565" };
+ }
+ const normalized = normalizePolicyPatch(patch);
+ const now = Date.now();
+ for (const [key, value] of Object.entries(normalized)) {
+ await pool.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)`,
+ [role, key, serializePolicyValue(key, value), now]
+ );
+ }
+ return { ok: true, ...await getRolePolicies(role) };
+ };
+ const getUserPolicies = async (userId) => {
+ const user = await getUserById(userId);
+ if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
+ const policyState = await resolveUserPolicies(user);
+ const overrides = await listPolicyEntries("user", userId);
+ return {
+ ok: true,
+ userId,
+ role: user.role,
+ unrestricted: policyState.unrestricted,
+ policies: policyState.policies,
+ overrides
+ };
+ };
+ const setUserPolicies = async (userId, patch) => {
+ const user = await getUserById(userId);
+ if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
+ if (user.role === "admin") {
+ return { ok: false, message: "\u7BA1\u7406\u5458\u4E0D\u53D7\u7B56\u7565\u9650\u5236" };
+ }
+ const normalized = normalizePolicyPatch(patch);
+ const now = Date.now();
+ for (const [key, value] of Object.entries(normalized)) {
+ if (!policyKeys().includes(key)) continue;
+ await pool.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)`,
+ [userId, key, serializePolicyValue(key, value), now]
+ );
+ }
+ return getUserPolicies(userId);
+ };
+ const clearUserPolicyOverrides = async (userId) => {
+ await pool.query(`DELETE FROM h5_user_policies WHERE subject_type = 'user' AND subject_id = ?`, [
+ userId
+ ]);
+ return getUserPolicies(userId);
+ };
+ const getRoleSkills = async (role = "user") => {
+ const roleDefaults = await listSkillGrants("role", role);
+ const skills = resolveSkillMap(roleDefaults, {}, skillCatalog);
+ return { role, skills };
+ };
+ const setRoleSkills = async (role, patch) => {
+ if (role !== "user") {
+ return { ok: false, message: "\u4EC5\u652F\u6301\u914D\u7F6E\u666E\u901A\u7528\u6237\u89D2\u8272\u9ED8\u8BA4\u6280\u80FD" };
+ }
+ const normalized = normalizeSkillPatch(skillCatalog, patch);
+ const now = Date.now();
+ for (const [name, enabled] of Object.entries(normalized)) {
+ await pool.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'`);
+ for (const row of users) {
+ await syncUserSkillsForUser(row);
+ }
+ return { ok: true, ...await getRoleSkills(role) };
+ };
+ const getUserSkills = async (userId) => {
+ const user = await getUserById(userId);
+ if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
+ const skills = await resolveUserSkillMap(user);
+ const overrides = await listSkillGrants("user", userId);
+ return {
+ ok: true,
+ userId,
+ role: user.role,
+ skills,
+ grantedSkills: grantedSkillNames(skills),
+ overrides
+ };
+ };
+ const setUserSkills = async (userId, patch) => {
+ const user = await getUserById(userId);
+ if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
+ if (user.role === "admin") {
+ return { ok: false, message: "\u7BA1\u7406\u5458\u4E0D\u53D7\u6280\u80FD\u9650\u5236" };
+ }
+ const normalized = normalizeSkillPatch(skillCatalog, patch);
+ const now = Date.now();
+ for (const [name, enabled] of Object.entries(normalized)) {
+ await pool.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)`,
+ [userId, name, enabled ? 1 : 0, now]
+ );
+ }
+ await syncUserSkillsForUser(user);
+ return getUserSkills(userId);
+ };
+ const clearUserSkillOverrides = async (userId) => {
+ await pool.query(`DELETE FROM h5_user_skill_grants WHERE subject_type = 'user' AND subject_id = ?`, [
+ userId
+ ]);
+ const user = await getUserById(userId);
+ if (user) await syncUserSkillsForUser(user);
+ return getUserSkills(userId);
+ };
+ 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`, [
+ adminUsername
+ ]);
+ if (rows.length === 0) {
+ if (!adminPassword) return;
+ await createUser({
+ username: adminUsername,
+ password: adminPassword,
+ displayName: "\u7BA1\u7406\u5458",
+ balanceCents: 999999999,
+ role: "admin"
+ });
+ } else {
+ const adminId = rows[0].id;
+ const now = Date.now();
+ const adminLayout = await publishLayoutFor({
+ id: adminId,
+ username: adminUsername,
+ displayName: "\u7BA1\u7406\u5458"
+ });
+ await pool.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(
+ `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
+ [adminId, adminLayout.publishDir]
+ );
+ await pool.query(
+ `UPDATE h5_user_wallets SET balance_cents = GREATEST(balance_cents, ?), updated_at = ? WHERE user_id = ?`,
+ [999999999, now, adminId]
+ );
+ }
+ if (adminPassword) {
+ await syncAdminPassword();
+ }
+ await seedRoleCapabilityDefaults();
+ await upgradeMemoryStoreCapability();
+ await upgradeDefaultUserCapabilities();
+ await seedRolePolicyDefaults();
+ await seedRoleSkillDefaults();
+ await upgradeDefaultUserSkills();
+ await repairAllUserPublishDirs();
+ };
+ const PENDING_BIND_TTL_MS = 15 * 60 * 1e3;
+ const issueUserSession = async (userId, role, now = Date.now()) => {
+ const token = crypto4.randomBytes(32).toString("base64url");
+ await storeSession(userId, role, token, now);
+ return token;
+ };
+ const findBindingByOpenid = async (appId, openid) => {
+ const [rows] = await pool.query(
+ `SELECT wi.user_id, u.status
+ FROM h5_user_wechat_identities wi
+ JOIN h5_users u ON u.id = wi.user_id
+ WHERE wi.app_id = ? AND wi.openid = ?
+ LIMIT 1`,
+ [appId, openid]
+ );
+ return rows[0] ?? null;
+ };
+ const findBindingByUnionid = async (unionid) => {
+ if (!unionid) return null;
+ const [rows] = await pool.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
+ WHERE wi.unionid = ?
+ LIMIT 1`,
+ [unionid]
+ );
+ return rows[0] ?? null;
+ };
+ const getWechatBindingForUser = async (userId, appId) => {
+ const [rows] = await pool.query(
+ `SELECT id, nickname, avatar_url, last_login_at, created_at
+ FROM h5_user_wechat_identities
+ WHERE user_id = ? AND app_id = ?
+ LIMIT 1`,
+ [userId, appId]
+ );
+ return rows[0] ?? null;
+ };
+ const getWechatOpenidForUser = async (userId, appId) => {
+ const [rows] = await pool.query(
+ `SELECT openid
+ FROM h5_user_wechat_identities
+ WHERE user_id = ? AND app_id = ?
+ LIMIT 1`,
+ [userId, appId]
+ );
+ return rows[0]?.openid ?? null;
+ };
+ const findWechatUserByOpenid = async (appId, openid) => {
+ const [rows] = await pool.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
+ WHERE wi.app_id = ? AND wi.openid = ?
+ LIMIT 1`,
+ [appId, openid]
+ );
+ return rows[0] ? {
+ userId: rows[0].user_id,
+ status: rows[0].status,
+ nickname: rows[0].nickname,
+ username: rows[0].username,
+ slug: rows[0].slug,
+ displayName: rows[0].display_name
+ } : null;
+ };
+ const getWechatAgentRoute = async (appId, openid) => {
+ const [rows] = await pool.query(
+ `SELECT id, user_id, agent_session_id, status, created_at, updated_at
+ FROM h5_wechat_agent_routes
+ WHERE app_id = ? AND openid = ?
+ LIMIT 1`,
+ [appId, openid]
+ );
+ const row = rows[0];
+ if (!row) return null;
+ return {
+ id: row.id,
+ userId: row.user_id,
+ agentSessionId: row.agent_session_id,
+ status: row.status,
+ createdAt: Number(row.created_at ?? 0),
+ updatedAt: Number(row.updated_at ?? 0)
+ };
+ };
+ const upsertWechatAgentRoute = async ({
+ userId,
+ appId,
+ openid,
+ agentSessionId,
+ status = "active",
+ now = Date.now()
+ }) => {
+ const id = crypto4.randomUUID();
+ await pool.query(
+ `INSERT INTO h5_wechat_agent_routes
+ (id, user_id, app_id, openid, agent_session_id, status, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE
+ user_id = VALUES(user_id),
+ agent_session_id = VALUES(agent_session_id),
+ status = VALUES(status),
+ updated_at = VALUES(updated_at)`,
+ [id, userId, appId, openid, agentSessionId, status, now, now]
+ );
+ const route = await getWechatAgentRoute(appId, openid);
+ return route?.id ?? id;
+ };
+ const clearWechatAgentRoute = async (appId, openid) => {
+ await pool.query(`DELETE FROM h5_wechat_agent_routes WHERE app_id = ? AND openid = ?`, [
+ appId,
+ openid
+ ]);
+ };
+ const recordWechatMpMessage = async ({
+ appId,
+ openid,
+ msgId,
+ now = Date.now()
+ }) => {
+ if (!appId || !openid || !msgId) return { inserted: true };
+ const [result] = await pool.query(
+ `INSERT IGNORE INTO h5_wechat_mp_messages
+ (app_id, openid, msg_id, status, created_at, updated_at)
+ VALUES (?, ?, ?, 'processing', ?, ?)`,
+ [appId, openid, String(msgId), now, now]
+ );
+ if (Number(result?.affectedRows ?? 0) > 0) return { inserted: true };
+ const retryCutoff = now - 10 * 60 * 1e3;
+ const [retryResult] = await pool.query(
+ `UPDATE h5_wechat_mp_messages
+ SET status = 'processing', agent_session_id = NULL, updated_at = ?
+ WHERE app_id = ? AND openid = ? AND msg_id = ?
+ AND (status = 'failed' OR (status = 'processing' AND updated_at < ?))`,
+ [now, appId, openid, String(msgId), retryCutoff]
+ );
+ return {
+ inserted: Number(retryResult?.affectedRows ?? 0) > 0,
+ duplicate: Number(retryResult?.affectedRows ?? 0) === 0
+ };
+ };
+ const finishWechatMpMessage = async ({
+ appId,
+ openid,
+ msgId,
+ status = "done",
+ agentSessionId = null,
+ now = Date.now()
+ }) => {
+ if (!appId || !openid || !msgId) return;
+ const safeStatus = status === "failed" ? "failed" : "done";
+ await pool.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 = ?`,
+ [safeStatus, agentSessionId, now, appId, openid, String(msgId)]
+ );
+ };
+ const insertWechatMpMessageDetail = async ({
+ appId,
+ openid,
+ userId = null,
+ msgId = null,
+ msgType,
+ displayText = "",
+ agentText = "",
+ mediaId = null,
+ mediaUrl = null,
+ mediaPublicUrl = null,
+ mediaFormat = null,
+ locationLat = null,
+ locationLng = null,
+ locationLabel = null,
+ linkUrl = null,
+ linkTitle = null,
+ rawXmlHash = null,
+ rawJson = null,
+ now = Date.now()
+ }) => {
+ if (!appId || !openid || !msgType) return null;
+ const id = crypto4.randomUUID();
+ await pool.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,
+ location_lat, location_lng, location_label,
+ link_url, link_title, raw_xml_hash, raw_json, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ id,
+ appId,
+ openid,
+ userId,
+ msgId ? String(msgId) : null,
+ String(msgType),
+ displayText || null,
+ agentText || null,
+ mediaId,
+ mediaUrl,
+ mediaPublicUrl,
+ mediaFormat,
+ locationLat,
+ locationLng,
+ locationLabel,
+ linkUrl,
+ linkTitle,
+ rawXmlHash,
+ rawJson ? JSON.stringify(rawJson) : null,
+ now
+ ]
+ );
+ return id;
+ };
+ const bindWechatToUser = async ({
+ userId,
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ now = Date.now()
+ }) => {
+ const existingOpenid = await findBindingByOpenid(appId, openid);
+ if (existingOpenid && existingOpenid.user_id !== userId) {
+ return { ok: false, message: "\u8BE5\u5FAE\u4FE1\u5DF2\u7ED1\u5B9A\u5176\u4ED6\u8D26\u53F7\uFF0C\u8BF7\u5148\u7528\u8BE5\u8D26\u53F7\u767B\u5F55" };
+ }
+ const existingUserBind = await getWechatBindingForUser(userId, appId);
+ if (existingUserBind) {
+ 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(
+ `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(),
+ userId,
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ now,
+ now,
+ now
+ ]
+ );
+ return { ok: true };
+ } catch (err) {
+ if (err?.code === "ER_DUP_ENTRY") {
+ return { ok: false, message: "\u5FAE\u4FE1\u7ED1\u5B9A\u51B2\u7A81\uFF0C\u8BF7\u91CD\u8BD5" };
+ }
+ throw err;
+ }
+ };
+ const touchWechatIdentity = async ({
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ now = Date.now()
+ }) => {
+ await pool.query(
+ `UPDATE h5_user_wechat_identities
+ SET nickname = COALESCE(?, nickname),
+ avatar_url = COALESCE(?, avatar_url),
+ unionid = COALESCE(?, unionid),
+ last_login_at = ?,
+ updated_at = ?
+ WHERE app_id = ? AND openid = ?`,
+ [nickname, avatarUrl, unionid, now, now, appId, openid]
+ );
+ };
+ const pruneWechatPendingBinds = async (now = Date.now()) => {
+ await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE expires_at <= ?`, [now]);
+ };
+ const createWechatPendingBind = async ({
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ returnTo = "/",
+ utmSource = null,
+ utmMedium = null,
+ utmCampaign = null,
+ now = Date.now()
+ }) => {
+ await pruneWechatPendingBinds(now);
+ const token = crypto4.randomBytes(24).toString("base64url");
+ await pool.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)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ token,
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ returnTo,
+ utmSource,
+ utmMedium,
+ utmCampaign,
+ now + PENDING_BIND_TTL_MS,
+ now
+ ]
+ );
+ return token;
+ };
+ const getWechatPendingBind = async (token, now = Date.now()) => {
+ if (!token) return null;
+ await pruneWechatPendingBinds(now);
+ const [rows] = await pool.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
+ WHERE token = ?
+ LIMIT 1`,
+ [token]
+ );
+ const row = rows[0];
+ if (!row || Number(row.expires_at) <= now) return null;
+ return row;
+ };
+ const consumeWechatPendingBind = async (token) => {
+ await pool.query(`DELETE FROM h5_wechat_pending_binds WHERE token = ?`, [token]);
+ };
+ const loginBoundWechatUser = async ({
+ userId,
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ now = Date.now()
+ }) => {
+ await touchWechatIdentity({ appId, openid, unionid, nickname, avatarUrl, now });
+ const user = await getUserById(userId);
+ if (!user || user.status === "disabled") {
+ return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" };
+ }
+ const token = await issueUserSession(user.id, user.role, now);
+ return { ok: true, token, user: publicUser(user), isNewUser: false };
+ };
+ 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");
+ let candidate = `wx_${suffix}`.slice(0, 32);
+ if (!isValidUsername(candidate)) {
+ candidate = `wx_${crypto4.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`, [
+ candidate
+ ]);
+ if (!rows[0]) return candidate;
+ candidate = `wx_${suffix.slice(0, Math.max(1, 8 - attempt))}${crypto4.randomBytes(2).toString("hex")}`.slice(
+ 0,
+ 32
+ );
+ }
+ return `wx_${crypto4.randomBytes(6).toString("hex")}`.slice(0, 32);
+ };
+ const registerViaWechat = async ({
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ now = Date.now()
+ }) => {
+ const normalized = await generateWechatUsername(openid);
+ const randomPassword = crypto4.randomBytes(24).toString("base64url");
+ const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword);
+ const userId = crypto4.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();
+ try {
+ await conn.beginTransaction();
+ await conn.query(
+ `INSERT INTO h5_users
+ (id, username, slug, email, display_name, salt, password_hash, password_algorithm,
+ role, status, plan_type, workspace_root, signup_source, created_at, updated_at)
+ VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', ?, ?)`,
+ [
+ userId,
+ normalized,
+ normalized,
+ displayName,
+ salt,
+ passwordHash,
+ passwordAlgorithm,
+ workspaceRoot,
+ now,
+ now
+ ]
+ );
+ await conn.query(
+ `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at)
+ VALUES (?, ?, 0, ?)`,
+ [userId, defaultSignupBalanceCents, now]
+ );
+ await recordSignupBonus(conn, userId, defaultSignupBalanceCents, now);
+ await conn.query(
+ `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`,
+ [userId, workspaceRoot]
+ );
+ await conn.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(),
+ userId,
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ now,
+ now,
+ now
+ ]
+ );
+ await initializeDefaultSpace(conn, userId, {
+ quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024),
+ now
+ });
+ await conn.commit();
+ ensureWorkspace(workspaceRoot);
+ ensureUserMemoryProfile(workspaceRoot, {
+ userId,
+ displayName,
+ username: normalized,
+ slug: normalized
+ });
+ if (subscriptionService2) {
+ subscriptionService2.grantSubscription(userId, "free", null, null, "\u6CE8\u518C\u8D60\u9001\u514D\u8D39\u5957\u9910").catch(() => {
+ });
+ }
+ const user = await getUserById(userId);
+ return { ok: true, user: publicUser(user) };
+ } catch (err) {
+ await conn.rollback();
+ if (err?.code === "ER_DUP_ENTRY") {
+ return { ok: false, message: "\u5FAE\u4FE1\u8D26\u53F7\u6CE8\u518C\u51B2\u7A81\uFF0C\u8BF7\u91CD\u8BD5" };
+ }
+ throw err;
+ } finally {
+ conn.release();
+ }
+ };
+ const resolveWechatAuth = async ({
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ intent = "login",
+ bindUserId = null,
+ returnTo = "/",
+ utmSource = null,
+ utmMedium = null,
+ utmCampaign = null,
+ now = Date.now()
+ }) => {
+ let binding = await findBindingByOpenid(appId, openid);
+ if (!binding && unionid) {
+ const unionBinding = await findBindingByUnionid(unionid);
+ if (unionBinding) {
+ const linked = await bindWechatToUser({
+ userId: unionBinding.user_id,
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ now
+ });
+ if (!linked.ok) return linked;
+ binding = { user_id: unionBinding.user_id, status: unionBinding.status };
+ }
+ }
+ if (binding) {
+ if (binding.status === "disabled") {
+ return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" };
+ }
+ return loginBoundWechatUser({
+ userId: binding.user_id,
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ now
+ }).then((result) => result.ok ? { ...result, action: "login" } : result);
+ }
+ if (intent === "bind" && bindUserId) {
+ const user = await getUserById(bindUserId);
+ if (!user) return { ok: false, message: "\u7528\u6237\u4E0D\u5B58\u5728" };
+ if (user.status === "disabled") {
+ return { ok: false, message: "\u8D26\u6237\u5DF2\u7981\u7528\uFF0C\u8BF7\u8054\u7CFB\u7BA1\u7406\u5458" };
+ }
+ const bound = await bindWechatToUser({
+ userId: bindUserId,
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ now
+ });
+ if (!bound.ok) return bound;
+ const token = await issueUserSession(user.id, user.role, now);
+ return {
+ ok: true,
+ action: "login",
+ token,
+ user: publicUser(user),
+ isNewUser: false,
+ bound: true
+ };
+ }
+ if (intent === "register") {
+ const registered = await registerViaWechat({
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ now
+ });
+ if (!registered.ok) return registered;
+ const token = await issueUserSession(registered.user.id, registered.user.role, now);
+ return {
+ ok: true,
+ action: "login",
+ token,
+ user: registered.user,
+ isNewUser: true
+ };
+ }
+ const pendingToken = await createWechatPendingBind({
+ appId,
+ openid,
+ unionid,
+ nickname,
+ avatarUrl,
+ returnTo,
+ utmSource,
+ utmMedium,
+ utmCampaign,
+ now
+ });
+ return {
+ ok: true,
+ action: "binding_gate",
+ pendingToken,
+ wechatProfile: {
+ nickname: nickname ?? null,
+ avatarUrl: avatarUrl ?? null
+ },
+ returnTo,
+ utmSource,
+ utmMedium,
+ utmCampaign
+ };
+ };
+ const completeWechatRegister = async ({ pendingToken, now = Date.now() }) => {
+ const pending = await getWechatPendingBind(pendingToken, now);
+ if (!pending) {
+ return { ok: false, message: "\u7ED1\u5B9A\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u5FAE\u4FE1\u767B\u5F55" };
+ }
+ const registered = await registerViaWechat({
+ appId: pending.app_id,
+ openid: pending.openid,
+ unionid: pending.unionid,
+ nickname: pending.nickname,
+ avatarUrl: pending.avatar_url,
+ now
+ });
+ if (!registered.ok) return registered;
+ await consumeWechatPendingBind(pendingToken);
+ const token = await issueUserSession(registered.user.id, registered.user.role, now);
+ return {
+ ok: true,
+ token,
+ user: registered.user,
+ isNewUser: true,
+ returnTo: pending.return_to || "/",
+ utmSource: pending.utm_source,
+ utmMedium: pending.utm_medium,
+ utmCampaign: pending.utm_campaign
+ };
+ };
+ const completeWechatBindAccount = async ({
+ pendingToken,
+ username,
+ password,
+ ip = "unknown",
+ now = Date.now()
+ }) => {
+ const pending = await getWechatPendingBind(pendingToken, now);
+ if (!pending) {
+ return { ok: false, message: "\u7ED1\u5B9A\u4F1A\u8BDD\u5DF2\u8FC7\u671F\uFF0C\u8BF7\u91CD\u65B0\u5FAE\u4FE1\u767B\u5F55" };
+ }
+ const loginResult = await login({ username, password, ip, now });
+ if (!loginResult.ok) return loginResult;
+ const bound = await bindWechatToUser({
+ userId: loginResult.user.id,
+ appId: pending.app_id,
+ openid: pending.openid,
+ unionid: pending.unionid,
+ nickname: pending.nickname,
+ avatarUrl: pending.avatar_url,
+ now
+ });
+ if (!bound.ok) return bound;
+ await consumeWechatPendingBind(pendingToken);
+ return {
+ ok: true,
+ token: loginResult.token,
+ user: loginResult.user,
+ isNewUser: false,
+ bound: true,
+ returnTo: pending.return_to || "/"
+ };
+ };
+ const getWechatBindingStatus = async (userId, appId) => {
+ const row = await getWechatBindingForUser(userId, appId);
+ if (!row) return { bound: false };
+ return {
+ bound: true,
+ nickname: row.nickname,
+ avatarUrl: row.avatar_url,
+ lastLoginAt: Number(row.last_login_at),
+ boundAt: Number(row.created_at)
+ };
+ };
+ const loginByWechat = async (params) => {
+ const result = await resolveWechatAuth({ ...params, intent: "login" });
+ if (!result.ok) return result;
+ if (result.action === "binding_gate") {
+ return { ok: false, message: "\u9700\u8981\u5B8C\u6210\u8D26\u53F7\u7ED1\u5B9A" };
+ }
+ return result;
+ };
+ return {
+ USER_COOKIE,
+ register,
+ login,
+ loginByWechat,
+ resolveWechatAuth,
+ completeWechatRegister,
+ completeWechatBindAccount,
+ getWechatPendingBind,
+ getWechatBindingStatus,
+ getWechatOpenidForUser,
+ setRechargeNotifier(callback) {
+ rechargeNotifier = typeof callback === "function" ? callback : null;
+ },
+ findWechatUserByOpenid,
+ getWechatAgentRoute,
+ upsertWechatAgentRoute,
+ clearWechatAgentRoute,
+ recordWechatMpMessage,
+ finishWechatMpMessage,
+ insertWechatMpMessageDetail,
+ resetPassword,
+ verify,
+ revoke,
+ revokeAllSessionsForUser,
+ getMe,
+ listPathGrants,
+ resolveWorkingDir,
+ getUserPublishLayout,
+ isPathAllowed,
+ repairAllUserPublishDirs,
+ registerAgentSession,
+ getSessionNode,
+ unregisterAgentSession,
+ ownsSession,
+ listOwnedSessionIds,
+ canUseChat,
+ getUserById,
+ getUserPublic,
+ listUsers,
+ createUser,
+ updateUser,
+ purchaseSpaceQuota,
+ recharge,
+ billSessionUsage,
+ listUsageRecords,
+ listBillingLedger,
+ getAdminSummary,
+ ensureAdminUser,
+ seedRoleCapabilityDefaults,
+ resolveUserCapabilities,
+ getAgentSessionPolicy,
+ getRoleCapabilities,
+ setRoleCapabilities,
+ getUserCapabilities,
+ setUserCapabilities,
+ clearUserCapabilityOverrides,
+ resolveUserPolicies,
+ getRolePolicies,
+ setRolePolicies,
+ getUserPolicies,
+ setUserPolicies,
+ clearUserPolicyOverrides,
+ getRoleSkills,
+ setRoleSkills,
+ getUserSkills,
+ setUserSkills,
+ clearUserSkillOverrides,
+ syncUserSkillsForUser,
+ capabilityCatalog: CAPABILITY_CATALOG,
+ policyCatalog: POLICY_CATALOG,
+ skillCatalog,
+ publicUser,
+ getUserById
+ };
+}
+function buildUserSessionCookie(token, secure, { domain, maxAge }) {
+ const parts = [
+ `${USER_COOKIE}=${encodeURIComponent(token)}`,
+ "Path=/",
+ "HttpOnly",
+ "SameSite=Lax",
+ `Max-Age=${maxAge}`
+ ];
+ if (domain) parts.push(`Domain=${domain}`);
+ if (secure) parts.push("Secure");
+ return parts.join("; ");
+}
+function userSessionCookie(token, secure, domain = resolveCookieDomain()) {
+ return buildUserSessionCookie(token, secure, {
+ domain,
+ maxAge: 7 * 24 * 60 * 60
+ });
+}
+function clearUserSessionCookie(secure, domain = resolveCookieDomain()) {
+ return buildUserSessionCookie("", secure, { domain, maxAge: 0 });
+}
+function userLoginCookies(token, secure, domain = resolveCookieDomain()) {
+ const cookies = [userSessionCookie(token, secure, domain)];
+ if (domain) {
+ cookies.push(clearUserSessionCookie(secure, null));
+ }
+ return cookies;
+}
+function resolveCookieDomain() {
+ const explicit = String(process.env.H5_COOKIE_DOMAIN ?? "").trim();
+ if (explicit) return explicit;
+ try {
+ const base = String(process.env.H5_PUBLIC_BASE_URL ?? "").trim();
+ if (!base) return null;
+ const hostname = new URL(base).hostname.toLowerCase();
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) {
+ return ".localhost";
+ }
+ if (hostname === "tkmind.cn" || hostname.endsWith(".tkmind.cn")) {
+ return ".tkmind.cn";
+ }
+ } catch {
+ }
+ return null;
+}
+function resolveCookieDomainForRequest(req) {
+ const explicit = String(process.env.H5_COOKIE_DOMAIN ?? "").trim();
+ if (explicit) return explicit;
+ const hostCandidates = [
+ req?.get?.("x-forwarded-host"),
+ req?.get?.("host"),
+ req?.hostname
+ ];
+ const origin = req?.get?.("origin");
+ if (origin) {
+ try {
+ hostCandidates.push(new URL(origin).host);
+ } catch {
+ }
+ }
+ for (const raw of hostCandidates) {
+ const hostname2 = String(raw ?? "").split(":")[0].toLowerCase();
+ if (hostname2.endsWith(".localhost")) {
+ return ".localhost";
+ }
+ if (hostname2 === "localhost" || hostname2 === "127.0.0.1" || hostname2 === "::1" || net.isIP(hostname2)) {
+ return null;
+ }
+ }
+ const hostname = String(req?.get?.("host") ?? req?.hostname ?? "").split(":")[0].toLowerCase();
+ if (!hostname || hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || net.isIP(hostname)) {
+ return null;
+ }
+ return resolveCookieDomain();
+}
+
+// wiki-auth.mjs
+import crypto5 from "node:crypto";
+import fs8 from "node:fs";
+import path9 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");
+}
+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");
+ }
+}
+function readUsers() {
+ ensureDb();
+ return JSON.parse(fs8.readFileSync(USERS_FILE, "utf-8"));
+}
+function writeUsers(data) {
+ ensureDb();
+ fs8.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);
+}
+function createWikiAuth(dataDir) {
+ initPaths(dataDir);
+ ensureDb();
+ const sessions = /* @__PURE__ */ new Map();
+ const COOKIE_NAME = "wiki_session";
+ function hashPassword2(password, salt) {
+ return crypto5.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 hashed = hashPassword2(password, salt);
+ const user = {
+ id: crypto5.randomUUID(),
+ username,
+ displayName: displayName || username,
+ salt,
+ hashedPassword: hashed,
+ createdAt: Date.now()
+ };
+ db.users.push(user);
+ writeUsers(db);
+ return { ok: true, user: { id: user.id, username: user.username, displayName: user.displayName } };
+ }
+ function login(username, password) {
+ const db = readUsers();
+ const user = db.users.find((u) => u.username === username);
+ if (!user) return { ok: false, message: "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" };
+ const hashed = hashPassword2(password, user.salt);
+ if (!safeEqual3(hashed, user.hashedPassword)) {
+ return { ok: false, message: "\u7528\u6237\u540D\u6216\u5BC6\u7801\u9519\u8BEF" };
+ }
+ const token = crypto5.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 } };
+ }
+ function verify(token) {
+ if (!token) return null;
+ const session = sessions.get(token);
+ if (!session || session.expiresAt < Date.now()) {
+ sessions.delete(token);
+ return null;
+ }
+ session.expiresAt = Date.now() + 7 * 24 * 60 * 60 * 1e3;
+ return session;
+ }
+ function revoke(token) {
+ sessions.delete(token);
+ }
+ function getUser(username) {
+ const db = readUsers();
+ const user = db.users.find((u) => u.username === username);
+ if (!user) return null;
+ return { id: user.id, username: user.username, displayName: user.displayName, createdAt: user.createdAt };
+ }
+ function getAllUsers() {
+ const db = readUsers();
+ return db.users.map((u) => ({ id: u.id, username: u.username, displayName: u.displayName, createdAt: u.createdAt }));
+ }
+ 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"));
+ return {
+ id: data.id,
+ title: data.title,
+ slug: data.slug,
+ updatedAt: data.updatedAt,
+ createdAt: data.createdAt,
+ tags: data.tags || []
+ };
+ }).sort((a, b) => b.updatedAt - a.updatedAt);
+ }
+ 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"));
+ }
+ 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 page = {
+ id: existing?.id || crypto5.randomUUID(),
+ slug,
+ title: title || slug,
+ content: content || "",
+ tags: tags || [],
+ username,
+ createdAt: existing?.createdAt || Date.now(),
+ updatedAt: Date.now()
+ };
+ fs8.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);
+ return true;
+ }
+ return false;
+ }
+ function searchPages(username, query) {
+ const pages = listPages(username);
+ const q = query.toLowerCase();
+ return pages.filter(
+ (p) => p.title.toLowerCase().includes(q) || p.tags.some((t) => t.toLowerCase().includes(q))
+ );
+ }
+ return {
+ COOKIE_NAME,
+ register,
+ login,
+ verify,
+ revoke,
+ getUser,
+ getAllUsers,
+ listPages,
+ getPage,
+ savePage,
+ deletePage,
+ searchPages
+ };
+}
+
+// scripts/local-test-config.mjs
+var LOCAL_IP = process.env.LOCAL_TEST_IP ?? "127.0.0.1";
+var DNS_PORT = Number(process.env.LOCAL_TEST_DNS_PORT ?? 5533);
+var H5_HOST = process.env.H5_LOCAL_HOST ?? "h5.localhost";
+var ADMIN_HOST = process.env.ADMIN_LOCAL_HOST ?? "adm.localhost";
+var PLAZA_HOST = process.env.PLAZA_LOCAL_HOST ?? "pla.localhost";
+var OPS_HOST = process.env.OPS_LOCAL_HOST ?? "ops.localhost";
+var TEST_HOSTS = [H5_HOST, ADMIN_HOST, PLAZA_HOST, OPS_HOST];
+var USES_LOCALHOST = TEST_HOSTS.every((host) => host === "localhost" || host.endsWith(".localhost"));
+var PUBLIC_SCHEME = (process.env.LOCAL_TEST_SCHEME ?? (String(process.env.LOCAL_TEST_HTTPS ?? "1") === "0" ? "http" : "https")).replace(/:$/, "");
+var PUBLIC_PORT = Number(
+ process.env.LOCAL_TEST_PUBLIC_PORT ?? (USES_LOCALHOST ? 8443 : PUBLIC_SCHEME === "https" ? 443 : 80)
+);
+function publicUrl(host, { path: path23 = "" } = {}) {
+ const defaultPort = PUBLIC_SCHEME === "https" ? 443 : 80;
+ const portSuffix = PUBLIC_PORT === defaultPort ? "" : `:${PUBLIC_PORT}`;
+ const normalizedPath = path23.startsWith("/") ? path23 : path23 ? `/${path23}` : "";
+ return `${PUBLIC_SCHEME}://${host}${portSuffix}${normalizedPath}`;
+}
+var H5_PUBLIC_BASE = (process.env.H5_PUBLIC_BASE_URL ?? publicUrl(H5_HOST)).replace(/\/$/, "");
+var ADMIN_PUBLIC_BASE = publicUrl(ADMIN_HOST).replace(/\/$/, "");
+var PLAZA_PUBLIC_BASE = (process.env.PLAZA_PUBLIC_BASE ?? publicUrl(PLAZA_HOST)).replace(/\/$/, "");
+var OPS_PUBLIC_BASE = publicUrl(OPS_HOST, { path: "/ops/" }).replace(/\/$/, "") + "/";
+function isLocalDevHostname(hostname) {
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname.endsWith(".localhost");
+}
+
+// mindspace-workspace-thumbnails.mjs
+import fs10 from "node:fs";
+import fsPromises from "node:fs/promises";
+import path11 from "node:path";
+
+// mindspace-thumbnails.mjs
+import fs9 from "node:fs/promises";
+import path10 from "node:path";
+var FEED_WIDTH = 540;
+var FEED_HEIGHT = 720;
+var MAX_COVER_BYTES = 1.5 * 1024 * 1024;
+var REMOTE_COVER_TIMEOUT_MS = 8e3;
+function escapeXml(value) {
+ return String(value).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
+}
+function titleFromHtml(html) {
+ const match = String(html).match(/]*>([^<]+)<\/title>/i);
+ return match?.[1]?.trim() ?? "";
+}
+function h1FromHtml(html) {
+ const match = String(html).match(/]*>([\s\S]*?)<\/h1>/i);
+ if (!match) return "";
+ return match[1].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
+}
+function descriptionFromHtml(html) {
+ const meta = String(html).match(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i) ?? String(html).match(/]+content=["']([^"']+)["'][^>]+name=["']description["']/i);
+ if (meta?.[1]) return meta[1].trim();
+ const paragraph = String(html).match(/
]*>([^<]{4,120})/i);
+ return paragraph?.[1]?.trim() ?? "";
+}
+function parseCoverMeta(html) {
+ const tag = String(html).match(/]*name=["']mindspace-cover["'][^>]*>/i)?.[0];
+ if (!tag) return {};
+ const contentMatch = tag.match(/content=(["'])([\s\S]*?)\1/i) ?? tag.match(/content=["']([^"']+)["']/i);
+ const raw = contentMatch?.[2] ?? contentMatch?.[1];
+ if (!raw) return {};
+ try {
+ return JSON.parse(raw.replaceAll(""", '"'));
+ } catch {
+ return {};
+ }
+}
+function extractEmoji(text) {
+ const match = String(text).match(new RegExp("\\p{Extended_Pictographic}", "u"));
+ return match?.[0] ?? "";
+}
+function coverImageFromHtml(html) {
+ const source = String(html);
+ const og = source.match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i) ?? source.match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i);
+ if (og?.[1]) return og[1].trim();
+ const hero = source.match(/
]+class=["'][^"']*hero[^"']*["'][^>]+src=["']([^"']+)["']/i) ?? source.match(/
]+src=["']([^"']+)["'][^>]+class=["'][^"']*hero/i);
+ if (hero?.[1]) return hero[1].trim();
+ const first = source.match(/
]+src=["']([^"']+)["']/i);
+ return first?.[1]?.trim() ?? "";
+}
+function svgImageHref(value) {
+ const raw = String(value);
+ if (raw.startsWith("data:")) return raw;
+ return escapeXml(raw);
+}
+function mimeFromImageBytes(buffer) {
+ if (buffer[0] === 255 && buffer[1] === 216) return "image/jpeg";
+ if (buffer[0] === 137 && buffer[1] === 80) return "image/png";
+ if (buffer.length >= 12 && buffer.slice(0, 4).toString() === "RIFF" && buffer.slice(8, 12).toString() === "WEBP") {
+ return "image/webp";
+ }
+ if (buffer[0] === 71 && buffer[1] === 73) return "image/gif";
+ return "image/jpeg";
+}
+function bytesToDataUri(buffer, mimeType) {
+ return `data:${mimeType};base64,${buffer.toString("base64")}`;
+}
+function bufferToImageDataUri(buffer) {
+ if (!buffer?.length) return null;
+ if (buffer.length > MAX_COVER_BYTES) return null;
+ return bytesToDataUri(buffer, mimeFromImageBytes(buffer));
+}
+async function readLocalImageDataUri(filePath) {
+ const buffer = await fs9.readFile(filePath);
+ if (buffer.length === 0 || buffer.length > MAX_COVER_BYTES) return null;
+ return bytesToDataUri(buffer, mimeFromImageBytes(buffer));
+}
+async function fetchRemoteImageDataUri(url) {
+ const response = await fetch(url, {
+ signal: AbortSignal.timeout(REMOTE_COVER_TIMEOUT_MS),
+ headers: { Accept: "image/*" },
+ redirect: "follow"
+ });
+ if (!response.ok) return null;
+ const contentType = response.headers.get("content-type") ?? "";
+ if (contentType && !contentType.startsWith("image/")) return null;
+ const buffer = Buffer.from(await response.arrayBuffer());
+ if (buffer.length === 0 || buffer.length > MAX_COVER_BYTES) return null;
+ const mimeType = contentType.startsWith("image/") ? contentType.split(";")[0] : mimeFromImageBytes(buffer);
+ return bytesToDataUri(buffer, mimeType);
+}
+function resolveLocalImagePath({ storageRoot, contentStorageKey, contentBaseDir, imageUrl }) {
+ 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;
+ } else if (contentBaseDir) {
+ baseDir = path10.resolve(contentBaseDir);
+ }
+ if (!baseDir) return null;
+ const target = path10.resolve(baseDir, imageUrl.replace(/^\.\//, ""));
+ if (target !== baseDir && !target.startsWith(`${baseDir}${path10.sep}`)) return null;
+ return target;
+}
+async function resolveCoverDataUri({ storageRoot, contentStorageKey, contentBaseDir, imageUrl }) {
+ const raw = String(imageUrl ?? "").trim();
+ if (!raw) return null;
+ if (raw.startsWith("data:")) return raw.length <= MAX_COVER_BYTES * 2 ? raw : null;
+ if (/^https?:\/\//i.test(raw)) {
+ try {
+ return await fetchRemoteImageDataUri(raw);
+ } catch {
+ return null;
+ }
+ }
+ const localPath = resolveLocalImagePath({ storageRoot, contentStorageKey, contentBaseDir, imageUrl: raw });
+ if (!localPath) return null;
+ try {
+ return await readLocalImageDataUri(localPath);
+ } catch {
+ return null;
+ }
+}
+function colorsFromHtml(html) {
+ const colors = [];
+ const hexRe = /#(?:[0-9a-f]{3}){1,2}\b/gi;
+ let match;
+ while ((match = hexRe.exec(String(html))) !== null && colors.length < 6) {
+ const normalized = normalizeHex(match[0]);
+ if (!normalized) continue;
+ if (["#ffffff", "#fff", "#000000", "#000"].includes(normalized)) continue;
+ if (!colors.includes(normalized)) colors.push(normalized);
+ }
+ return colors;
+}
+function normalizeHex(value) {
+ const raw = String(value).trim().toLowerCase();
+ if (!raw.startsWith("#")) return null;
+ if (raw.length === 4) {
+ return `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`;
+ }
+ if (raw.length === 7) return raw;
+ return null;
+}
+function darken(hex, amount = 0.28) {
+ const color = normalizeHex(hex) ?? "#2f6f57";
+ const r = Math.round(parseInt(color.slice(1, 3), 16) * (1 - amount));
+ const g = Math.round(parseInt(color.slice(3, 5), 16) * (1 - amount));
+ const b = Math.round(parseInt(color.slice(5, 7), 16) * (1 - amount));
+ return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`;
+}
+function splitTitleLines(title, maxLines = 2) {
+ const cleaned = String(title || "\u672A\u547D\u540D\u9875\u9762").replace(/\s+/g, " ").trim();
+ if (!cleaned) return ["\u672A\u547D\u540D\u9875\u9762"];
+ if (cleaned.includes("|")) {
+ return cleaned.split("|").map((part) => part.trim()).filter(Boolean).slice(0, maxLines);
+ }
+ if (cleaned.length <= 16) return [cleaned];
+ const midpoint = Math.ceil(cleaned.length / 2);
+ const splitAt = cleaned.lastIndexOf(" ", midpoint) > 8 ? cleaned.lastIndexOf(" ", midpoint) : midpoint;
+ return [cleaned.slice(0, splitAt).trim(), cleaned.slice(splitAt).trim()].filter(Boolean);
+}
+function lighten(hex, amount = 0.22) {
+ const color = normalizeHex(hex) ?? "#2f6f57";
+ const r = Math.min(255, Math.round(parseInt(color.slice(1, 3), 16) + 255 * amount));
+ const g = Math.min(255, Math.round(parseInt(color.slice(3, 5), 16) + 255 * amount));
+ const b = Math.min(255, Math.round(parseInt(color.slice(5, 7), 16) + 255 * amount));
+ return `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`;
+}
+function inferTagFromContent(html, title = "") {
+ const text = `${title}
+${String(html).slice(0, 8e3)}`;
+ if (/旅行|旅游|攻略|travel/i.test(text)) return "\u65C5\u884C";
+ if (/美食|餐厅|菜谱|料理|food/i.test(text)) return "\u7F8E\u98DF";
+ if (/报告|分析|研报|数据|report/i.test(text)) return "\u62A5\u544A";
+ if (/普拉提|瑜伽|健身|运动|pilates|yoga/i.test(text)) return "\u8FD0\u52A8";
+ if (/618|促销|活动|优惠|限时|大促/i.test(text)) return "\u6D3B\u52A8";
+ return null;
+}
+function shouldUseScenicBackground(signals) {
+ return /旅行|travel|美食|food|餐|报告|report|分析/i.test(String(signals.tag ?? ""));
+}
+function resolvePhotoPalette(signals) {
+ const tag = String(signals.tag ?? "");
+ const accent = signals.accent;
+ const accent2 = signals.accent2;
+ if (/旅行|travel/i.test(tag)) {
+ return {
+ sky: "#4f8fb8",
+ glow: "#f6d7a8",
+ horizon: "#e39a4d",
+ land: "#24343a",
+ shadow: "#0d1518",
+ flare: "#ffe9c7",
+ bokeh: "#fff8ef"
+ };
+ }
+ if (/美食|food|餐/i.test(tag)) {
+ return {
+ sky: "#5a2b22",
+ glow: "#ffb27a",
+ horizon: "#d85f3b",
+ land: "#241412",
+ shadow: "#120909",
+ flare: "#ffd0a8",
+ bokeh: "#ffe8d6"
+ };
+ }
+ if (/报告|report|分析/i.test(tag)) {
+ return {
+ sky: "#3d4f68",
+ glow: "#9eb4d8",
+ horizon: "#607892",
+ land: "#1a2430",
+ shadow: "#0a1018",
+ flare: "#c8d8ef",
+ bokeh: "#eef3fb"
+ };
+ }
+ return {
+ sky: lighten(accent, 0.18),
+ glow: lighten(accent, 0.34),
+ horizon: accent,
+ land: darken(accent2, 0.1),
+ shadow: darken(accent2, 0.32),
+ flare: lighten(accent, 0.42),
+ bokeh: "#fff8f2"
+ };
+}
+function extractCoverSignals(html, meta = {}) {
+ const coverMeta = parseCoverMeta(html);
+ const rawTitle = meta.title || h1FromHtml(html) || titleFromHtml(html) || "\u672A\u547D\u540D\u9875\u9762";
+ const title = rawTitle.replace(new RegExp("\\p{Extended_Pictographic}", "gu"), "").replace(/\s+/g, " ").trim();
+ const colors = colorsFromHtml(html);
+ const accent = normalizeHex(coverMeta.accent ?? meta.accent ?? colors[0] ?? "#2f6f57");
+ const accent2 = normalizeHex(coverMeta.accent2 ?? colors[1] ?? darken(accent, 0.15));
+ return {
+ title: title || "\u672A\u547D\u540D\u9875\u9762",
+ subtitle: coverMeta.subtitle ?? meta.subtitle ?? descriptionFromHtml(html) ?? "TKMind \u4F5C\u54C1",
+ tag: coverMeta.tag ?? meta.tag ?? inferTagFromContent(html, title) ?? "\u7CBE\u9009\u9875\u9762",
+ emoji: coverMeta.emoji ?? meta.emoji ?? extractEmoji(rawTitle) ?? extractEmoji(h1FromHtml(html)),
+ accent,
+ accent2,
+ mood: coverMeta.mood ?? meta.mood ?? "photo",
+ image: coverMeta.cover ?? coverMeta.image ?? meta.cover ?? meta.image ?? coverImageFromHtml(html)
+ };
+}
+function themeBackgroundLayers(signals) {
+ const accent = escapeXml(signals.accent);
+ const accent2 = escapeXml(signals.accent2);
+ const glow = escapeXml(lighten(signals.accent, 0.28));
+ return `
+
+ `;
+}
+function buildFeedThumbnailSvg(signals, options = {}) {
+ const coverDataUri = options.coverDataUri ?? null;
+ const hasPhoto = Boolean(coverDataUri);
+ const useScenic = !hasPhoto && shouldUseScenicBackground(signals);
+ const palette = resolvePhotoPalette(signals);
+ const titleLines = splitTitleLines(signals.title);
+ const line1 = escapeXml(titleLines[0] ?? signals.title).slice(0, 24);
+ const line2 = escapeXml(titleLines[1] ?? "").slice(0, 24);
+ const subtitle = escapeXml(signals.subtitle).slice(0, 42);
+ const tag = escapeXml(signals.tag).slice(0, 12);
+ const sky = escapeXml(palette.sky);
+ const glow = escapeXml(palette.glow);
+ const horizon = escapeXml(palette.horizon);
+ const land = escapeXml(palette.land);
+ const shadow = escapeXml(palette.shadow);
+ const flare = escapeXml(palette.flare);
+ const bokeh = escapeXml(palette.bokeh);
+ const titleY2 = line2 ? 652 : 0;
+ const scenicLayers = useScenic ? `
+
+
+
+
+
+
+ ` : !hasPhoto ? themeBackgroundLayers(signals) : "";
+ const photoLayer = hasPhoto ? `` : "";
+ const overlayStops = hasPhoto ? `
+
+ ` : `
+
+ `;
+ const vignetteOpacity = hasPhoto ? "0.52" : "0.42";
+ const grainOpacity = hasPhoto ? "0.18" : "0.28";
+ return `
+`;
+}
+function assetThumbnailKey(userId, assetId) {
+ return path10.posix.join("users", userId, "assets", assetId, "thumbnail.svg");
+}
+function pageThumbnailKey(userId, pageId) {
+ return path10.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}`)) {
+ throw new Error("\u7F29\u7565\u56FE\u8DEF\u5F84\u8D8A\u754C");
+ }
+ await fs9.mkdir(path10.dirname(target), { recursive: true });
+ await fs9.writeFile(target, svg, "utf8");
+ return target;
+}
+function isModernFeedThumbnail(svg) {
+ if (!svg) return false;
+ return /width="540" height="720"/.test(svg) && /filter id="grain"/.test(svg);
+}
+async function generateHtmlThumbnail(storageRoot, storageKey, html, meta = {}) {
+ const signals = extractCoverSignals(html, meta);
+ const coverDataUri = await resolveCoverDataUri({
+ storageRoot,
+ contentStorageKey: meta.contentStorageKey,
+ contentBaseDir: meta.contentBaseDir,
+ imageUrl: signals.image
+ });
+ const svg = buildFeedThumbnailSvg(signals, { coverDataUri });
+ await writeThumbnail(storageRoot, storageKey, svg);
+ return svg;
+}
+async function ensureHtmlThumbnail(storageRoot, storageKey, html, meta = {}) {
+ const existing = await readThumbnailIfExists(storageRoot, storageKey);
+ if (existing && isModernFeedThumbnail(existing) && !meta.force) {
+ return existing;
+ }
+ return generateHtmlThumbnail(storageRoot, storageKey, html, meta);
+}
+async function ensurePageThumbnail({
+ storageRoot,
+ pageThumbnailStorageKey,
+ html,
+ meta = {},
+ workspacePublishDir = null,
+ workspaceHtmlRelativePath = null
+}) {
+ if (workspacePublishDir && workspaceHtmlRelativePath) {
+ const sidecar = await readThumbnailIfExists(
+ workspacePublishDir,
+ workspaceThumbnailRelativePath(workspaceHtmlRelativePath)
+ );
+ if (sidecar && isModernFeedThumbnail(sidecar)) {
+ await writeThumbnail(storageRoot, pageThumbnailStorageKey, sidecar);
+ return sidecar;
+ }
+ }
+ return ensureHtmlThumbnail(storageRoot, pageThumbnailStorageKey, html, meta);
+}
+async function readThumbnailIfExists(storageRoot, storageKey) {
+ try {
+ return await fs9.readFile(path10.resolve(storageRoot, storageKey), "utf8");
+ } catch {
+ return null;
+ }
+}
+function scheduleHtmlThumbnail(storageRoot, storageKey, html, meta = {}) {
+ queueMicrotask(() => {
+ void ensureHtmlThumbnail(storageRoot, storageKey, html, meta).catch(() => {
+ });
+ });
+}
+
+// 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 thumb = `${base}.thumbnail.svg`;
+ return dir === "." ? thumb : path11.posix.join(dir, thumb);
+}
+async function ensureWorkspaceHtmlThumbnail(publishDir, htmlRelativePath, html, meta = {}) {
+ const htmlPath = path11.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)
+ });
+}
+async function scanPublishTree(publishRoot) {
+ if (!fs10.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);
+ await scanUserHtmlFiles(userDir);
+ }
+}
+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);
+ 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);
+ await ensureWorkspaceHtmlThumbnail(userDir, rel).catch(() => {
+ });
+ }
+ };
+ await walk(userDir);
+}
+function startWorkspaceThumbnailWatcher(publishRoot) {
+ if (!fs10.existsSync(publishRoot)) {
+ fs10.mkdirSync(publishRoot, { recursive: true });
+ }
+ void scanPublishTree(publishRoot);
+ const pending = /* @__PURE__ */ new Map();
+ const schedule = (userDir, relativePath) => {
+ const key = path11.join(userDir, relativePath);
+ const existing = pending.get(key);
+ if (existing) clearTimeout(existing);
+ pending.set(
+ key,
+ setTimeout(() => {
+ pending.delete(key);
+ void ensureWorkspaceHtmlThumbnail(userDir, relativePath).catch(() => {
+ });
+ }, 400)
+ );
+ };
+ const attachUserWatcher = (userDir) => {
+ if (!fs10.existsSync(userDir)) return;
+ void scanUserHtmlFiles(userDir);
+ try {
+ fs10.watch(userDir, { recursive: true }, (_event, filename) => {
+ if (!filename || !String(filename).endsWith(".html")) return;
+ if (String(filename).endsWith(".thumbnail.svg")) return;
+ schedule(userDir, filename);
+ });
+ } catch {
+ }
+ };
+ for (const entry of fs10.readdirSync(publishRoot, { withFileTypes: true })) {
+ if (entry.isDirectory() && entry.name !== "wiki" && !entry.name.startsWith(".")) {
+ attachUserWatcher(path11.join(publishRoot, entry.name));
+ }
+ }
+ try {
+ fs10.watch(publishRoot, (_event, filename) => {
+ if (!filename) return;
+ const userDir = path11.join(publishRoot, filename);
+ if (fs10.existsSync(userDir) && fs10.statSync(userDir).isDirectory()) {
+ attachUserWatcher(userDir);
+ }
+ });
+ } catch {
+ }
+}
+
+// mindspace-workspace-sync.mjs
+import crypto7 from "node:crypto";
+import fs12 from "node:fs";
+import fsPromises2 from "node:fs/promises";
+import path13 from "node:path";
+
+// mindspace-assets.mjs
+import crypto6 from "node:crypto";
+import fs11 from "node:fs/promises";
+import path12 from "node:path";
+
+// mindspace-scan.mjs
+var SCRIPT_PATTERNS = [
+ /`;
+function escapeHtml(text) {
+ return String(text ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+}
+function previewDocument(title, bodyHtml, { downloadUrl = null, extraHead = "", allowScripts = false, bodyClass = "" } = {}) {
+ const csp = [
+ "default-src 'none'",
+ "style-src 'unsafe-inline'",
+ "img-src 'self' data: https:",
+ "font-src 'self' data:",
+ "frame-src 'self'",
+ "object-src 'self'",
+ "base-uri 'none'",
+ "form-action 'none'",
+ allowScripts ? "script-src 'unsafe-inline'" : "script-src 'none'"
+ ].join("; ");
+ const downloadLink = downloadUrl ? `
\u4E0B\u8F7D\u539F\u6587\u4EF6
` : "";
+ const bodyAttrs = bodyClass ? ` class="${escapeHtml(bodyClass)}"` : "";
+ return `${escapeHtml(title)}${extraHead}${escapeHtml(title)}
${downloadLink}${bodyHtml}`;
+}
+function renderImageLightboxMarkup({ downloadUrl, title, triggerClass = "image-frame" }) {
+ const safeUrl = escapeHtml(downloadUrl);
+ const safeTitle = escapeHtml(title);
+ return `

${IMAGE_LIGHTBOX_SCRIPT}`;
+}
+function wantsInlineImageViewer(req) {
+ if (req?.query?.viewer === "1") return true;
+ if (req?.query?.viewer === "0") return false;
+ const fetchDest = String(req?.get?.("sec-fetch-dest") ?? req?.headers?.["sec-fetch-dest"] ?? "").toLowerCase();
+ if (fetchDest === "image") return false;
+ if (fetchDest === "document" || fetchDest === "iframe") return true;
+ const accept = String(req?.get?.("accept") ?? req?.headers?.accept ?? "");
+ return /text\/html/i.test(accept);
+}
+function renderImageAssetViewerHtml({ asset, downloadUrl }) {
+ const title = asset.displayName || asset.filename;
+ return previewDocument(title, renderImageLightboxMarkup({ downloadUrl, title }), {
+ downloadUrl,
+ allowScripts: true,
+ bodyClass: "image-viewer"
+ });
+}
+function renderInlineMarkdown(text) {
+ let html = escapeHtml(text);
+ html = html.replace(/`([^`]+)`/g, "$1");
+ html = html.replace(/\*\*(.+?)\*\*/g, "$1");
+ html = html.replace(/\*(.+?)\*/g, "$1");
+ html = html.replace(/\[([^\]]+)]\(([^)]+)\)/g, '$1');
+ return html;
+}
+function renderMarkdownDocument(text) {
+ const lines = String(text ?? "").split("\n");
+ const blocks = [];
+ let inCode = false;
+ let code = [];
+ for (const line of lines) {
+ if (line.startsWith("```")) {
+ if (inCode) {
+ blocks.push(`${escapeHtml(code.join("\n"))}
`);
+ code = [];
+ inCode = false;
+ } else {
+ inCode = true;
+ }
+ continue;
+ }
+ if (inCode) {
+ code.push(line);
+ continue;
+ }
+ if (!line.trim()) continue;
+ const heading = line.match(/^(#{1,6})\s+(.+)$/);
+ if (heading) {
+ const level = heading[1].length;
+ blocks.push(`${renderInlineMarkdown(heading[2])}`);
+ continue;
+ }
+ blocks.push(`${renderInlineMarkdown(line)}
`);
+ }
+ if (inCode && code.length) {
+ blocks.push(`${escapeHtml(code.join("\n"))}
`);
+ }
+ return blocks.join("\n");
+}
+function parseCsvRows(text) {
+ const rows = [];
+ let row = [];
+ let cell = "";
+ let inQuotes = false;
+ for (let i = 0; i < text.length; i += 1) {
+ const ch = text[i];
+ const next = text[i + 1];
+ if (inQuotes) {
+ if (ch === '"' && next === '"') {
+ cell += '"';
+ i += 1;
+ } else if (ch === '"') {
+ inQuotes = false;
+ } else {
+ cell += ch;
+ }
+ continue;
+ }
+ if (ch === '"') {
+ inQuotes = true;
+ continue;
+ }
+ if (ch === ",") {
+ row.push(cell);
+ cell = "";
+ continue;
+ }
+ if (ch === "\n") {
+ row.push(cell);
+ rows.push(row);
+ row = [];
+ cell = "";
+ continue;
+ }
+ if (ch === "\r") continue;
+ cell += ch;
+ }
+ row.push(cell);
+ rows.push(row);
+ return rows.filter((item) => item.some((value) => String(value ?? "").trim()));
+}
+function renderCsvPreview(text) {
+ const rows = parseCsvRows(String(text ?? ""));
+ if (rows.length === 0) return "\uFF08\u7A7A\u6587\u4EF6\uFF09
";
+ const [head, ...body] = rows;
+ const header = `${head.map((cell) => `| ${escapeHtml(cell)} | `).join("")}
`;
+ const content = body.slice(0, 200).map((line) => `${line.map((cell) => `| ${escapeHtml(cell)} | `).join("")}
`).join("");
+ const tail = body.length > 200 ? `\u4EC5\u5C55\u793A\u524D 200 \u884C
` : "";
+ return ``;
+}
+function extractZipEntry2(buffer, targetName) {
+ let offset = 0;
+ while (offset + 30 <= buffer.length) {
+ if (buffer.subarray(offset, offset + 2).toString("ascii") !== "PK") break;
+ const compressionMethod = buffer.readUInt16LE(offset + 8);
+ const compressedSize = buffer.readUInt32LE(offset + 18);
+ const nameLength = buffer.readUInt16LE(offset + 26);
+ const extraLength = buffer.readUInt16LE(offset + 28);
+ const name = buffer.subarray(offset + 30, offset + 30 + nameLength).toString("utf8");
+ const dataStart = offset + 30 + nameLength + extraLength;
+ if (name === targetName) {
+ const compressed = buffer.subarray(dataStart, dataStart + compressedSize);
+ if (compressionMethod === 0) return compressed;
+ if (compressionMethod === 8) return zlib2.inflateRawSync(compressed);
+ return null;
+ }
+ offset = dataStart + compressedSize;
+ }
+ return null;
+}
+function extractDocxText2(buffer) {
+ const xmlBuffer = extractZipEntry2(buffer, "word/document.xml");
+ if (!xmlBuffer) return "";
+ const xml = xmlBuffer.toString("utf8");
+ const paragraphs = [];
+ for (const block of xml.split("")) {
+ const texts = [...block.matchAll(/]*>([\s\S]*?)<\/w:t>/g)].map(
+ (match) => match[1].replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, '"')
+ );
+ const line = texts.join("");
+ if (line.trim()) paragraphs.push(line.trim());
+ }
+ return paragraphs.join("\n\n");
+}
+function canPreviewAsset(mimeType) {
+ return PREVIEWABLE_MIME_TYPES.has(mimeType);
+}
+function renderAssetPreviewHtml({ asset, buffer, downloadUrl }) {
+ const title = asset.displayName || asset.filename;
+ const mimeType = asset.mimeType;
+ if (mimeType === "text/html") {
+ const csp = ``;
+ const html = buffer.toString("utf8");
+ if (/]*>/i.test(html)) {
+ return html.replace(/]*)>/i, `${csp}`);
+ }
+ return `${csp}${html}`;
+ }
+ if (mimeType === "application/pdf") {
+ return previewDocument(
+ title,
+ ``,
+ { downloadUrl }
+ );
+ }
+ if (mimeType.startsWith("image/")) {
+ return previewDocument(title, renderImageLightboxMarkup({ downloadUrl, title }), {
+ downloadUrl,
+ allowScripts: true
+ });
+ }
+ if (mimeType === "text/csv") {
+ return previewDocument(title, renderCsvPreview(buffer.toString("utf8")), { downloadUrl });
+ }
+ if (mimeType === "text/markdown") {
+ return previewDocument(title, renderMarkdownDocument(buffer.toString("utf8")), { downloadUrl });
+ }
+ if (mimeType === "text/plain") {
+ return previewDocument(title, `${escapeHtml(buffer.toString("utf8"))}`, { downloadUrl });
+ }
+ if (mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") {
+ const text = extractDocxText2(buffer);
+ const body = text ? `${text.split(/\n{2,}/).map((paragraph) => `${escapeHtml(paragraph)}
`).join("")}` : '\u65E0\u6CD5\u63D0\u53D6\u6B63\u6587\uFF0C\u8BF7\u4E0B\u8F7D\u539F\u6587\u4EF6\u67E5\u770B\u3002
';
+ return previewDocument(title, body, { downloadUrl });
+ }
+ throw Object.assign(new Error("\u8BE5\u8D44\u4EA7\u4E0D\u652F\u6301\u9884\u89C8"), { code: "preview_not_supported" });
+}
+
+// mindspace-assets.mjs
+var ALLOWED_EXTENSIONS = /* @__PURE__ */ new Map([
+ [".txt", "text/plain"],
+ [".md", "text/markdown"],
+ [".csv", "text/csv"],
+ [".pdf", "application/pdf"],
+ [".png", "image/png"],
+ [".jpg", "image/jpeg"],
+ [".jpeg", "image/jpeg"],
+ [".webp", "image/webp"],
+ [".doc", "application/msword"],
+ [".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"],
+ [".xls", "application/vnd.ms-excel"],
+ [".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
+ [".ppt", "application/vnd.ms-powerpoint"],
+ [".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"],
+ [".html", "text/html"],
+ [".htm", "text/html"]
+]);
+var MAX_IMAGE_UPLOAD_BYTES = 1536 * 1024;
+var PUBLIC_TEMP_IMAGE_DIR = ".tmp-images";
+var PUBLIC_IMAGE_EXTENSIONS = /* @__PURE__ */ new Map([
+ ["image/png", ".png"],
+ ["image/jpeg", ".jpg"],
+ ["image/webp", ".webp"]
+]);
+function asNumber2(value) {
+ return Number(value ?? 0);
+}
+function normalizeFilename(filename) {
+ const normalized = String(filename ?? "").normalize("NFKC").trim();
+ if (!normalized || normalized === "." || normalized === ".." || normalized.includes("/") || normalized.includes("\\") || normalized.includes("\0") || /[\u0000-\u001f\u007f]/.test(normalized)) {
+ throw Object.assign(new Error("\u6587\u4EF6\u540D\u65E0\u6548"), { code: "invalid_filename" });
+ }
+ return normalized.slice(0, 255);
+}
+function expectedMimeType(filename) {
+ return ALLOWED_EXTENSIONS.get(path12.extname(filename).toLowerCase()) ?? null;
+}
+function detectMimeType(buffer, filename) {
+ const head = buffer.subarray(0, 256).toString("utf8").trimStart().toLowerCase();
+ if (head.startsWith(" 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" });
+ }
+ 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;
+ const maxFileBytes = Number(options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES);
+ const uploadTtlMs = Number(options.uploadTtlMs ?? 30 * 60 * 1e3);
+ const idFactory = options.idFactory ?? (() => crypto6.randomUUID());
+ const workspaceSync = createWorkspaceAssetSync({
+ pool,
+ storageRoot,
+ h5Root,
+ maxFileBytes,
+ idFactory
+ });
+ const mirrorToUserWorkspace = async (userId, { categoryCode, filename, sourcePath }) => {
+ if (!h5Root) return null;
+ return mirrorAssetToZone({
+ workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
+ categoryCode,
+ filename,
+ sourcePath
+ });
+ };
+ const writePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename, 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);
+ return target;
+ };
+ const removePublicTempImageMirror = async (userId, assetId, mimeType, fallbackFilename) => {
+ if (!h5Root) return;
+ 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.rm(target, { force: true });
+ };
+ const absoluteStoragePath = (storageKey) => {
+ const resolved = path12.resolve(storageRoot, storageKey);
+ if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path12.sep}`)) {
+ throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C");
+ }
+ return resolved;
+ };
+ const resolveStoragePathCandidates = (storageKey) => {
+ const normalized = String(storageKey ?? "").replace(/\\/g, "/");
+ const withoutMd = normalized.endsWith(".md") ? normalized.slice(0, -3) : normalized;
+ const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/);
+ if (!match) return [normalized];
+ const [, userId, scope, entityId, versionTag] = match;
+ return [
+ normalized,
+ `users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`,
+ `users/${userId}/${scope}/${entityId}/versions/${versionTag}`
+ ].filter((candidate, index, list) => list.indexOf(candidate) === index);
+ };
+ const resolveReadableStoragePath = async (storageKey) => {
+ let lastError = null;
+ for (const candidate of resolveStoragePathCandidates(storageKey)) {
+ const absolutePath = absoluteStoragePath(candidate);
+ try {
+ await fs11.stat(absolutePath);
+ return absolutePath;
+ } catch (error) {
+ if (error?.code === "ENOENT") {
+ lastError = error;
+ continue;
+ }
+ throw error;
+ }
+ }
+ throw lastError ?? Object.assign(new Error("\u5B58\u50A8\u6587\u4EF6\u4E0D\u5B58\u5728"), { code: "storage_not_found" });
+ };
+ const createUpload = async (userId, input) => {
+ const validated = validateUploadRequest({ ...input, maxFileBytes });
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ const [categories] = await conn.query(
+ `SELECT c.id, c.space_id, c.category_code
+ FROM h5_space_categories c
+ WHERE c.id = ? AND c.user_id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [input.categoryId, userId]
+ );
+ 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)) {
+ throw Object.assign(new Error("\u8BE5\u5206\u7C7B\u4E0D\u5141\u8BB8\u76F4\u63A5\u4E0A\u4F20"), {
+ code: "category_not_uploadable"
+ });
+ }
+ const [spaces] = await conn.query(
+ `SELECT id, quota_bytes, used_bytes, reserved_bytes, status
+ FROM h5_user_spaces
+ WHERE id = ? AND user_id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [category.space_id, userId]
+ );
+ const space = spaces[0];
+ if (!space || space.status !== "active") {
+ 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 < validated.sizeBytes) {
+ throw Object.assign(new Error("\u5269\u4F59\u7A7A\u95F4\u4E0D\u8DB3"), {
+ code: "quota_exceeded",
+ details: { requiredBytes: validated.sizeBytes, availableBytes: Math.max(0, available) }
+ });
+ }
+ const uploadId = idFactory();
+ const now = Date.now();
+ const temporaryStorageKey = path12.posix.join("users", userId, "temp", `${uploadId}.upload`);
+ await conn.query(
+ `UPDATE h5_user_spaces
+ SET reserved_bytes = reserved_bytes + ?, updated_at = ?
+ WHERE id = ? AND user_id = ?`,
+ [validated.sizeBytes, now, space.id, userId]
+ );
+ await conn.query(
+ `INSERT INTO h5_upload_sessions
+ (id, user_id, space_id, category_id, filename, expected_size, declared_mime_type,
+ reserved_bytes, temporary_storage_key, status, expires_at, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'reserved', ?, ?)`,
+ [
+ uploadId,
+ userId,
+ space.id,
+ category.id,
+ validated.filename,
+ validated.sizeBytes,
+ input.declaredMimeType || null,
+ validated.sizeBytes,
+ temporaryStorageKey,
+ now + uploadTtlMs,
+ now
+ ]
+ );
+ await conn.commit();
+ return {
+ id: uploadId,
+ filename: validated.filename,
+ expectedSize: validated.sizeBytes,
+ uploadUrl: `/api/mindspace/v1/uploads/${uploadId}/content`,
+ expiresAt: now + uploadTtlMs,
+ reservedBytes: validated.sizeBytes
+ };
+ } catch (error) {
+ await conn.rollback();
+ throw error;
+ } finally {
+ conn.release();
+ }
+ };
+ const writeUploadContent = async (userId, uploadId, buffer) => {
+ 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(
+ `SELECT id, filename, expected_size, temporary_storage_key, status, expires_at
+ FROM h5_upload_sessions
+ WHERE id = ? AND user_id = ?
+ LIMIT 1`,
+ [uploadId, userId]
+ );
+ const upload = rows[0];
+ if (!upload) throw Object.assign(new Error("\u4E0A\u4F20\u4F1A\u8BDD\u4E0D\u5B58\u5728"), { code: "upload_not_found" });
+ if (upload.status !== "reserved") {
+ throw Object.assign(new Error("\u4E0A\u4F20\u4F1A\u8BDD\u72B6\u6001\u65E0\u6548"), { code: "invalid_upload_state" });
+ }
+ 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) {
+ throw Object.assign(new Error("\u4E0A\u4F20\u5185\u5BB9\u5927\u5C0F\u4E0E\u9884\u671F\u4E0D\u4E00\u81F4"), {
+ code: "file_size_mismatch"
+ });
+ }
+ const detectedMimeType = detectMimeType(buffer, upload.filename);
+ if (!detectedMimeType) {
+ throw Object.assign(new Error("\u65E0\u6CD5\u786E\u8BA4\u6587\u4EF6\u7C7B\u578B"), { code: "unsupported_file_type" });
+ }
+ 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 (error?.code !== "EEXIST") throw error;
+ await fs11.writeFile(target, buffer);
+ });
+ const checksum = crypto6.createHash("sha256").update(buffer).digest("hex");
+ await pool.query(
+ `UPDATE h5_upload_sessions
+ SET actual_size = ?, detected_mime_type = ?, checksum = ?, status = 'uploaded'
+ WHERE id = ? AND user_id = ? AND status = 'reserved'`,
+ [buffer.length, detectedMimeType, checksum, uploadId, userId]
+ );
+ return { sizeBytes: buffer.length, mimeType: detectedMimeType, checksum };
+ };
+ const completeUpload = async (userId, uploadId) => {
+ const conn = await pool.getConnection();
+ let temporaryPath;
+ let finalPath;
+ let publicMirror = null;
+ try {
+ await conn.beginTransaction();
+ const [rows] = await conn.query(
+ `SELECT u.*, c.category_code
+ FROM h5_upload_sessions u
+ JOIN h5_space_categories c ON c.id = u.category_id AND c.user_id = u.user_id
+ WHERE u.id = ? AND u.user_id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [uploadId, userId]
+ );
+ const upload = rows[0];
+ if (!upload) throw Object.assign(new Error("\u4E0A\u4F20\u4F1A\u8BDD\u4E0D\u5B58\u5728"), { code: "upload_not_found" });
+ if (upload.status === "completed" && upload.completed_asset_id) {
+ const [existing] = await conn.query(
+ `SELECT a.*, c.category_code
+ FROM h5_assets a
+ JOIN h5_space_categories c ON c.id = a.category_id
+ WHERE a.id = ? AND a.user_id = ?`,
+ [upload.completed_asset_id, userId]
+ );
+ await conn.commit();
+ return existing[0] ? assetResponse(existing[0]) : null;
+ }
+ if (upload.status !== "uploaded") {
+ throw Object.assign(new Error("\u6587\u4EF6\u5185\u5BB9\u5C1A\u672A\u4E0A\u4F20"), { code: "invalid_upload_state" });
+ }
+ if (asNumber2(upload.actual_size) !== asNumber2(upload.expected_size) || !upload.detected_mime_type || !upload.checksum) {
+ throw Object.assign(new Error("\u4E0A\u4F20\u5185\u5BB9\u4E0D\u5B8C\u6574"), { code: "file_size_mismatch" });
+ }
+ 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
+ });
+ 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(
+ userId,
+ assetId,
+ upload.detected_mime_type,
+ upload.filename,
+ finalPath
+ );
+ }
+ const now = Date.now();
+ const visibility = visibilityForCategory(upload.category_code);
+ await conn.query(
+ `INSERT INTO h5_assets
+ (id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
+ display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
+ status, source_type, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)`,
+ [
+ assetId,
+ userId,
+ upload.space_id,
+ upload.category_id,
+ assetTypeForMime(upload.detected_mime_type),
+ upload.detected_mime_type,
+ upload.filename,
+ upload.filename,
+ versionId,
+ upload.actual_size,
+ upload.checksum,
+ scan.riskLevel,
+ visibility,
+ assetStatus,
+ now,
+ now
+ ]
+ );
+ await conn.query(
+ `INSERT INTO h5_asset_versions
+ (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type,
+ created_by, change_note, scan_status, created_at)
+ VALUES (?, ?, 1, ?, ?, ?, ?, ?, '\u521D\u59CB\u4E0A\u4F20', ?, ?)`,
+ [
+ versionId,
+ assetId,
+ finalStorageKey,
+ upload.actual_size,
+ upload.checksum,
+ upload.detected_mime_type,
+ userId,
+ versionScanStatus,
+ now
+ ]
+ );
+ await conn.query(
+ `UPDATE h5_user_spaces
+ SET reserved_bytes = GREATEST(0, reserved_bytes - ?),
+ used_bytes = used_bytes + ?,
+ updated_at = ?
+ WHERE id = ? AND user_id = ?`,
+ [upload.reserved_bytes, upload.actual_size, now, upload.space_id, userId]
+ );
+ await conn.query(
+ `UPDATE h5_upload_sessions
+ SET status = 'completed', completed_at = ?, completed_asset_id = ?
+ WHERE id = ? AND user_id = ?`,
+ [now, assetId, uploadId, userId]
+ );
+ await conn.commit();
+ return {
+ id: assetId,
+ user_id: userId,
+ categoryId: upload.category_id,
+ categoryCode: upload.category_code,
+ assetType: assetTypeForMime(upload.detected_mime_type),
+ mimeType: upload.detected_mime_type,
+ filename: upload.filename,
+ displayName: upload.filename,
+ sizeBytes: asNumber2(upload.actual_size),
+ checksum: upload.checksum,
+ riskLevel: scan.riskLevel,
+ visibility,
+ status: assetStatus,
+ scanStatus: versionScanStatus,
+ sourceType: "upload",
+ createdAt: now,
+ updatedAt: now,
+ publicUrl: shouldPublishTempImage ? publicTempImageUrl(userId, assetId, upload.detected_mime_type, upload.filename) : null
+ };
+ } catch (error) {
+ await conn.rollback();
+ if (finalPath && finalPath !== temporaryPath) await fs11.rm(finalPath, { force: true }).catch(() => {
+ });
+ if (publicMirror) await fs11.rm(publicMirror, { force: true }).catch(() => {
+ });
+ throw error;
+ } finally {
+ conn.release();
+ }
+ };
+ const cancelUpload = async (userId, uploadId) => {
+ const conn = await pool.getConnection();
+ let storageKey;
+ try {
+ await conn.beginTransaction();
+ const [rows] = await conn.query(
+ `SELECT id, space_id, reserved_bytes, temporary_storage_key, status
+ FROM h5_upload_sessions
+ WHERE id = ? AND user_id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [uploadId, userId]
+ );
+ const upload = rows[0];
+ if (!upload) throw Object.assign(new Error("\u4E0A\u4F20\u4F1A\u8BDD\u4E0D\u5B58\u5728"), { code: "upload_not_found" });
+ if (["completed", "cancelled", "expired"].includes(upload.status)) {
+ await conn.commit();
+ return { cancelled: upload.status !== "completed" };
+ }
+ storageKey = upload.temporary_storage_key;
+ const now = Date.now();
+ await conn.query(
+ `UPDATE h5_user_spaces
+ SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ?
+ WHERE id = ? AND user_id = ?`,
+ [upload.reserved_bytes, now, upload.space_id, userId]
+ );
+ await conn.query(
+ `UPDATE h5_upload_sessions SET status = 'cancelled' WHERE id = ? AND user_id = ?`,
+ [uploadId, userId]
+ );
+ await conn.commit();
+ if (storageKey) await fs11.rm(absoluteStoragePath(storageKey), { force: true });
+ return { cancelled: true };
+ } catch (error) {
+ await conn.rollback();
+ throw error;
+ } finally {
+ conn.release();
+ }
+ };
+ const listAssets = async (userId, { categoryId, categoryCode, syncWorkspace = true } = {}) => {
+ if (syncWorkspace && categoryCode && ["oa", "private", "public"].includes(categoryCode)) {
+ await workspaceSync.syncUserWorkspace(userId, { categoryCode }).catch(() => {
+ });
+ }
+ const filters = [`a.user_id = ?`, `a.status <> 'deleted'`];
+ const params = [userId];
+ if (categoryId) {
+ filters.push(`a.category_id = ?`);
+ params.push(categoryId);
+ }
+ if (categoryCode) {
+ filters.push(`c.category_code = ?`);
+ params.push(categoryCode);
+ }
+ const [rows] = await pool.query(
+ `SELECT a.*, c.category_code,
+ 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
+ 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
+ ORDER BY a.updated_at DESC
+ LIMIT 100`,
+ params
+ );
+ return rows.map(assetResponse);
+ };
+ const deleteAsset = async (userId, assetId) => {
+ const conn = await pool.getConnection();
+ let mirrorCleanup = null;
+ let storageCleanup = null;
+ try {
+ await conn.beginTransaction();
+ const [rows] = await conn.query(
+ `SELECT a.id, a.space_id, a.size_bytes, a.status, a.original_filename, a.mime_type,
+ 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
+ LEFT JOIN h5_asset_versions v ON v.id = a.current_version_id
+ WHERE a.id = ? AND a.user_id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [assetId, userId]
+ );
+ const asset = rows[0];
+ if (!asset || asset.status === "deleted") {
+ throw Object.assign(new Error("\u8D44\u4EA7\u4E0D\u5B58\u5728"), { code: "asset_not_found" });
+ }
+ const [pageReferences] = await conn.query(
+ `SELECT p.id, p.title, p.status,
+ EXISTS(
+ SELECT 1 FROM h5_publish_records pr
+ WHERE pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online'
+ ) AS published_online
+ FROM h5_page_versions pv
+ JOIN h5_page_records p ON p.id = pv.page_id AND p.status <> 'deleted'
+ WHERE pv.content_asset_id = ? OR pv.bundle_asset_id = ?
+ GROUP BY p.id, p.title, p.status
+ LIMIT 10`,
+ [assetId, assetId]
+ );
+ if (pageReferences.length > 0) {
+ throw Object.assign(new Error("\u8D44\u4EA7\u6B63\u5728\u88AB\u9875\u9762\u4F7F\u7528\uFF0C\u4E0D\u80FD\u5220\u9664"), {
+ code: "asset_in_use",
+ details: {
+ hint: "\u8BF7\u6253\u5F00\u5173\u8054\u9875\u9762\u5E76\u5220\u9664\uFF1B\u5220\u9664\u9875\u9762\u4F1A\u81EA\u52A8\u4E0B\u7EBF\u516C\u5F00\u94FE\u63A5\u5E76\u5220\u9664\u9875\u9762\u5185\u5BB9\u8D44\u4EA7\uFF0C\u4E4B\u540E\u5373\u53EF\u5220\u9664\u539F\u59CB\u8D44\u6599\u3002",
+ references: pageReferences.map((page) => ({
+ type: "page",
+ id: page.id,
+ title: page.title,
+ status: page.status,
+ publishedOnline: Boolean(page.published_online)
+ }))
+ }
+ });
+ }
+ const now = Date.now();
+ await conn.query(
+ `UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ?
+ WHERE id = ? AND user_id = ?`,
+ [now, now, assetId, userId]
+ );
+ await conn.query(
+ `UPDATE h5_user_spaces
+ SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ?
+ WHERE id = ? AND user_id = ?`,
+ [asset.size_bytes, now, asset.space_id, userId]
+ );
+ await conn.commit();
+ if (h5Root) {
+ mirrorCleanup = {
+ id: asset.id,
+ categoryCode: asset.category_code,
+ filename: asset.original_filename,
+ mimeType: asset.mime_type
+ };
+ }
+ if (asset.storage_key) storageCleanup = asset.storage_key;
+ } catch (error) {
+ await conn.rollback();
+ throw error;
+ } finally {
+ conn.release();
+ }
+ if (mirrorCleanup) {
+ try {
+ removeZoneMirror({
+ workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
+ categoryCode: mirrorCleanup.categoryCode,
+ filename: mirrorCleanup.filename
+ });
+ if (/\.html$/i.test(mirrorCleanup.filename)) {
+ const thumbName = mirrorCleanup.filename.replace(/\.html$/i, ".thumbnail.svg");
+ removeZoneMirror({
+ workspaceRoot: resolveUserWorkspaceRoot(h5Root, { id: userId }),
+ categoryCode: mirrorCleanup.categoryCode,
+ filename: thumbName
+ });
+ }
+ if (mirrorCleanup.categoryCode === "public" && mirrorCleanup.mimeType?.startsWith?.("image/")) {
+ await removePublicTempImageMirror(
+ userId,
+ mirrorCleanup.id,
+ mirrorCleanup.mimeType,
+ mirrorCleanup.filename
+ );
+ }
+ } catch {
+ }
+ }
+ if (storageCleanup) {
+ try {
+ await fs11.rm(absoluteStoragePath(storageCleanup), { force: true });
+ } catch {
+ }
+ }
+ return { deleted: true };
+ };
+ const readAsset = async (userId, assetId) => {
+ const [rows] = await pool.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.user_id = ? AND a.status <> 'deleted'
+ LIMIT 1`,
+ [assetId, userId]
+ );
+ 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" });
+ }
+ 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();
+ let finalPath;
+ let publicMirror = null;
+ try {
+ await conn.beginTransaction();
+ const [categories] = await conn.query(
+ `SELECT c.id, c.space_id, c.category_code
+ FROM h5_space_categories c
+ WHERE c.user_id = ? AND c.category_code = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [userId, categoryCode]
+ );
+ 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)) {
+ throw Object.assign(new Error("\u8BE5\u5206\u7C7B\u4E0D\u5141\u8BB8\u4FDD\u5B58\u804A\u5929\u8D44\u4EA7"), {
+ code: "category_not_uploadable"
+ });
+ }
+ const [spaces] = await conn.query(
+ `SELECT id, quota_bytes, used_bytes, reserved_bytes, status
+ FROM h5_user_spaces
+ WHERE id = ? AND user_id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [category.space_id, userId]
+ );
+ const space = spaces[0];
+ if (!space || space.status !== "active") {
+ 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) {
+ throw Object.assign(new Error("\u5269\u4F59\u7A7A\u95F4\u4E0D\u8DB3"), {
+ code: "quota_exceeded",
+ details: { requiredBytes: buffer.length, 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 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(
+ userId,
+ assetId,
+ detectedMimeType,
+ normalizedFilename,
+ finalPath
+ );
+ }
+ const now = Date.now();
+ const visibility = visibilityForCategory(category.category_code);
+ await conn.query(
+ `INSERT INTO h5_assets
+ (id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
+ display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
+ status, source_type, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ assetId,
+ userId,
+ category.space_id,
+ category.id,
+ assetTypeForMime(detectedMimeType),
+ detectedMimeType,
+ normalizedFilename,
+ displayName || normalizedFilename,
+ versionId,
+ buffer.length,
+ checksum,
+ scan.riskLevel,
+ visibility,
+ assetStatus,
+ sourceType,
+ now,
+ now
+ ]
+ );
+ await conn.query(
+ `INSERT INTO h5_asset_versions
+ (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type,
+ created_by, change_note, scan_status, created_at)
+ VALUES (?, ?, 1, ?, ?, ?, ?, ?, '\u4ECE\u804A\u5929\u4FDD\u5B58', ?, ?)`,
+ [
+ versionId,
+ assetId,
+ finalStorageKey,
+ buffer.length,
+ checksum,
+ detectedMimeType,
+ userId,
+ versionScanStatus,
+ now
+ ]
+ );
+ 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]
+ );
+ await conn.commit();
+ if (detectedMimeType === "text/html") {
+ scheduleHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), buffer.toString("utf8"), {
+ title: displayName || normalizedFilename,
+ subtitle: category.category_code.toUpperCase(),
+ contentStorageKey: finalStorageKey
+ });
+ }
+ return assetResponse({
+ id: assetId,
+ 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,
+ risk_level: scan.riskLevel,
+ visibility,
+ status: assetStatus,
+ scan_status: versionScanStatus,
+ source_type: sourceType,
+ created_at: now,
+ updated_at: now,
+ has_thumbnail: detectedMimeType === "text/html"
+ });
+ } catch (error) {
+ await conn.rollback();
+ if (finalPath) await fs11.rm(finalPath, { force: true }).catch(() => {
+ });
+ if (publicMirror) await fs11.rm(publicMirror, { force: true }).catch(() => {
+ });
+ throw error;
+ } finally {
+ conn.release();
+ }
+ };
+ const renderAssetThumbnail = async (userId, assetId) => {
+ const { asset, path: assetPath } = await readAsset(userId, assetId);
+ 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(
+ `SELECT storage_key FROM h5_asset_versions
+ WHERE asset_id = ? AND version_no = 1
+ LIMIT 1`,
+ [assetId]
+ );
+ const html = await fs11.readFile(assetPath, "utf8");
+ return ensureHtmlThumbnail(storageRoot, assetThumbnailKey(userId, assetId), html, {
+ title: asset.displayName,
+ subtitle: asset.categoryCode?.toUpperCase?.() ?? "HTML",
+ contentStorageKey: versions[0]?.storage_key
+ });
+ };
+ const renderAssetPreview = async (userId, assetId, { downloadPath = null } = {}) => {
+ const { asset, path: assetPath } = await readAsset(userId, assetId);
+ 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 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 extracted = extractAttachmentText(buffer, asset.mimeType, asset.filename);
+ return {
+ asset: assetResponse(asset),
+ ...extracted,
+ charCount: extracted.text.length
+ };
+ };
+ const expireStaleUploads = async (now = Date.now()) => {
+ const [rows] = await pool.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 <= ?
+ LIMIT 200`,
+ [now]
+ );
+ let expired = 0;
+ for (const upload of rows) {
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ const [locked] = await conn.query(
+ `SELECT id, reserved_bytes, temporary_storage_key, status
+ FROM h5_upload_sessions
+ WHERE id = ? AND user_id = ? AND status IN ('reserved', 'uploaded')
+ LIMIT 1
+ FOR UPDATE`,
+ [upload.id, upload.user_id]
+ );
+ const row = locked[0];
+ if (!row) {
+ await conn.commit();
+ continue;
+ }
+ await conn.query(
+ `UPDATE h5_user_spaces
+ SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ?
+ WHERE id = ? AND user_id = ?`,
+ [row.reserved_bytes, now, upload.space_id, upload.user_id]
+ );
+ await conn.query(
+ `UPDATE h5_upload_sessions SET status = 'expired' WHERE id = ? AND user_id = ?`,
+ [upload.id, upload.user_id]
+ );
+ await conn.commit();
+ if (row.temporary_storage_key) {
+ await fs11.rm(absoluteStoragePath(row.temporary_storage_key), { force: true });
+ }
+ expired += 1;
+ } catch {
+ await conn.rollback();
+ } finally {
+ conn.release();
+ }
+ }
+ return expired;
+ };
+ return {
+ storageRoot,
+ createUpload,
+ writeUploadContent,
+ completeUpload,
+ cancelUpload,
+ createChatAsset,
+ listAssets,
+ deleteAsset,
+ readAsset,
+ renderAssetPreview,
+ extractAssetText,
+ renderAssetThumbnail,
+ syncWorkspaceAssets: workspaceSync.syncUserWorkspace,
+ expireStaleUploads
+ };
+}
+var assetInternals = {
+ detectMimeType,
+ normalizeFilename,
+ expectedMimeType,
+ assetTypeForMime
+};
+
+// mindspace-workspace-sync.mjs
+var SKIP_FILENAMES = /* @__PURE__ */ new Set(["index.html", ".tkmindhints", ".goosehints", ".ls_output"]);
+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;
+}
+async function listWorkspaceZoneFiles(workspaceRoot, categoryCode) {
+ if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return [];
+ const zoneDir = resolveZoneDir(workspaceRoot, categoryCode);
+ let entries;
+ try {
+ entries = await fsPromises2.readdir(zoneDir, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+ const files = [];
+ for (const entry of entries) {
+ if (!entry.isFile()) continue;
+ if (!shouldSyncWorkspaceFilename(entry.name)) continue;
+ const absolutePath = path13.join(zoneDir, entry.name);
+ const stat = await fsPromises2.stat(absolutePath);
+ files.push({
+ filename: entry.name,
+ absolutePath,
+ sizeBytes: stat.size,
+ mtimeMs: stat.mtimeMs
+ });
+ }
+ 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;
+ };
+ const loadExistingAssets = async (userId, categoryId) => {
+ const [rows] = await pool.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'`,
+ [userId, categoryId]
+ );
+ return new Map(rows.map((row) => [row.original_filename, row]));
+ };
+ const loadDeletedWorkspaceChecksums = async (userId, categoryId) => {
+ const [rows] = await pool.query(
+ `SELECT original_filename, checksum
+ FROM h5_assets
+ WHERE user_id = ? AND category_id = ? AND status = 'deleted' AND source_type = 'workspace'`,
+ [userId, categoryId]
+ );
+ const byFilename = /* @__PURE__ */ new Map();
+ for (const row of rows) {
+ const checksums = byFilename.get(row.original_filename) ?? /* @__PURE__ */ new Set();
+ checksums.add(row.checksum);
+ byFilename.set(row.original_filename, checksums);
+ }
+ return byFilename;
+ };
+ const importWorkspaceFile = async (userId, category, file, buffer) => {
+ const conn = await pool.getConnection();
+ let finalPath;
+ try {
+ await conn.beginTransaction();
+ const [spaces] = await conn.query(
+ `SELECT id, quota_bytes, used_bytes, reserved_bytes, status
+ FROM h5_user_spaces
+ WHERE id = ? AND user_id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [category.space_id, userId]
+ );
+ const space = spaces[0];
+ if (!space || space.status !== "active") {
+ throw Object.assign(new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u7528"), { code: "space_unavailable" });
+ }
+ const available = asNumber3(space.quota_bytes) - asNumber3(space.used_bytes) - asNumber3(space.reserved_bytes);
+ if (available < buffer.length) {
+ throw Object.assign(new Error("\u5269\u4F59\u7A7A\u95F4\u4E0D\u8DB3"), { code: "quota_exceeded" });
+ }
+ const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename);
+ if (!detectedMimeType) {
+ throw Object.assign(new Error("\u65E0\u6CD5\u786E\u8BA4\u6587\u4EF6\u7C7B\u578B"), { code: "unsupported_file_type" });
+ }
+ const scan = runBasicFileScan(buffer, {
+ filename: file.filename,
+ mimeType: detectedMimeType
+ });
+ const assetStatus = scan.scanStatus === "passed" ? "ready" : "quarantined";
+ const versionScanStatus = scan.scanStatus === "passed" ? "passed" : "blocked";
+ const checksum = crypto7.createHash("sha256").update(buffer).digest("hex");
+ const assetId = idFactory();
+ const versionId = idFactory();
+ const finalStorageKey = path13.posix.join(
+ "users",
+ userId,
+ "assets",
+ assetId,
+ "versions",
+ versionId
+ );
+ 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(
+ `INSERT INTO h5_assets
+ (id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
+ display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
+ status, source_type, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'workspace', ?, ?)`,
+ [
+ assetId,
+ userId,
+ category.space_id,
+ category.id,
+ assetInternals.assetTypeForMime(detectedMimeType),
+ detectedMimeType,
+ file.filename,
+ path13.basename(file.filename, path13.extname(file.filename)) || file.filename,
+ versionId,
+ buffer.length,
+ checksum,
+ scan.riskLevel,
+ visibility,
+ assetStatus,
+ now,
+ now
+ ]
+ );
+ await conn.query(
+ `INSERT INTO h5_asset_versions
+ (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type,
+ created_by, change_note, scan_status, created_at)
+ VALUES (?, ?, 1, ?, ?, ?, ?, ?, '\u4ECE\u5DE5\u4F5C\u533A\u540C\u6B65', ?, ?)`,
+ [
+ versionId,
+ assetId,
+ finalStorageKey,
+ buffer.length,
+ checksum,
+ detectedMimeType,
+ userId,
+ versionScanStatus,
+ now
+ ]
+ );
+ 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]
+ );
+ await conn.commit();
+ 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;
+ try {
+ await conn.beginTransaction();
+ const [spaces] = await conn.query(
+ `SELECT id, quota_bytes, used_bytes, reserved_bytes, status
+ FROM h5_user_spaces
+ WHERE id = ? AND user_id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [category.space_id, userId]
+ );
+ const space = spaces[0];
+ if (!space || space.status !== "active") {
+ throw Object.assign(new Error("\u7528\u6237\u7A7A\u95F4\u4E0D\u53EF\u7528"), { code: "space_unavailable" });
+ }
+ const sizeDelta = buffer.length - asNumber3(existing.size_bytes);
+ const available = asNumber3(space.quota_bytes) - asNumber3(space.used_bytes) - asNumber3(space.reserved_bytes);
+ if (sizeDelta > 0 && available < sizeDelta) {
+ throw Object.assign(new Error("\u5269\u4F59\u7A7A\u95F4\u4E0D\u8DB3"), { code: "quota_exceeded" });
+ }
+ const detectedMimeType = assetInternals.detectMimeType(buffer, file.filename);
+ if (!detectedMimeType) {
+ throw Object.assign(new Error("\u65E0\u6CD5\u786E\u8BA4\u6587\u4EF6\u7C7B\u578B"), { code: "unsupported_file_type" });
+ }
+ const scan = runBasicFileScan(buffer, {
+ filename: file.filename,
+ mimeType: detectedMimeType
+ });
+ const assetStatus = scan.scanStatus === "passed" ? "ready" : "quarantined";
+ const versionScanStatus = scan.scanStatus === "passed" ? "passed" : "blocked";
+ const checksum = crypto7.createHash("sha256").update(buffer).digest("hex");
+ const [versionRows] = await conn.query(
+ `SELECT COALESCE(MAX(version_no), 0) AS max_version
+ FROM h5_asset_versions
+ WHERE asset_id = ?`,
+ [existing.id]
+ );
+ const versionNo = asNumber3(versionRows[0]?.max_version) + 1;
+ const versionId = idFactory();
+ const finalStorageKey = path13.posix.join(
+ "users",
+ userId,
+ "assets",
+ existing.id,
+ "versions",
+ versionId
+ );
+ 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
+ SET current_version_id = ?, size_bytes = ?, checksum = ?, mime_type = ?,
+ asset_type = ?, risk_level = ?, status = ?, updated_at = ?
+ WHERE id = ? AND user_id = ?`,
+ [
+ versionId,
+ buffer.length,
+ checksum,
+ detectedMimeType,
+ assetInternals.assetTypeForMime(detectedMimeType),
+ scan.riskLevel,
+ assetStatus,
+ now,
+ existing.id,
+ userId
+ ]
+ );
+ await conn.query(
+ `INSERT INTO h5_asset_versions
+ (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type,
+ created_by, change_note, scan_status, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, '\u5DE5\u4F5C\u533A\u6587\u4EF6\u66F4\u65B0', ?, ?)`,
+ [
+ versionId,
+ existing.id,
+ versionNo,
+ finalStorageKey,
+ buffer.length,
+ checksum,
+ detectedMimeType,
+ userId,
+ versionScanStatus,
+ now
+ ]
+ );
+ if (sizeDelta !== 0) {
+ await conn.query(
+ `UPDATE h5_user_spaces SET used_bytes = GREATEST(0, used_bytes + ?), updated_at = ?
+ WHERE id = ? AND user_id = ?`,
+ [sizeDelta, now, category.space_id, userId]
+ );
+ }
+ await conn.commit();
+ 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();
+ }
+ };
+ const syncCategory = async (userId, categoryCode) => {
+ 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(
+ `SELECT c.id, c.space_id, c.category_code
+ FROM h5_space_categories c
+ WHERE c.user_id = ? AND c.category_code = ?
+ LIMIT 1`,
+ [userId, categoryCode]
+ );
+ const category = categories[0];
+ if (!category) return { imported: 0, updated: 0, skipped: 0 };
+ const existingByName = await loadExistingAssets(userId, category.id);
+ const deletedWorkspaceChecksums = await loadDeletedWorkspaceChecksums(userId, category.id);
+ let imported = 0;
+ let updated = 0;
+ let skipped = 0;
+ for (const file of files) {
+ if (file.sizeBytes <= 0 || file.sizeBytes > maxFileBytes) {
+ skipped += 1;
+ continue;
+ }
+ const buffer = await fsPromises2.readFile(file.absolutePath);
+ const checksum = crypto7.createHash("sha256").update(buffer).digest("hex");
+ if (deletedWorkspaceChecksums.get(file.filename)?.has(checksum)) {
+ skipped += 1;
+ continue;
+ }
+ const existing = existingByName.get(file.filename);
+ if (existing?.checksum === checksum) {
+ skipped += 1;
+ continue;
+ }
+ if (existing) {
+ await updateWorkspaceFile(userId, category, existing, file, buffer);
+ existing.checksum = checksum;
+ updated += 1;
+ } else {
+ await importWorkspaceFile(userId, category, file, buffer);
+ existingByName.set(file.filename, { checksum });
+ imported += 1;
+ }
+ }
+ return { imported, updated, skipped };
+ };
+ const syncUserWorkspace = async (userId, { categoryCode } = {}) => {
+ const codes = categoryCode ? [categoryCode] : UPLOAD_ZONE_CODES;
+ let imported = 0;
+ let updated = 0;
+ let skipped = 0;
+ for (const code of codes) {
+ const result = await syncCategory(userId, code);
+ imported += result.imported;
+ updated += result.updated;
+ skipped += result.skipped;
+ }
+ return { imported, updated, skipped };
+ };
+ return { syncUserWorkspace, syncCategory, listWorkspaceZoneFiles };
+}
+function startWorkspaceAssetSyncWatcher({ publishRoot, syncUserWorkspaceByDirKey }) {
+ if (!publishRoot || !syncUserWorkspaceByDirKey) return () => {
+ };
+ const pending = /* @__PURE__ */ new Map();
+ const schedule = (dirKey, categoryCode) => {
+ const key = `${dirKey}:${categoryCode ?? "all"}`;
+ const existing = pending.get(key);
+ if (existing) clearTimeout(existing);
+ pending.set(
+ key,
+ setTimeout(() => {
+ pending.delete(key);
+ void syncUserWorkspaceByDirKey(dirKey, categoryCode ? { categoryCode } : {}).catch(() => {
+ });
+ }, 600)
+ );
+ };
+ const watchZoneDir = (dirKey, zoneDir, categoryCode) => {
+ try {
+ fs12.watch(zoneDir, (_event, filename) => {
+ if (!filename || !shouldSyncWorkspaceFilename(filename)) return;
+ schedule(dirKey, categoryCode);
+ });
+ } catch {
+ }
+ };
+ const attachUser = async (dirKey) => {
+ const userDir = path13.join(publishRoot, dirKey);
+ try {
+ await fsPromises2.access(userDir);
+ } catch {
+ return;
+ }
+ for (const categoryCode of UPLOAD_ZONE_CODES) {
+ watchZoneDir(dirKey, resolveZoneDir(userDir, categoryCode), categoryCode);
+ }
+ };
+ for (const entry of fs12.readdirSync(publishRoot, { withFileTypes: true })) {
+ if (!entry.isDirectory() || entry.name === "wiki" || entry.name.startsWith(".")) continue;
+ void attachUser(entry.name);
+ }
+ try {
+ fs12.watch(publishRoot, (_event, filename) => {
+ if (!filename) return;
+ const userDir = path13.join(publishRoot, filename);
+ if (fs12.existsSync(userDir) && fs12.statSync(userDir).isDirectory()) {
+ void attachUser(filename);
+ }
+ });
+ } catch {
+ }
+ return () => {
+ for (const timer of pending.values()) clearTimeout(timer);
+ pending.clear();
+ };
+}
+
+// api-response.mjs
+import crypto8 from "node:crypto";
+function createRequestId(existing) {
+ if (typeof existing === "string" && existing.trim()) return existing.trim();
+ return `req_${crypto8.randomUUID()}`;
+}
+function attachRequestId(req, res, next) {
+ const requestId = createRequestId(req.get("x-request-id"));
+ req.requestId = requestId;
+ res.setHeader("X-Request-Id", requestId);
+ next();
+}
+function sendJson(res, req, status, body) {
+ const requestId = req?.requestId ?? createRequestId();
+ return res.status(status).json({ ...body, request_id: requestId });
+}
+function sendData(res, req, data, status = 200) {
+ return sendJson(res, req, status, { data });
+}
+function sendError(res, req, status, code, message, details) {
+ return sendJson(res, req, status, {
+ error: {
+ code,
+ message,
+ ...details ? { details } : {}
+ }
+ });
+}
+
+// mindspace-audit.mjs
+function createMindSpaceAuditWriter(pool) {
+ const write = async ({
+ userId,
+ action,
+ objectType,
+ objectId,
+ ip = null,
+ result = "success",
+ riskLevel = null,
+ now = Date.now()
+ }) => {
+ if (!pool) return;
+ await pool.query(
+ `INSERT INTO h5_mindspace_audit_logs
+ (user_id, action, object_type, object_id, ip, result, risk_level, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ [userId, action, objectType, objectId, ip, result, riskLevel, now]
+ );
+ };
+ return { write };
+}
+
+// mindspace-flags.mjs
+function mindspaceFlags(env = process.env) {
+ const enabled = env.MINDSPACE_ENABLED !== "false";
+ return {
+ 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_agent_jobs_enabled: enabled && env.MINDSPACE_AGENT_JOBS_ENABLED === "true"
+ };
+}
+function assertMindSpaceRoute(flags, route) {
+ if (!flags.mindspace_enabled) {
+ throw Object.assign(new Error("MindSpace \u529F\u80FD\u672A\u542F\u7528"), { code: "feature_disabled" });
+ }
+ if (route === "upload" && !flags.mindspace_upload_enabled) {
+ throw Object.assign(new Error("\u4E0A\u4F20\u529F\u80FD\u672A\u542F\u7528"), { code: "feature_disabled" });
+ }
+ if (route === "agent" && !flags.mindspace_agent_jobs_enabled) {
+ throw Object.assign(new Error("Agent \u4EFB\u52A1\u529F\u80FD\u672A\u542F\u7528"), { code: "feature_disabled" });
+ }
+}
+
+// mindspace-pages.mjs
+import crypto10 from "node:crypto";
+import fs13 from "node:fs/promises";
+import path14 from "node:path";
+
+// mindspace-content-scan.mjs
+import crypto9 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;
+var TEXT_RULES = [
+ {
+ type: "id_card",
+ label: "\u8EAB\u4EFD\u8BC1\u53F7",
+ riskLevel: "high",
+ blocking: true,
+ pattern: /(? `${value.slice(0, 6)}********${value.slice(-4)}`,
+ replacement: (value) => `${value.slice(0, 6)}********${value.slice(-4)}`
+ },
+ {
+ type: "bank_card",
+ label: "\u94F6\u884C\u5361\u53F7",
+ riskLevel: "high",
+ blocking: true,
+ pattern: /(? `${value.slice(0, 4)} **** **** ${value.slice(-4)}`,
+ replacement: (value) => `${value.slice(0, 4)} **** **** ${value.slice(-4)}`
+ },
+ {
+ type: "phone",
+ label: "\u624B\u673A\u53F7",
+ riskLevel: "medium",
+ blocking: false,
+ pattern: /(? `${value.slice(0, 3)}****${value.slice(-4)}`,
+ replacement: (value) => `${value.slice(0, 3)}****${value.slice(-4)}`
+ },
+ {
+ type: "email",
+ label: "\u90AE\u7BB1\u5730\u5740",
+ riskLevel: "medium",
+ blocking: false,
+ pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
+ mask: (value) => `${value.slice(0, 2)}***@***`,
+ replacement: () => "[\u90AE\u7BB1\u5730\u5740]"
+ },
+ {
+ type: "api_key",
+ label: "API Key",
+ riskLevel: "critical",
+ blocking: true,
+ pattern: /\b(?:sk|rk|pk)_(?:test_|live_|proj_)?[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{16}\b|ghp_[A-Za-z0-9]{20,}\b|AIza[0-9A-Za-z\-_]{20,}\b/g,
+ mask: (value) => `${value.slice(0, 4)}********${value.slice(-4)}`,
+ replacement: () => "[API_KEY]"
+ },
+ {
+ type: "access_token",
+ label: "\u8BBF\u95EE\u4EE4\u724C",
+ riskLevel: "critical",
+ blocking: true,
+ pattern: /\b(?:access[_-]?token|refresh[_-]?token|authorization)\b\s*[:=]?\s*(?:bearer\s+)?[A-Za-z0-9\-_=+.]{16,}/gi,
+ mask: (value) => `${value.slice(0, 10)}********`,
+ replacement: (value) => value.replace(/[A-Za-z0-9\-_=+.]{16,}$/i, "[TOKEN]")
+ },
+ {
+ type: "private_key",
+ label: "\u79C1\u94A5",
+ riskLevel: "critical",
+ blocking: true,
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
+ mask: () => "-----BEGIN PRIVATE KEY-----********",
+ replacement: () => "[PRIVATE_KEY]"
+ },
+ {
+ type: "external_link",
+ label: "\u5916\u90E8\u94FE\u63A5",
+ riskLevel: "low",
+ blocking: false,
+ pattern: /https?:\/\/[^\s<>"')]+/gi,
+ mask: (value) => value.slice(0, 80),
+ replacement: (value) => value
+ }
+];
+var HTML_RULES = [
+ {
+ type: "html_script",
+ label: "\u811A\u672C\u6807\u7B7E",
+ riskLevel: "critical",
+ blocking: true,
+ pattern: /",
+ replacement: () => ""
+ },
+ {
+ type: "html_inline_handler",
+ label: "\u5185\u8054\u4E8B\u4EF6",
+ riskLevel: "high",
+ blocking: true,
+ pattern: /\son[a-z]+\s*=\s*(['"]).*?\1/gi,
+ mask: () => "on*=\u2026",
+ replacement: () => ""
+ },
+ {
+ type: "html_javascript_url",
+ label: "javascript \u94FE\u63A5",
+ riskLevel: "high",
+ blocking: true,
+ pattern: /(href|src)\s*=\s*(['"])\s*javascript:[\s\S]*?\2/gi,
+ mask: (value) => value.slice(0, 40),
+ replacement: (_value, match) => `${match[1]}=${match[2]}#${match[2]}`
+ },
+ {
+ type: "html_forbidden_embed",
+ label: "\u5D4C\u5165\u5F0F\u5916\u90E8\u5185\u5BB9",
+ riskLevel: "high",
+ blocking: true,
+ pattern: /<(iframe|object|embed)\b[\s\S]*?>[\s\S]*?(?:<\/\1>|$)/gi,
+ mask: (value, match) => `<${match[1]}>\u2026${match[1]}>`,
+ replacement: () => ""
+ },
+ {
+ type: "html_form_action",
+ label: "\u8868\u5355\u63D0\u4EA4",
+ riskLevel: "high",
+ blocking: true,
+ pattern: /",
+ replacement: () => ""
+ },
+ {
+ type: "html_meta_refresh",
+ label: "\u9875\u9762\u8DF3\u8F6C",
+ riskLevel: "medium",
+ blocking: true,
+ pattern: /]*http-equiv\s*=\s*(['"])refresh\1[^>]*>/gi,
+ mask: () => '',
+ replacement: () => ""
+ },
+ {
+ type: "private_resource_reference",
+ label: "\u79C1\u6709\u8D44\u6E90\u5F15\u7528",
+ riskLevel: "high",
+ blocking: true,
+ pattern: PRIVATE_URL_PATTERN,
+ mask: (value) => value.slice(0, 48),
+ replacement: () => "#private-resource-redacted"
+ }
+];
+var ACKNOWLEDGEABLE_HTML_ACTIVE_TYPES = /* @__PURE__ */ new Set([
+ "html_script",
+ "html_inline_handler"
+]);
+var TRUSTED_EXTERNAL_HOSTS = /* @__PURE__ */ new Set([
+ "fonts.googleapis.com",
+ "fonts.gstatic.com",
+ "images.unsplash.com"
+]);
+function replacePrivateResourceReferences(content, replacement) {
+ const source = String(content ?? "");
+ if (!source) return source;
+ const replacer = typeof replacement === "function" ? replacement : () => String(replacement ?? "");
+ return source.replace(PRIVATE_URL_PATTERN, (...args) => replacer(...args));
+}
+function inferFormat(content, format) {
+ if (format === "html") return "html";
+ return HTML_TAG_PATTERN.test(String(content ?? "")) ? "html" : "text";
+}
+function isTrustedExternalUrl(value) {
+ try {
+ return TRUSTED_EXTERNAL_HOSTS.has(new URL(value).hostname);
+ } catch {
+ return false;
+ }
+}
+function riskMax(left, right) {
+ return RISK_ORDER.indexOf(right) > RISK_ORDER.indexOf(left) ? right : left;
+}
+function uniqueMatches(content, pattern) {
+ return [
+ ...new Map(
+ [...String(content).matchAll(pattern)].map((match) => [
+ match[0].toLowerCase(),
+ match
+ ])
+ ).values()
+ ];
+}
+function applyRule(content, rule, findings, redactions, options = {}) {
+ let matches = uniqueMatches(content, rule.pattern);
+ if (rule.type === "external_link") {
+ matches = matches.filter((match) => !isTrustedExternalUrl(match[0]));
+ }
+ 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),
+ type: rule.type,
+ label: rule.label,
+ riskLevel: rule.riskLevel,
+ occurrenceCount: matches.length,
+ sampleMasked: rule.mask(matches[0][0], matches[0]),
+ blocking
+ });
+ let next = String(content);
+ if (typeof rule.replacement === "function") {
+ next = next.replace(rule.pattern, (...args) => rule.replacement(...args));
+ redactions.push(rule.type);
+ }
+ return next;
+}
+function finalizeResult(findings) {
+ const riskLevel = findings.reduce((highest, finding) => riskMax(highest, finding.riskLevel), "none");
+ return {
+ status: findings.some((finding) => finding.blocking) ? "blocked" : findings.length ? "warned" : "passed",
+ riskLevel,
+ findings,
+ allowed: !findings.some((finding) => finding.blocking)
+ };
+}
+function scanContent(content, options = {}) {
+ const format = inferFormat(content, options.format);
+ const findings = [];
+ const working = String(content ?? "");
+ for (const rule of TEXT_RULES) {
+ applyRule(working, rule, findings, [], options);
+ }
+ if (format === "html") {
+ for (const rule of HTML_RULES) {
+ applyRule(working, rule, findings, [], options);
+ }
+ }
+ return finalizeResult(findings);
+}
+function redactContent(content, options = {}) {
+ const format = inferFormat(content, options.format);
+ const findings = [];
+ const redactions = [];
+ let next = String(content ?? "");
+ for (const rule of TEXT_RULES) {
+ next = applyRule(next, rule, findings, redactions);
+ }
+ if (format === "html") {
+ for (const rule of HTML_RULES) {
+ next = applyRule(next, rule, findings, redactions);
+ }
+ }
+ const result = finalizeResult(findings);
+ return {
+ ...result,
+ content: next,
+ format,
+ redactionCount: redactions.length,
+ changed: next !== String(content ?? "")
+ };
+}
+
+// mindspace-cover-meta.mjs
+function escapeMetaAttribute(value) {
+ return String(value ?? "").replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<");
+}
+function parseMindspaceCoverMeta(html) {
+ const tag = String(html).match(/]*name=["']mindspace-cover["'][^>]*>/i)?.[0];
+ if (!tag) return null;
+ const contentMatch = tag.match(/content=(["'])([\s\S]*?)\1/i) ?? tag.match(/content=["']([^"']+)["']/i);
+ const raw = contentMatch?.[2] ?? contentMatch?.[1];
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw.replaceAll(""", '"'));
+ } catch {
+ return null;
+ }
+}
+function upsertMindspaceCoverMeta(html, coverMeta) {
+ const payload = { ...parseMindspaceCoverMeta(html) ?? {}, ...coverMeta };
+ const metaTag = ``;
+ const source = String(html ?? "");
+ if (/]*name=["']mindspace-cover["'][^>]*>/i.test(source)) {
+ return source.replace(/]*name=["']mindspace-cover["'][^>]*>/i, metaTag);
+ }
+ if (/]*>/i.test(source)) {
+ return source.replace(/]*)>/i, `
+ ${metaTag}`);
+ }
+ if (/]*>/i.test(source)) {
+ return source.replace(/]*)>/i, `${metaTag}`);
+ }
+ return `${metaTag}${source}`;
+}
+function normalizeCoverMetaSuggestion(raw) {
+ const payload = raw && typeof raw === "object" ? raw : {};
+ const next = {};
+ if (payload.tag != null) next.tag = String(payload.tag).trim().slice(0, 12);
+ if (payload.emoji != null) next.emoji = String(payload.emoji).trim().slice(0, 4);
+ if (payload.accent != null) next.accent = String(payload.accent).trim();
+ if (payload.accent2 != null) next.accent2 = String(payload.accent2).trim();
+ if (payload.subtitle != null) next.subtitle = String(payload.subtitle).trim().slice(0, 80);
+ if (payload.cover != null) next.cover = String(payload.cover).trim().slice(0, 512);
+ if (payload.image != null && !next.cover) next.cover = String(payload.image).trim().slice(0, 512);
+ if (payload.mood != null) next.mood = String(payload.mood).trim().slice(0, 24);
+ return next;
+}
+
+// mindspace-pages.mjs
+var MAX_TITLE_LENGTH = 255;
+var MAX_SUMMARY_LENGTH = 1e3;
+var MAX_CONTENT_BYTES = 1024 * 1024;
+var TEMPLATE_IDS = /* @__PURE__ */ new Set(["editorial", "report", "profile", "knowledge-card", "static-html"]);
+var SAVE_CATEGORY_CODES = /* @__PURE__ */ new Set(["draft", "oa", "private", "public"]);
+var PRIVATE_ASSET_URL_PATTERN = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
+function asNumber4(value) {
+ return Number(value ?? 0);
+}
+function pageError(message, code, details) {
+ return Object.assign(new Error(message), { code, details });
+}
+function normalizeText(value, maxLength, fieldName) {
+ const text = String(value ?? "").normalize("NFKC").trim();
+ if (!text) throw pageError(`${fieldName}\u4E0D\u80FD\u4E3A\u7A7A`, "invalid_page_input");
+ if (text.length > maxLength) {
+ throw pageError(`${fieldName}\u8D85\u8FC7\u957F\u5EA6\u9650\u5236`, "invalid_page_input");
+ }
+ return text;
+}
+function normalizePageInput(input) {
+ const title = normalizeText(input.title, MAX_TITLE_LENGTH, "\u6807\u9898");
+ const content = normalizeText(input.content, MAX_CONTENT_BYTES, "\u9875\u9762\u5185\u5BB9");
+ const contentBytes = Buffer.byteLength(content, "utf8");
+ if (contentBytes > MAX_CONTENT_BYTES) {
+ throw pageError("\u9875\u9762\u5185\u5BB9\u8D85\u8FC7 1 MB \u9650\u5236", "page_content_too_large");
+ }
+ const summary = String(input.summary ?? "").normalize("NFKC").trim().slice(0, MAX_SUMMARY_LENGTH);
+ const contentFormat = input.contentFormat === "html" ? "html" : "markdown";
+ const templateId = contentFormat === "html" ? "static-html" : TEMPLATE_IDS.has(input.templateId) ? input.templateId : "editorial";
+ const plainSummary = contentFormat === "html" ? content.replace(/`;
+var EMBED_LAYOUT_OVERRIDES = '';
+function stripPublicationHtmlCspMeta(html) {
+ return String(html ?? "").replace(
+ /]*>/gi,
+ ""
+ );
+}
+function preparePublicationHtmlForEmbed(html) {
+ let source = stripPublicationHtmlCspMeta(html);
+ if (!source.includes('id="plaza-embed-overrides"') && /]*>/i.test(source)) {
+ source = source.replace(/]*)>/i, `${EMBED_LAYOUT_OVERRIDES}`);
+ }
+ return injectPlazaEmbedBootstrap(source);
+}
+function injectPlazaEmbedBootstrap(html) {
+ const source = String(html ?? "");
+ if (source.includes('id="plaza-embed-bootstrap"')) return source;
+ if (/<\/body>/i.test(source)) {
+ return source.replace(/<\/body>/i, `${EMBED_BOOTSTRAP}`);
+ }
+ return `${source}${EMBED_BOOTSTRAP}`;
+}
+
+// mindspace-cleanup.mjs
+import crypto20 from "node:crypto";
+import fs16 from "node:fs/promises";
+import path17 from "node:path";
+var WORKSPACE_TEMP_SKIP = /* @__PURE__ */ new Set([
+ ".tkmindhints",
+ ".goosehints",
+ ".tkmind-profile.json",
+ ".agents",
+ ".goose"
+]);
+function candidateId(kind, key) {
+ return `${kind}:${crypto20.createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
+}
+async function fileSize(targetPath) {
+ try {
+ const stat = await fs16.stat(targetPath);
+ return stat.isFile() ? stat.size : 0;
+ } catch {
+ return 0;
+ }
+}
+async function walkFiles(rootDir, onFile) {
+ let entries;
+ try {
+ entries = await fs16.readdir(rootDir, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const fullPath = path17.join(rootDir, entry.name);
+ if (entry.isDirectory()) {
+ if (WORKSPACE_TEMP_SKIP.has(entry.name)) continue;
+ await walkFiles(fullPath, onFile);
+ continue;
+ }
+ if (!entry.isFile()) continue;
+ 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}`)) {
+ throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C");
+ }
+ return resolved;
+ };
+ const listCandidates = async (userId, username) => {
+ const candidates = [];
+ const [uploads] = await pool.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')
+ ORDER BY created_at DESC
+ LIMIT 100`,
+ [userId]
+ );
+ for (const upload of uploads) {
+ const storagePath = upload.temporary_storage_key ? absoluteStoragePath(upload.temporary_storage_key) : null;
+ const exists = storagePath ? await fileSize(storagePath) : 0;
+ if (!exists && upload.status === "expired") continue;
+ candidates.push({
+ id: candidateId("upload", upload.id),
+ kind: "stale_upload",
+ label: upload.filename,
+ path: upload.temporary_storage_key ?? "",
+ sizeBytes: exists || Number(upload.reserved_bytes ?? 0),
+ createdAt: Number(upload.created_at),
+ detail: upload.status === "reserved" ? "\u672A\u5B8C\u6210\u7684\u4E0A\u4F20\u9884\u7559" : upload.status === "uploaded" ? "\u5DF2\u4E0A\u4F20\u4F46\u672A\u5B8C\u6210\u5165\u5E93" : "\u5DF2\u5931\u6548\u7684\u4E0A\u4F20\u4E34\u65F6\u6587\u4EF6",
+ refId: upload.id
+ });
+ }
+ const tmpDir = path17.join(storageRoot, "tmp", userId);
+ await walkFiles(tmpDir, async (fullPath) => {
+ const key = path17.relative(storageRoot, fullPath).split(path17.sep).join("/");
+ const active = uploads.some(
+ (upload) => upload.temporary_storage_key === key && upload.status === "reserved"
+ );
+ if (active) return;
+ const sizeBytes = await fileSize(fullPath);
+ if (!sizeBytes) return;
+ candidates.push({
+ id: candidateId("tmp", key),
+ kind: "orphan_tmp",
+ label: path17.basename(fullPath),
+ path: key,
+ sizeBytes,
+ createdAt: null,
+ detail: "\u5B64\u7ACB\u7684\u4E34\u65F6\u4E0A\u4F20\u6587\u4EF6",
+ refId: key
+ });
+ });
+ const workspaceTempDir = path17.join(h5Root, "temp", username);
+ await walkFiles(workspaceTempDir, async (fullPath) => {
+ const rel = path17.relative(workspaceTempDir, fullPath).split(path17.sep).join("/");
+ const sizeBytes = await fileSize(fullPath);
+ if (!sizeBytes) return;
+ candidates.push({
+ id: candidateId("workspace", rel),
+ kind: "workspace_temp",
+ label: rel,
+ path: `temp/${username}/${rel}`,
+ sizeBytes,
+ createdAt: null,
+ detail: "Agent \u5DE5\u4F5C\u533A\u4E34\u65F6\u6587\u4EF6",
+ refId: rel
+ });
+ });
+ const [staleVersions] = await pool.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
+ FROM h5_page_versions pv
+ JOIN h5_page_records p ON p.id = pv.page_id AND p.user_id = ? AND p.status <> 'deleted'
+ JOIN h5_assets a ON a.id = pv.content_asset_id AND a.user_id = ? AND a.status <> 'deleted'
+ LEFT JOIN h5_assets bundle
+ ON bundle.id = pv.bundle_asset_id AND bundle.user_id = ? AND bundle.status <> 'deleted'
+ WHERE pv.id <> p.current_version_id
+ AND NOT EXISTS (
+ SELECT 1 FROM h5_publish_records pr
+ WHERE pr.page_version_id = pv.id AND pr.user_id = ?
+ )
+ ORDER BY pv.created_at ASC
+ LIMIT 500`,
+ [userId, userId, userId, userId]
+ );
+ for (const row of staleVersions) {
+ const contentBytes = Number(row.size_bytes ?? 0);
+ const bundleBytes = Number(row.bundle_size_bytes ?? 0);
+ candidates.push({
+ id: candidateId("page_version", row.version_id),
+ kind: "stale_page_version",
+ label: `${row.title} \xB7 v${row.version_no}`,
+ path: `page/${row.page_id}/version/${row.version_no}`,
+ sizeBytes: contentBytes + bundleBytes,
+ createdAt: Number(row.created_at),
+ detail: "\u9875\u9762\u5386\u53F2\u7248\u672C\uFF08\u975E\u5F53\u524D\u7248\u672C\uFF09",
+ refId: row.version_id
+ });
+ }
+ return candidates;
+ };
+ const runCleanup = async (userId, username, itemIds) => {
+ const selected = new Set(itemIds ?? []);
+ const candidates = await listCandidates(userId, username);
+ const targets = candidates.filter((item) => selected.has(item.id));
+ let freedBytes = 0;
+ let removedCount = 0;
+ for (const item of targets) {
+ if (item.kind === "stale_upload") {
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ const [rows] = await conn.query(
+ `SELECT id, space_id, reserved_bytes, temporary_storage_key, status
+ FROM h5_upload_sessions
+ WHERE id = ? AND user_id = ? AND status IN ('reserved', 'uploaded', 'expired', 'failed')
+ LIMIT 1 FOR UPDATE`,
+ [item.refId, userId]
+ );
+ const upload = rows[0];
+ if (upload) {
+ await conn.query(
+ `UPDATE h5_user_spaces
+ SET reserved_bytes = GREATEST(0, reserved_bytes - ?), updated_at = ?
+ WHERE id = ? AND user_id = ?`,
+ [upload.reserved_bytes, Date.now(), upload.space_id, userId]
+ );
+ await conn.query(
+ `UPDATE h5_upload_sessions SET status = 'expired' WHERE id = ? AND user_id = ?`,
+ [upload.id, userId]
+ );
+ await conn.commit();
+ if (upload.temporary_storage_key) {
+ const target = absoluteStoragePath(upload.temporary_storage_key);
+ const sizeBytes = await fileSize(target);
+ await fs16.rm(target, { force: true });
+ freedBytes += sizeBytes;
+ }
+ removedCount += 1;
+ } else {
+ await conn.rollback();
+ }
+ } catch {
+ await conn.rollback();
+ } finally {
+ conn.release();
+ }
+ continue;
+ }
+ if (item.kind === "orphan_tmp") {
+ const target = absoluteStoragePath(item.refId);
+ const sizeBytes = await fileSize(target);
+ await fs16.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 sizeBytes = await fileSize(resolved);
+ await fs16.rm(resolved, { force: true });
+ freedBytes += sizeBytes;
+ removedCount += 1;
+ continue;
+ }
+ if (item.kind === "stale_page_version") {
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ const [rows] = await conn.query(
+ `SELECT pv.id, pv.page_id, pv.content_asset_id, pv.bundle_asset_id,
+ p.current_version_id, p.space_id
+ FROM h5_page_versions pv
+ JOIN h5_page_records p ON p.id = pv.page_id
+ WHERE pv.id = ? AND p.user_id = ?
+ LIMIT 1 FOR UPDATE`,
+ [item.refId, userId]
+ );
+ const version = rows[0];
+ if (!version || version.id === version.current_version_id) {
+ await conn.rollback();
+ continue;
+ }
+ const [pubRows] = await conn.query(
+ `SELECT id FROM h5_publish_records WHERE page_version_id = ? AND user_id = ? LIMIT 1`,
+ [version.id, userId]
+ );
+ if (pubRows.length) {
+ await conn.rollback();
+ continue;
+ }
+ const now = Date.now();
+ let freed = 0;
+ const assetsToDelete = [version.content_asset_id].filter(Boolean);
+ if (version.bundle_asset_id) {
+ const [bundleUse] = await conn.query(
+ `SELECT COUNT(*) AS cnt FROM h5_page_versions
+ WHERE bundle_asset_id = ? AND id <> ?`,
+ [version.bundle_asset_id, version.id]
+ );
+ if (Number(bundleUse[0]?.cnt ?? 0) === 0) {
+ assetsToDelete.push(version.bundle_asset_id);
+ }
+ }
+ for (const assetId of assetsToDelete) {
+ const [assetRows] = await conn.query(
+ `SELECT id, size_bytes FROM h5_assets
+ WHERE id = ? AND user_id = ? AND status <> 'deleted'
+ LIMIT 1 FOR UPDATE`,
+ [assetId, userId]
+ );
+ const asset = assetRows[0];
+ if (!asset) continue;
+ await conn.query(
+ `UPDATE h5_assets SET status = 'deleted', deleted_at = ?, updated_at = ?
+ WHERE id = ? AND user_id = ?`,
+ [now, now, assetId, userId]
+ );
+ freed += Number(asset.size_bytes ?? 0);
+ }
+ await conn.query(`DELETE FROM h5_page_versions WHERE id = ?`, [version.id]);
+ if (freed > 0) {
+ await conn.query(
+ `UPDATE h5_user_spaces
+ SET used_bytes = GREATEST(0, used_bytes - ?), updated_at = ?
+ WHERE id = ? AND user_id = ?`,
+ [freed, now, version.space_id, userId]
+ );
+ }
+ await conn.commit();
+ freedBytes += freed;
+ removedCount += 1;
+ } catch {
+ await conn.rollback();
+ } finally {
+ conn.release();
+ }
+ }
+ }
+ return { removedCount, freedBytes };
+ };
+ return { listCandidates, runCleanup };
+}
+
+// mindspace-agent-jobs.mjs
+import crypto21 from "node:crypto";
+import path18 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"]);
+var RETRYABLE_ERROR_CODES = /* @__PURE__ */ new Set([
+ "worker_crashed",
+ "worker_unavailable",
+ "model_rate_limited",
+ "network_temporary",
+ "storage_temporary",
+ "job_timed_out",
+ "invalid_agent_job_output"
+]);
+function asNumber5(value) {
+ return Number(value ?? 0);
+}
+function agentJobError(message, code, details) {
+ return Object.assign(new Error(message), { code, details });
+}
+function dedupeStrings(values) {
+ return [...new Set((Array.isArray(values) ? values : []).map((value) => String(value).trim()).filter(Boolean))];
+}
+function normalizeInstruction(value) {
+ const instruction = String(value ?? "").normalize("NFKC").trim();
+ if (!instruction) throw agentJobError("\u4EFB\u52A1\u8BF4\u660E\u4E0D\u80FD\u4E3A\u7A7A", "invalid_agent_job_input");
+ if (instruction.length > 4e3) {
+ throw agentJobError("\u4EFB\u52A1\u8BF4\u660E\u8D85\u8FC7\u957F\u5EA6\u9650\u5236", "invalid_agent_job_input");
+ }
+ return instruction;
+}
+function normalizeJobInput(input) {
+ const jobType = String(input.jobType ?? "").trim();
+ if (!JOB_TYPES.has(jobType)) {
+ throw agentJobError("\u4E0D\u652F\u6301\u7684\u4EFB\u52A1\u7C7B\u578B", "invalid_agent_job_input");
+ }
+ const outputType = String(input.outputType ?? "").trim();
+ if (!OUTPUT_TYPES.has(outputType)) {
+ throw agentJobError("\u4E0D\u652F\u6301\u7684\u8F93\u51FA\u7C7B\u578B", "invalid_agent_job_input");
+ }
+ const allowedAssetIds = dedupeStrings(input.allowedAssetIds);
+ if (allowedAssetIds.length === 0) {
+ throw agentJobError("\u81F3\u5C11\u9700\u8981\u6388\u6743\u4E00\u4E2A\u8F93\u5165\u8D44\u4EA7", "invalid_agent_job_input");
+ }
+ return {
+ jobType,
+ instruction: normalizeInstruction(input.instruction),
+ outputType,
+ allowedAssetIds,
+ idempotencyKey: String(input.idempotencyKey ?? "").trim() || null,
+ outputCategoryId: input.outputCategoryId ? String(input.outputCategoryId) : null,
+ locale: String(input.locale ?? "zh-CN").trim() || "zh-CN",
+ timezone: String(input.timezone ?? "Asia/Shanghai").trim() || "Asia/Shanghai",
+ capabilities: {
+ network: Boolean(input.capabilities?.network),
+ shell: Boolean(input.capabilities?.shell),
+ createPage: input.capabilities?.createPage !== false
+ }
+ };
+}
+function hashJobToken(token) {
+ return crypto21.createHash("sha256").update(String(token)).digest("hex");
+}
+function jsonValue(value, fallback) {
+ if (value == null) return fallback;
+ if (typeof value === "object") return value;
+ try {
+ return JSON.parse(String(value));
+ } catch {
+ return fallback;
+ }
+}
+function jobResponse(row, assets = []) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ sessionId: row.session_id ?? null,
+ jobType: row.job_type,
+ instruction: row.instruction,
+ status: row.status,
+ outputType: row.output_type,
+ outputCategoryId: row.output_category_id,
+ outputCategoryCode: row.output_category_code ?? null,
+ progress: jsonValue(row.progress_json, { stage: row.status }),
+ permissionScope: jsonValue(row.permission_scope, {}),
+ userContext: jsonValue(row.user_context_json, {
+ locale: "zh-CN",
+ timezone: "Asia/Shanghai"
+ }),
+ resultPageId: row.result_page_id ?? null,
+ resultAssetId: row.result_asset_id ?? null,
+ errorCode: row.error_code ?? null,
+ errorMessage: row.error_message ?? null,
+ retryable: RETRYABLE_ERROR_CODES.has(row.error_code),
+ queuedAt: asNumber5(row.queued_at),
+ startedAt: row.started_at == null ? null : asNumber5(row.started_at),
+ completedAt: row.completed_at == null ? null : asNumber5(row.completed_at),
+ expiresAt: row.expires_at == null ? null : asNumber5(row.expires_at),
+ assets
+ };
+}
+function outputContentFormat(outputType, input) {
+ if (input.contentFormat === "html" || outputType === "html_page") return "html";
+ return "markdown";
+}
+function progressPayload(input, fallbackStage) {
+ const stage = String(input?.stage ?? fallbackStage ?? "").trim() || fallbackStage || "running";
+ const payload = { stage };
+ const message = String(input?.message ?? "").trim();
+ if (message) payload.message = message.slice(0, 500);
+ return payload;
+}
+function hasPathAccess(asset) {
+ return asset.status !== "deleted" && asset.status !== "quarantined" && asset.scan_status !== "blocked";
+}
+function verifyTokenHash(expectedHash, token) {
+ if (!expectedHash || !token) return false;
+ 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);
+}
+function createAgentJobService(pool, options = {}) {
+ const idFactory = options.idFactory ?? (() => crypto21.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}`)) {
+ throw new Error("\u5B58\u50A8\u8DEF\u5F84\u8D8A\u754C");
+ }
+ return resolved;
+ };
+ const resolveStoragePathCandidates = (storageKey) => {
+ const normalized = String(storageKey ?? "").replace(/\\/g, "/");
+ const withoutMd = normalized.endsWith(".md") ? normalized.slice(0, -3) : normalized;
+ const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/);
+ if (!match) return [normalized];
+ const [, userId, scope, entityId, versionTag] = match;
+ return [
+ normalized,
+ `users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`,
+ `users/${userId}/${scope}/${entityId}/versions/${versionTag}`
+ ].filter((candidate, index, list) => list.indexOf(candidate) === index);
+ };
+ const resolveReadableStoragePath = async (storageKey) => {
+ let lastError = null;
+ for (const candidate of resolveStoragePathCandidates(storageKey)) {
+ const absolutePath = absoluteStoragePath(candidate);
+ try {
+ await fs.stat(absolutePath);
+ return absolutePath;
+ } catch (error) {
+ if (error?.code === "ENOENT") {
+ lastError = error;
+ continue;
+ }
+ throw error;
+ }
+ }
+ 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(
+ `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
+ JOIN h5_assets a ON a.id = ja.asset_id
+ JOIN h5_asset_versions v ON v.id = ja.asset_version_id
+ WHERE ja.job_id = ?
+ ORDER BY ja.created_at ASC`,
+ [jobId]
+ );
+ return rows.map((row) => ({
+ assetId: row.asset_id,
+ assetVersionId: row.asset_version_id,
+ permission: row.permission,
+ displayName: row.display_name,
+ mimeType: row.mime_type,
+ status: row.status,
+ scanStatus: row.scan_status
+ }));
+ };
+ const fetchJobRow = async (jobId, userId = null) => {
+ const params = [jobId];
+ let sql = `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.id = ?`;
+ if (userId) {
+ sql += " AND j.user_id = ?";
+ params.push(userId);
+ }
+ sql += " LIMIT 1";
+ const [rows] = await pool.query(sql, params);
+ return rows[0] ?? null;
+ };
+ const createJob = async (userId, input) => {
+ const normalized = normalizeJobInput(input);
+ if (!pageService) {
+ throw agentJobError("\u9875\u9762\u670D\u52A1\u672A\u521D\u59CB\u5316", "internal_error");
+ }
+ if (normalized.idempotencyKey) {
+ const [existingRows] = await pool.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.user_id = ? AND j.idempotency_key = ?
+ LIMIT 1`,
+ [userId, normalized.idempotencyKey]
+ );
+ if (existingRows[0]) {
+ const assets = await fetchJobAssets(existingRows[0].id);
+ return jobResponse(existingRows[0], assets);
+ }
+ }
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ const categoryParams = normalized.outputCategoryId ? [normalized.outputCategoryId, userId] : [userId];
+ const [categoryRows] = await conn.query(
+ normalized.outputCategoryId ? `SELECT id, category_code
+ FROM h5_space_categories
+ WHERE id = ? AND user_id = ?
+ LIMIT 1
+ FOR UPDATE` : `SELECT id, category_code
+ FROM h5_space_categories
+ WHERE user_id = ? AND category_code = 'draft'
+ LIMIT 1
+ FOR UPDATE`,
+ categoryParams
+ );
+ 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)) {
+ throw agentJobError("\u8BE5\u5206\u7C7B\u4E0D\u5141\u8BB8\u5199\u5165 Agent \u8F93\u51FA", "invalid_agent_job_input");
+ }
+ const placeholders = normalized.allowedAssetIds.map(() => "?").join(", ");
+ const [assetRows] = await conn.query(
+ `SELECT a.id, a.current_version_id, a.display_name, a.mime_type, a.status, v.scan_status
+ FROM h5_assets a
+ JOIN h5_asset_versions v ON v.id = a.current_version_id
+ WHERE a.user_id = ? AND a.id IN (${placeholders}) AND a.status <> 'deleted'
+ FOR UPDATE`,
+ [userId, ...normalized.allowedAssetIds]
+ );
+ const assetMap = new Map(assetRows.map((row) => [row.id, row]));
+ const missingIds = normalized.allowedAssetIds.filter((assetId) => !assetMap.has(assetId));
+ if (missingIds.length > 0) {
+ throw agentJobError("\u5B58\u5728\u672A\u6388\u6743\u6216\u4E0D\u5B58\u5728\u7684\u8D44\u4EA7", "asset_not_found", {
+ assetIds: missingIds
+ });
+ }
+ const blockedAssets = assetRows.filter((row) => !hasPathAccess(row));
+ if (blockedAssets.length > 0) {
+ throw agentJobError("\u6709\u8F93\u5165\u8D44\u4EA7\u5C1A\u672A\u901A\u8FC7\u5B89\u5168\u68C0\u67E5", "security_scan_required", {
+ assetIds: blockedAssets.map((row) => row.id)
+ });
+ }
+ const jobId = idFactory();
+ const now = nowFactory();
+ await conn.query(
+ `INSERT INTO h5_agent_jobs
+ (id, user_id, job_type, instruction, permission_scope, user_context_json,
+ output_category_id, output_type, status, idempotency_key, progress_json,
+ queued_at, expires_at, updated_at, max_output_bytes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?)`,
+ [
+ jobId,
+ userId,
+ normalized.jobType,
+ normalized.instruction,
+ JSON.stringify({ capabilities: normalized.capabilities }),
+ JSON.stringify({ locale: normalized.locale, timezone: normalized.timezone }),
+ category.id,
+ normalized.outputType,
+ normalized.idempotencyKey,
+ JSON.stringify({ stage: "queued" }),
+ now,
+ now + tokenTtlMs,
+ now,
+ maxOutputBytes
+ ]
+ );
+ for (const assetId of normalized.allowedAssetIds) {
+ const asset = assetMap.get(assetId);
+ await conn.query(
+ `INSERT INTO h5_agent_job_assets
+ (id, job_id, asset_id, asset_version_id, permission, created_at)
+ VALUES (?, ?, ?, ?, 'read', ?)`,
+ [idFactory(), jobId, asset.id, asset.current_version_id, now]
+ );
+ }
+ await conn.commit();
+ const created = await fetchJobRow(jobId, userId);
+ const assets = await fetchJobAssets(jobId);
+ return jobResponse(created, assets);
+ } catch (error) {
+ await conn.rollback();
+ throw error;
+ } finally {
+ conn.release();
+ }
+ };
+ const getJob = async (userId, jobId) => {
+ const row = await fetchJobRow(jobId, userId);
+ if (!row) throw agentJobError("\u4EFB\u52A1\u4E0D\u5B58\u5728", "agent_job_not_found");
+ const assets = await fetchJobAssets(jobId);
+ return jobResponse(row, assets);
+ };
+ 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(
+ `SELECT COUNT(*) AS total FROM h5_agent_jobs WHERE user_id = ?`,
+ [userId]
+ );
+ const total = Number(countRows[0]?.total ?? 0);
+ const [rows] = await pool.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.user_id = ?
+ ORDER BY COALESCE(j.completed_at, j.started_at, j.queued_at) DESC
+ LIMIT ${safeLimit} OFFSET ${safeOffset}`,
+ [userId]
+ );
+ const jobs = await Promise.all(
+ rows.map(async (row) => jobResponse(row, await fetchJobAssets(row.id)))
+ );
+ return {
+ items: jobs,
+ total,
+ offset: safeOffset,
+ limit: safeLimit,
+ hasMore: safeOffset + jobs.length < total
+ };
+ };
+ const cancelJob = async (userId, jobId) => {
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ const [rows] = await conn.query(
+ `SELECT * FROM h5_agent_jobs
+ WHERE id = ? AND user_id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [jobId, userId]
+ );
+ const job = rows[0];
+ if (!job) throw agentJobError("\u4EFB\u52A1\u4E0D\u5B58\u5728", "agent_job_not_found");
+ if (!ACTIVE_JOB_STATUSES.has(job.status)) {
+ throw agentJobError("\u5F53\u524D\u72B6\u6001\u4E0D\u5141\u8BB8\u53D6\u6D88\u4EFB\u52A1", "invalid_state_transition");
+ }
+ const now = nowFactory();
+ await conn.query(
+ `UPDATE h5_agent_jobs
+ SET status = 'cancelled', completed_at = ?, expires_at = ?, updated_at = ?,
+ job_token_hash = NULL, heartbeat_at = NULL, progress_json = ?
+ WHERE id = ?`,
+ [now, now, now, JSON.stringify({ stage: "cancelled" }), jobId]
+ );
+ await conn.commit();
+ return getJob(userId, jobId);
+ } catch (error) {
+ await conn.rollback();
+ throw error;
+ } finally {
+ conn.release();
+ }
+ };
+ const retryJob = async (userId, jobId) => {
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ const [rows] = await conn.query(
+ `SELECT * FROM h5_agent_jobs
+ WHERE id = ? AND user_id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [jobId, userId]
+ );
+ const job = rows[0];
+ if (!job) throw agentJobError("\u4EFB\u52A1\u4E0D\u5B58\u5728", "agent_job_not_found");
+ if (!["failed", "timed_out", "cancelled"].includes(job.status)) {
+ throw agentJobError("\u5F53\u524D\u72B6\u6001\u4E0D\u5141\u8BB8\u91CD\u8BD5\u4EFB\u52A1", "invalid_state_transition");
+ }
+ if (job.status === "failed" && !RETRYABLE_ERROR_CODES.has(job.error_code)) {
+ throw agentJobError("\u8BE5\u4EFB\u52A1\u9519\u8BEF\u4E0D\u53EF\u81EA\u52A8\u91CD\u8BD5", "invalid_state_transition");
+ }
+ const now = nowFactory();
+ await conn.query(
+ `UPDATE h5_agent_jobs
+ SET status = 'queued', progress_json = ?, started_at = NULL, completed_at = NULL,
+ error_code = NULL, error_message = NULL, result_page_id = NULL, result_asset_id = NULL,
+ job_token_hash = NULL, heartbeat_at = NULL, expires_at = ?, updated_at = ?
+ WHERE id = ?`,
+ [JSON.stringify({ stage: "queued" }), now + tokenTtlMs, now, jobId]
+ );
+ await conn.commit();
+ return getJob(userId, jobId);
+ } catch (error) {
+ await conn.rollback();
+ throw error;
+ } finally {
+ conn.release();
+ }
+ };
+ const claimJob = async (jobId) => {
+ const conn = await pool.getConnection();
+ try {
+ await conn.beginTransaction();
+ const [rows] = await conn.query(
+ `SELECT j.*, c.category_code AS output_category_code
+ FROM h5_agent_jobs j
+ JOIN h5_space_categories c ON c.id = j.output_category_id
+ WHERE j.id = ?
+ LIMIT 1
+ FOR UPDATE`,
+ [jobId]
+ );
+ const job = rows[0];
+ if (!job) throw agentJobError("\u4EFB\u52A1\u4E0D\u5B58\u5728", "agent_job_not_found");
+ if (job.status !== "queued") {
+ throw agentJobError("\u4EFB\u52A1\u5F53\u524D\u4E0D\u53EF\u9886\u53D6", "invalid_state_transition");
+ }
+ 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
+ };
+ } catch (error) {
+ await conn.rollback();
+ throw error;
+ } finally {
+ conn.release();
+ }
+ };
+ const requireJobToken = async (jobId, token) => {
+ const [rows] = await pool.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.id = ?
+ LIMIT 1`,
+ [jobId]
+ );
+ const job = rows[0];
+ if (!job) throw agentJobError("\u4EFB\u52A1\u4E0D\u5B58\u5728", "agent_job_not_found");
+ if (job.status !== "running") {
+ throw agentJobError("\u4EFB\u52A1\u672A\u5904\u4E8E\u6267\u884C\u4E2D\u72B6\u6001", "invalid_state_transition");
+ }
+ if (job.expires_at != null && asNumber5(job.expires_at) < nowFactory()) {
+ throw agentJobError("\u4EFB\u52A1 token \u5DF2\u8FC7\u671F", "agent_job_expired");
+ }
+ if (!verifyTokenHash(job.job_token_hash, token)) {
+ throw agentJobError("\u4EFB\u52A1 token \u65E0\u6548", "agent_job_token_invalid");
+ }
+ return job;
+ };
+ const getAssetForJob = async (jobId, token, assetId) => {
+ await requireJobToken(jobId, token);
+ const [rows] = await pool.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
+ JOIN h5_assets a ON a.id = ja.asset_id
+ JOIN h5_asset_versions v ON v.id = ja.asset_version_id
+ WHERE ja.job_id = ? AND ja.asset_id = ?
+ LIMIT 1`,
+ [jobId, assetId]
+ );
+ const asset = rows[0];
+ if (!asset) throw agentJobError("\u4EFB\u52A1\u8D44\u4EA7\u4E0D\u5B58\u5728", "asset_not_found");
+ if (!hasPathAccess(asset)) {
+ throw agentJobError("\u4EFB\u52A1\u8D44\u4EA7\u5C1A\u672A\u901A\u8FC7\u5B89\u5168\u68C0\u67E5", "security_scan_required");
+ }
+ return {
+ assetId: asset.id,
+ assetVersionId: asset.asset_version_id,
+ displayName: asset.display_name,
+ mimeType: asset.mime_type,
+ path: await resolveReadableStoragePath(asset.storage_key)
+ };
+ };
+ const heartbeat = async (jobId, token, input) => {
+ const job = await requireJobToken(jobId, token);
+ const now = nowFactory();
+ const payload = progressPayload(input, "running");
+ await pool.query(
+ `UPDATE h5_agent_jobs
+ SET heartbeat_at = ?, updated_at = ?, expires_at = ?, progress_json = ?
+ WHERE id = ?`,
+ [now, now, now + tokenTtlMs, JSON.stringify(payload), jobId]
+ );
+ return jobResponse({ ...job, progress_json: JSON.stringify(payload), heartbeat_at: now });
+ };
+ const completeJob = async (jobId, token, input) => {
+ const job = await requireJobToken(jobId, token);
+ const now = nowFactory();
+ 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(
+ `UPDATE h5_agent_jobs
+ SET status = 'failed', error_code = ?, error_message = ?, completed_at = ?,
+ updated_at = ?, heartbeat_at = NULL, job_token_hash = NULL, progress_json = ?
+ WHERE id = ?`,
+ [errorCode, errorMessage.slice(0, 1e3), now, now, JSON.stringify({ stage: "failed" }), jobId]
+ );
+ return getJob(job.user_id, jobId);
+ }
+ const content = String(input?.content ?? "");
+ const outputBytes = Buffer.byteLength(content, "utf8");
+ if (!content.trim()) {
+ throw agentJobError("\u4EFB\u52A1\u8F93\u51FA\u5185\u5BB9\u4E0D\u80FD\u4E3A\u7A7A", "invalid_agent_job_output");
+ }
+ if (outputBytes > asNumber5(job.max_output_bytes)) {
+ throw agentJobError("\u4EFB\u52A1\u8F93\u51FA\u8D85\u8FC7\u5927\u5C0F\u9650\u5236", "page_content_too_large", {
+ maxBytes: asNumber5(job.max_output_bytes)
+ });
+ }
+ const sourceAssetIds = dedupeStrings(input?.sourceAssetIds);
+ const boundAssets = await fetchJobAssets(jobId);
+ const boundAssetIdSet = new Set(boundAssets.map((asset) => asset.assetId));
+ const invalidSourceIds = sourceAssetIds.filter((assetId) => !boundAssetIdSet.has(assetId));
+ if (invalidSourceIds.length > 0) {
+ throw agentJobError("\u8F93\u51FA\u5F15\u7528\u4E86\u672A\u6388\u6743\u7684\u8F93\u5165\u8D44\u4EA7", "invalid_agent_job_output", {
+ assetIds: invalidSourceIds
+ });
+ }
+ const page = await pageService.createFromAgent(
+ job.user_id,
+ {
+ title: input?.title,
+ summary: input?.summary,
+ content,
+ contentFormat: outputContentFormat(job.output_type, input),
+ pageType: input?.pageType,
+ templateId: input?.templateId,
+ categoryCode: job.output_category_code,
+ changeNote: `Agent job ${jobId}`
+ },
+ {
+ jobId,
+ assetId: sourceAssetIds[0] ?? null,
+ assetIds: sourceAssetIds
+ }
+ );
+ await pool.query(
+ `UPDATE h5_agent_jobs
+ SET status = 'completed', result_page_id = ?, completed_at = ?, updated_at = ?,
+ heartbeat_at = NULL, job_token_hash = NULL, progress_json = ?
+ WHERE id = ?`,
+ [page.id, now, now, JSON.stringify({ stage: "completed" }), jobId]
+ );
+ return getJob(job.user_id, jobId);
+ };
+ return {
+ createJob,
+ getJob,
+ listJobs,
+ cancelJob,
+ retryJob,
+ claimJob,
+ getAssetForJob,
+ heartbeat,
+ completeJob
+ };
+}
+
+// mindspace-agent-runner.mjs
+import crypto22 from "node:crypto";
+import fs17 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";
+
+// message-stream.mjs
+function mergeStreamingChunk(previous, incoming) {
+ const prev = String(previous ?? "");
+ const next = String(incoming ?? "");
+ if (!next) return prev;
+ if (!prev) return next;
+ if (next === prev) return prev;
+ if (next.startsWith(prev)) return next;
+ if (prev.startsWith(next)) return prev;
+ if (prev.endsWith(next)) return prev;
+ const maxOverlap = Math.min(prev.length, next.length);
+ for (let size = maxOverlap; size > 0; size -= 1) {
+ if (prev.endsWith(next.slice(0, size))) {
+ return prev + next.slice(size);
+ }
+ }
+ return prev + next;
+}
+function findLastContentIndex(content, type) {
+ for (let index = content.length - 1; index >= 0; index -= 1) {
+ if (content[index]?.type === type) return index;
+ }
+ return -1;
+}
+function mergeTextLikeBlock(block, incomingValue, field) {
+ block[field] = mergeStreamingChunk(block[field], incomingValue);
+}
+function mergeMessageContent(existingContent, incomingContent) {
+ const merged = [...existingContent];
+ for (const item of incomingContent) {
+ if (item.type === "text") {
+ const index = findLastContentIndex(merged, "text");
+ if (index >= 0) {
+ mergeTextLikeBlock(merged[index], item.text, "text");
+ continue;
+ }
+ merged.push({ ...item });
+ continue;
+ }
+ if (item.type === "thinking") {
+ const index = findLastContentIndex(merged, "thinking");
+ if (index >= 0) {
+ mergeTextLikeBlock(merged[index], item.thinking, "thinking");
+ continue;
+ }
+ merged.push({ ...item });
+ continue;
+ }
+ const tail = merged[merged.length - 1];
+ if (tail && JSON.stringify(tail) === JSON.stringify(item)) continue;
+ merged.push({ ...item });
+ }
+ return merged;
+}
+
+// mindspace-agent-runner.mjs
+var insecureDispatcher5 = new Agent5({
+ connect: { rejectUnauthorized: false }
+});
+var DEFAULT_TEXT_BYTES = 48 * 1024;
+function isHttpsTarget3(target) {
+ return String(target).startsWith("https://");
+}
+function runnerError(message, code, details) {
+ return Object.assign(new Error(message), { code, details });
+}
+function createUserMessage(text) {
+ return {
+ id: crypto22.randomUUID(),
+ role: "user",
+ created: Math.floor(Date.now() / 1e3),
+ content: [{ type: "text", text }],
+ metadata: { userVisible: true, agentVisible: true }
+ };
+}
+function messageVisibleText(message) {
+ return (message?.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("");
+}
+function pushMessage(messages, incoming) {
+ const last = messages[messages.length - 1];
+ if (last?.id && incoming?.id && last.id === incoming.id) {
+ return [
+ ...messages.slice(0, -1),
+ {
+ ...last,
+ content: mergeMessageContent(last.content, incoming.content)
+ }
+ ];
+ }
+ return [...messages, incoming];
+}
+function extractBalancedJsonObject(text, startIndex = 0) {
+ let depth = 0;
+ let inString = false;
+ let escape = false;
+ for (let i = startIndex; i < text.length; i += 1) {
+ const ch = text[i];
+ if (inString) {
+ if (escape) {
+ escape = false;
+ continue;
+ }
+ if (ch === "\\") {
+ escape = true;
+ continue;
+ }
+ if (ch === '"') inString = false;
+ continue;
+ }
+ if (ch === '"') {
+ inString = true;
+ continue;
+ }
+ if (ch === "{") depth += 1;
+ else if (ch === "}") {
+ depth -= 1;
+ if (depth === 0) return text.slice(startIndex, i + 1);
+ }
+ }
+ return null;
+}
+function collectJsonCandidates(text) {
+ const source = String(text ?? "").trim();
+ const candidates = [];
+ const seen = /* @__PURE__ */ new Set();
+ const push = (value) => {
+ const trimmed = String(value ?? "").trim();
+ if (!trimmed || seen.has(trimmed)) return;
+ seen.add(trimmed);
+ candidates.push(trimmed);
+ };
+ for (const match of source.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) {
+ push(match[1]);
+ }
+ push(source);
+ for (const candidate of [...candidates]) {
+ for (let i = 0; i < candidate.length; i += 1) {
+ if (candidate[i] !== "{") continue;
+ const balanced = extractBalancedJsonObject(candidate, i);
+ if (balanced) push(balanced);
+ }
+ }
+ return candidates;
+}
+function parseJsonObject2(text) {
+ for (const candidate of collectJsonCandidates(text)) {
+ for (const normalized of [candidate, jsonrepair2(candidate)]) {
+ try {
+ const parsed = JSON.parse(normalized);
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ return parsed;
+ }
+ } catch {
+ }
+ }
+ }
+ return null;
+}
+function extractJsonObject2(text) {
+ const source = String(text ?? "").trim();
+ const parsed = parseJsonObject2(source);
+ if (parsed) return parsed;
+ const start = source.indexOf("{");
+ const end = source.lastIndexOf("}");
+ if (start < 0 || end <= start) {
+ throw runnerError("Agent \u8F93\u51FA\u7F3A\u5C11 JSON \u7ED3\u679C", "invalid_agent_job_output");
+ }
+ const raw = extractBalancedJsonObject(source, start) ?? source.slice(start, end + 1);
+ try {
+ return JSON.parse(jsonrepair2(raw));
+ } catch (error) {
+ throw runnerError("Agent \u8F93\u51FA JSON \u89E3\u6790\u5931\u8D25", "invalid_agent_job_output", {
+ raw: raw.slice(0, 2e3),
+ cause: error instanceof Error ? error.message : String(error)
+ });
+ }
+}
+function normalizeStructuredResult(payload) {
+ const title = String(payload?.title ?? "").trim();
+ const summary = String(payload?.summary ?? "").trim();
+ const content = String(payload?.content ?? payload?.markdown ?? payload?.body ?? "").trim();
+ const contentFormat = String(payload?.content_format ?? payload?.contentFormat ?? "markdown").trim().toLowerCase();
+ if (!title || !content) {
+ throw runnerError("Agent \u8F93\u51FA\u7F3A\u5C11\u6807\u9898\u6216\u6B63\u6587", "invalid_agent_job_output");
+ }
+ return {
+ title,
+ summary,
+ content,
+ contentFormat: contentFormat === "html" ? "html" : "markdown",
+ pageType: contentFormat === "html" ? "html" : "article",
+ templateId: contentFormat === "html" ? "static-html" : "report"
+ };
+}
+async function readAssetContext(asset, maxBytes = DEFAULT_TEXT_BYTES) {
+ const mimeType = String(asset.mimeType ?? "");
+ const textLike = mimeType.startsWith("text/") || mimeType === "application/json" || mimeType.includes("xml") || mimeType.includes("javascript");
+ if (!textLike) {
+ return {
+ assetId: asset.assetId,
+ displayName: asset.displayName,
+ mimeType,
+ excerpt: "",
+ 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);
+ return {
+ assetId: asset.assetId,
+ displayName: asset.displayName,
+ mimeType,
+ excerpt: buffer.subarray(0, maxBytes).toString("utf8"),
+ truncated: buffer.length > maxBytes,
+ note: buffer.length > maxBytes ? `\u5185\u5BB9\u5DF2\u622A\u65AD\u5230 ${maxBytes} \u5B57\u8282\u3002` : void 0
+ };
+}
+function buildAgentJobPrompt(job, assetContexts) {
+ const assetSections = assetContexts.map((asset, index) => {
+ const header = `\u8D44\u6599 ${index + 1}: ${asset.displayName} (${asset.mimeType || "unknown"})`;
+ const note = asset.note ? `\u8BF4\u660E: ${asset.note}
+` : "";
+ const body = asset.excerpt ? `\u5185\u5BB9:
+<<>>
+${asset.excerpt}
+<<>>` : "\u5185\u5BB9: [\u672A\u5185\u5D4C\u6587\u672C\u5185\u5BB9]";
+ return `${header}
+${note}${body}`;
+ }).join("\n\n");
+ return [
+ "\u4F60\u6B63\u5728\u4E3A MindSpace \u751F\u6210\u4E00\u4E2A\u9875\u9762\u8349\u7A3F\u3002",
+ "\u4F60\u53EA\u80FD\u57FA\u4E8E\u7ED9\u5B9A\u8D44\u6599\u548C\u7528\u6237\u4EFB\u52A1\u751F\u6210\u7ED3\u679C\uFF0C\u4E0D\u80FD\u5047\u8BBE\u989D\u5916\u4E8B\u5B9E\u3002",
+ "\u4E0D\u8981\u8BF7\u6C42\u5DE5\u5177\u786E\u8BA4\uFF0C\u4E0D\u8981\u8F93\u51FA\u89E3\u91CA\uFF0C\u4E0D\u8981\u8C03\u7528\u5DE5\u5177\u3002",
+ "\u6700\u7EC8\u56DE\u590D\u5FC5\u987B\u662F\u4E00\u4E2A\u5408\u6CD5 JSON \u5BF9\u8C61\uFF0C\u4E14\u81F3\u5C11\u5305\u542B title\u3001summary\u3001content \u4E09\u4E2A\u5B57\u6BB5\u3002",
+ 'content \u53EF\u4EE5\u662F Markdown \u6216 HTML\uFF1B\u5982\u9700 HTML\uFF0C\u8BF7\u628A content_format \u8BBE\u4E3A "html"\u3002',
+ "\u793A\u4F8B\uFF1A",
+ '{"title":"\u9875\u9762\u6807\u9898","summary":"\u4E00\u53E5\u8BDD\u6458\u8981","content":"# \u6807\u9898\\n\\n\u6B63\u6587\u6BB5\u843D","content_format":"markdown"}',
+ `\u7528\u6237\u4EFB\u52A1: ${job.instruction}`,
+ "",
+ assetSections
+ ].join("\n");
+}
+async function readJsonResponse(response) {
+ const text = await response.text();
+ if (!response.ok) {
+ throw new Error(text || `upstream ${response.status}`);
+ }
+ return text ? JSON.parse(text) : null;
+}
+async function defaultExecuteSessionReply(apiFetch2, sessionId, requestId, prompt) {
+ const eventsResponse = await apiFetch2(`/sessions/${sessionId}/events`, {
+ method: "GET",
+ headers: { Accept: "text/event-stream" }
+ });
+ if (!eventsResponse.ok || !eventsResponse.body) {
+ const text = await eventsResponse.text().catch(() => "");
+ throw runnerError(text || "\u65E0\u6CD5\u5EFA\u7ACB\u4EFB\u52A1\u4E8B\u4EF6\u6D41", "worker_unavailable");
+ }
+ const replyResponse = await apiFetch2(`/sessions/${sessionId}/reply`, {
+ method: "POST",
+ body: JSON.stringify({
+ request_id: requestId,
+ user_message: createUserMessage(prompt)
+ })
+ });
+ if (!replyResponse.ok) {
+ const text = await replyResponse.text().catch(() => "");
+ throw runnerError(text || "Agent reply \u8BF7\u6C42\u5931\u8D25", "worker_unavailable");
+ }
+ replyResponse.body?.cancel().catch?.(() => {
+ });
+ const reader = Readable2.fromWeb(eventsResponse.body);
+ const decoder = new TextDecoder();
+ let buffer = "";
+ let messages = [];
+ for await (const chunk of reader) {
+ buffer += decoder.decode(chunk, { stream: true });
+ const frames = buffer.split("\n\n");
+ buffer = frames.pop() ?? "";
+ for (const frame of frames) {
+ let data = "";
+ for (const line of frame.split("\n")) {
+ if (line.startsWith("data:")) data += `${line.slice(5).trim()}`;
+ }
+ if (!data) continue;
+ let event;
+ try {
+ event = JSON.parse(data);
+ } catch {
+ continue;
+ }
+ const routingId = event.chat_request_id ?? event.request_id;
+ if (routingId && routingId !== requestId) continue;
+ if (event.type === "Message" && event.message?.metadata?.userVisible) {
+ const hasActionRequired = event.message.content?.some((item) => item.type === "actionRequired");
+ if (hasActionRequired) {
+ throw runnerError("\u4EFB\u52A1\u6267\u884C\u9700\u8981\u4EBA\u5DE5\u786E\u8BA4\uFF0CRunner \u5F53\u524D\u65E0\u6CD5\u81EA\u52A8\u5904\u7406", "worker_unavailable");
+ }
+ messages = pushMessage(messages, event.message);
+ } else if (event.type === "UpdateConversation") {
+ messages = (event.conversation ?? []).filter((item) => item.metadata?.userVisible);
+ } else if (event.type === "Error") {
+ throw runnerError(event.error || "\u4EFB\u52A1\u6267\u884C\u5931\u8D25", "worker_unavailable");
+ } else if (event.type === "Finish") {
+ const assistant = [...messages].reverse().find((item) => item.role === "assistant");
+ return {
+ text: messageVisibleText(assistant),
+ tokenState: event.token_state ?? null
+ };
+ }
+ }
+ }
+ throw runnerError("\u4EFB\u52A1\u4E8B\u4EF6\u6D41\u63D0\u524D\u7ED3\u675F", "worker_unavailable");
+}
+function createMindSpaceAgentRunner({
+ apiTarget,
+ apiSecret,
+ userAuth: userAuth2,
+ agentJobService,
+ executeSessionReply: executeSessionReply2 = defaultExecuteSessionReply,
+ apiFetchImpl = null
+}) {
+ const apiFetch2 = apiFetchImpl ?? (async (pathname, init = {}) => {
+ const url = new URL(pathname, apiTarget);
+ const headers = {
+ ...init.headers ?? {},
+ "X-Secret-Key": apiSecret
+ };
+ if (init.body && !headers["Content-Type"]) {
+ headers["Content-Type"] = "application/json";
+ }
+ return undiciFetch5(url, {
+ ...init,
+ headers,
+ dispatcher: isHttpsTarget3(apiTarget) ? insecureDispatcher5 : void 0
+ });
+ });
+ const runJob = async (jobId) => {
+ let claim = null;
+ let sessionId = null;
+ try {
+ claim = 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");
+ }
+ const workingDir = await userAuth2.resolveWorkingDir(claim.userId);
+ const sessionPolicy = await userAuth2.getAgentSessionPolicy(claim.userId);
+ const publishLayout = await userAuth2.getUserPublishLayout(claim.userId);
+ const startSession = await readJsonResponse(
+ await apiFetch2("/agent/start", {
+ method: "POST",
+ body: JSON.stringify({
+ working_dir: workingDir,
+ enable_context_memory: sessionPolicy.enableContextMemory,
+ ...sessionPolicy.extensionOverrides ? { extension_overrides: sessionPolicy.extensionOverrides } : {}
+ })
+ })
+ );
+ sessionId = startSession?.id;
+ if (!sessionId) {
+ throw runnerError("Agent \u4F1A\u8BDD\u542F\u52A8\u5931\u8D25", "worker_unavailable");
+ }
+ await userAuth2.registerAgentSession(claim.userId, sessionId);
+ await reconcileAgentSession(
+ (pathname, init) => apiFetch2(pathname, init),
+ sessionId,
+ {
+ workingDir,
+ sessionPolicy,
+ sandboxConstraints: publishLayout?.constraints ?? null,
+ userContext: publishLayout ? {
+ userId: claim.userId,
+ displayName: publishLayout.displayName,
+ username: publishLayout.username,
+ slug: publishLayout.slug
+ } : null
+ }
+ );
+ const assetContexts = [];
+ for (const asset of claim.allowedAssets) {
+ const localAsset = await agentJobService.getAssetForJob(jobId, claim.jobToken, asset.assetId);
+ assetContexts.push(await readAssetContext(localAsset));
+ }
+ const prompt = buildAgentJobPrompt(claim, assetContexts);
+ const requestId = crypto22.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, {
+ title: parsed.title,
+ summary: parsed.summary,
+ content: parsed.content,
+ contentFormat: parsed.contentFormat,
+ pageType: parsed.pageType,
+ templateId: parsed.templateId,
+ sourceAssetIds: claim.allowedAssets.map((asset) => asset.assetId)
+ });
+ } catch (error) {
+ if (claim?.jobToken) {
+ await agentJobService.completeJob(jobId, claim.jobToken, {
+ status: "failed",
+ errorCode: error?.code ?? "worker_unavailable",
+ errorMessage: error instanceof Error ? error.message : String(error)
+ }).catch(() => {
+ });
+ }
+ throw error;
+ }
+ };
+ return { runJob };
+}
+
+// mindspace-chat-save.mjs
+import fs18 from "node:fs/promises";
+import path19 from "node:path";
+var URL_PATTERN = /https?:\/\/[^\s<>"')\]]+\/(?:MindSpace|temp)\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi;
+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) {
+ if (!canonicalRelativePath || canonicalRelativePath === String(originalRelativePath ?? "").replace(/^\/+/, "")) {
+ return publicUrl2;
+ }
+ const suffix = encodeUrlPath(originalRelativePath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ return String(publicUrl2).replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath));
+}
+function extractStaticPageLinks(content, { userId, username } = {}) {
+ const text = String(content ?? "");
+ const links = [];
+ const seen = /* @__PURE__ */ new Set();
+ 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);
+ if (normalizedUserId) {
+ if (owner !== normalizedUserId && owner !== normalizedUsername) continue;
+ } else if (normalizedUsername && owner !== normalizedUsername) {
+ continue;
+ }
+ const key = `${owner}/${relativePath}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ links.push({
+ publicUrl: canonicalizeStaticPageUrl(match[0], originalRelativePath, relativePath),
+ owner,
+ relativePath,
+ filename: path19.basename(relativePath)
+ });
+ }
+ return links;
+}
+function buildWorkspaceAssetUrl(userId, relativePath) {
+ const key = String(userId ?? "").trim();
+ const clean = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== "..").map((part) => encodeURIComponent(part)).join("/");
+ if (!key || !clean) return null;
+ return `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(key)}/${clean}`;
+}
+function buildWorkspaceThumbnailUrl(userId, htmlRelativePath) {
+ const thumbRel = workspaceThumbnailRelativePath(htmlRelativePath);
+ return buildWorkspaceAssetUrl(userId, thumbRel);
+}
+function resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath) {
+ const key = String(userId ?? "").trim().toLowerCase();
+ if (!PUBLISH_KEY_UUID.test(key)) {
+ throw Object.assign(new Error("\u65E0\u6548\u7684\u7528\u6237 ID"), { code: "invalid_page_path" });
+ }
+ const clean = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== "..").join("/");
+ 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}`)) {
+ 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");
+ 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) };
+}
+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 });
+ } catch {
+ return null;
+ }
+ for (const entry of entries) {
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
+ const full = path19.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),
+ basename,
+ maxDepth,
+ depth + 1
+ );
+ if (found) return found;
+ }
+ return null;
+}
+async function collectPublishHtmlPaths(publishRoot, results, maxDepth = 6, depth = 0) {
+ if (depth > maxDepth || results.length >= 200) return;
+ let entries;
+ try {
+ entries = await fs18.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);
+ if (entry.isFile() && entry.name.toLowerCase().endsWith(".html")) {
+ results.push(full);
+ if (results.length >= 200) return;
+ }
+ }
+ 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);
+ if (results.length >= 200) return;
+ }
+}
+function levenshteinDistance(left, right) {
+ if (left === right) return 0;
+ if (!left) return right.length;
+ if (!right) return left.length;
+ const prev = Array.from({ length: right.length + 1 }, (_, index) => index);
+ const next = new Array(right.length + 1);
+ for (let i = 0; i < left.length; i += 1) {
+ next[0] = i + 1;
+ for (let j = 0; j < right.length; j += 1) {
+ const cost = left[i] === right[j] ? 0 : 1;
+ next[j + 1] = Math.min(
+ next[j] + 1,
+ prev[j + 1] + 1,
+ prev[j] + cost
+ );
+ }
+ for (let j = 0; j <= right.length; j += 1) {
+ prev[j] = next[j];
+ }
+ }
+ return prev[right.length];
+}
+function htmlNameForSimilarity(relativePath) {
+ return path19.basename(String(relativePath ?? ""), ".html").toLowerCase();
+}
+function isAcceptableSimilarHtmlMatch(requested, candidate, score, nextScore = Infinity) {
+ if (!requested || !candidate || requested === candidate) return false;
+ const longest = Math.max(requested.length, candidate.length);
+ const allowedDistance = longest >= 18 ? 2 : 1;
+ if (score > allowedDistance) return false;
+ return nextScore > score;
+}
+async function resolveClosestHtmlRelativePath(rootDir, relativePath) {
+ const normalized = normalizeStaticHtmlRelativePath(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 candidateName = htmlNameForSimilarity(candidateRelative);
+ return {
+ relativePath: candidateRelative,
+ score: levenshteinDistance(requestedName, candidateName)
+ };
+ }).sort((left, right) => left.score - right.score || left.relativePath.localeCompare(right.relativePath));
+ if (ranked.length === 0) return null;
+ const best = ranked[0];
+ const nextBestScore = ranked[1]?.score ?? Infinity;
+ if (!isAcceptableSimilarHtmlMatch(requestedName, htmlNameForSimilarity(best.relativePath), best.score, nextBestScore)) {
+ return null;
+ }
+ return best.relativePath;
+}
+async function findPublishHtml(h5Root, userId, relativePath) {
+ const normalized = normalizeStaticHtmlRelativePath(relativePath);
+ const basename = path19.basename(normalized);
+ const candidates = [
+ normalized,
+ path19.posix.join("public", basename),
+ basename
+ ].filter((value, index, list) => value && list.indexOf(value) === index);
+ for (const candidate of candidates) {
+ try {
+ return await readPublishHtml(h5Root, userId, candidate);
+ } catch {
+ }
+ }
+ const publishRoot = path19.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("/");
+ return readPublishHtml(h5Root, userId, resolvedRelativePath);
+ }
+ const similarRelativePath = await resolveClosestHtmlRelativePath(publishRoot, normalized);
+ if (similarRelativePath) {
+ return readPublishHtml(h5Root, userId, similarRelativePath);
+ }
+ throw Object.assign(new Error("\u65E0\u6CD5\u8BFB\u53D6\u94FE\u63A5\u9875\u9762\u5185\u5BB9"), { code: "static_page_not_found" });
+}
+function buildWorkspaceBaseHref(userId, htmlRelativePath) {
+ const key = String(userId ?? "").trim();
+ const dir = path19.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}/` : ""}`;
+}
+function injectHtmlBaseHref(html, baseHref) {
+ const safeBase = String(baseHref ?? "").replace(/"/g, "%22");
+ if (!safeBase) return html;
+ if (/]*href="[^"]*"[^>]*>/i, ``);
+ }
+ if (/]*>/i.test(html)) {
+ return html.replace(/]*>/i, (match) => `${match}
+`);
+ }
+ return `${html}`;
+}
+function buildChatSavePreviewQuery({
+ sessionId,
+ messageId,
+ selectedLinkIndex = 0,
+ previewTitle,
+ previewSummary
+} = {}) {
+ const params = new URLSearchParams({
+ session_id: String(sessionId ?? ""),
+ message_id: String(messageId ?? ""),
+ selected_link_index: String(selectedLinkIndex)
+ });
+ const title = String(previewTitle ?? "").trim();
+ const summary = String(previewSummary ?? "").trim();
+ if (title) params.set("preview_title", title);
+ if (summary) params.set("preview_summary", summary);
+ return params;
+}
+function buildChatSavePreviewFrameUrl(input) {
+ return `/api/mindspace/v1/pages/chat-save-preview?${buildChatSavePreviewQuery(input).toString()}`;
+}
+function buildChatSaveThumbnailUrl(input) {
+ return `/api/mindspace/v1/pages/chat-save-thumbnail?${buildChatSavePreviewQuery(input).toString()}`;
+}
+function titleFromHtml2(html) {
+ const match = String(html).match(/]*>([^<]+)<\/title>/i);
+ return match?.[1]?.trim() ?? "";
+}
+function summaryFromHtml(html) {
+ const stripped = String(html).replace(/
+
+`;
+}
+function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
+ let html = result.html;
+ if (embed) {
+ html = preparePublicationHtmlForEmbed(html);
+ allowPlazaEmbedFrame(res);
+ } else if (raw) {
+ html = stripPublicationHtmlCspMeta(html);
+ }
+ 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");
+ 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'"
+ );
+ 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
+ })
+ );
+ }
+ res.set("Content-Type", "text/html; charset=utf-8");
+ res.set("Content-Security-Policy", publishedPageCsp(html, { embed, raw }));
+ res.set(
+ "Cache-Control",
+ result.publication.accessMode === "public" ? "public, max-age=60" : "private, no-store"
+ );
+ return res.send(html);
+}
+function passwordGateHtml(action) {
+ return `
+
+
+
+
+
+ \u53D7\u4FDD\u62A4\u9875\u9762
+
+
+
+
+
+`;
+}
+function escapePublicHtml(value) {
+ return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
+}
+function publicHomepageHtml(data) {
+ const cards = data.pages.map(
+ (page) => `
+
+ ${escapePublicHtml(page.templateId)}
+ ${Number(page.viewCount)} \u6B21\u6D4F\u89C8
+
+ ${escapePublicHtml(page.title)}
+ ${escapePublicHtml(page.summary || "\u6682\u65E0\u6458\u8981")}
+
+`
+ ).join("");
+ return `
+
+
+
+
+
+ ${escapePublicHtml(data.owner.displayName)} \xB7 MindSpace
+
+
+
+
+
+ MindSpace Public
+ ${escapePublicHtml(data.owner.displayName)}
+ \u8FD9\u91CC\u5C55\u793A ${escapePublicHtml(data.owner.displayName)} \u5F53\u524D\u516C\u5F00\u53D1\u5E03\u4E14\u5728\u7EBF\u7684 MindSpace \u9875\u9762\u3002
+
+ ${Number(data.pageCount)} \u4E2A\u516C\u5F00\u9875\u9762
+ ${Number(data.totalViews)} \u6B21\u7D2F\u8BA1\u6D4F\u89C8
+ /u/${escapePublicHtml(data.owner.slug)}
+
+
+ ${data.pages.length ? `` : '\u8FD9\u4E2A\u4E3B\u9875\u8FD8\u6CA1\u6709\u516C\u5F00\u9875\u9762\u3002\u7A0D\u540E\u518D\u6765\u770B\u770B\uFF0C\u6216\u8005\u76F4\u63A5\u8BBF\u95EE\u4F5C\u8005\u5206\u4EAB\u7ED9\u4F60\u7684\u4E13\u5C5E\u94FE\u63A5\u3002'}
+
+
+`;
+}
+async function resolvePublishedRoute(req, res, password = null) {
+ await userAuthReady;
+ if (!mindSpacePublications) return res.status(503).send("MindSpace \u672A\u542F\u7528");
+ try {
+ const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null;
+ const result = await mindSpacePublications.resolvePublic(
+ req.params.ownerSlug,
+ req.params.urlSlug,
+ viewer?.id,
+ password,
+ {
+ userAgent: req.get("user-agent"),
+ referrer: req.get("referer")
+ }
+ );
+ return sendPublishedPage(req, res, result, {
+ embed: isPlazaEmbedRequest(req.query),
+ raw: String(req.query.view ?? "").toLowerCase() === "raw"
+ });
+ } catch (error) {
+ if (error?.code === "publication_password_required") {
+ return res.status(password ? 403 : 200).send(passwordGateHtml(req.originalUrl));
+ }
+ if (error?.code === "publication_login_required") {
+ return res.status(401).send("\u8BF7\u5148\u767B\u5F55 TKMind \u540E\u518D\u8BBF\u95EE\u6B64\u9875\u9762");
+ }
+ if (error?.code === "publication_not_found") return res.status(404).send("\u9875\u9762\u4E0D\u5B58\u5728\u6216\u5DF2\u4E0B\u7EBF");
+ return res.status(500).send("\u9875\u9762\u52A0\u8F7D\u5931\u8D25");
+ }
+}
+app.use(async (req, res, next) => {
+ const thumbnailMatch = /^\/u\/([^/]+)\/pages\/([^/]+)\.thumbnail\.png$/.exec(req.path);
+ if (!thumbnailMatch) return next();
+ const ownerSlug = thumbnailMatch[1];
+ const urlSlug = thumbnailMatch[2];
+ await userAuthReady;
+ if (!authPool || !mindSpacePages) return res.status(503).send("MindSpace \u672A\u542F\u7528");
+ try {
+ const [rows] = await authPool.query(
+ `SELECT pr.user_id, pr.page_id
+ FROM h5_publish_records pr
+ JOIN h5_users u ON u.id = pr.user_id
+ WHERE COALESCE(u.slug, u.username) = ?
+ AND pr.url_slug = ?
+ AND pr.status = 'online'
+ ORDER BY pr.published_at DESC
+ LIMIT 1`,
+ [ownerSlug, urlSlug]
+ );
+ const row = rows[0];
+ if (!row) return res.status(404).send("\u7F29\u7565\u56FE\u4E0D\u5B58\u5728");
+ const svg = await mindSpacePages.renderThumbnail(row.user_id, row.page_id);
+ res.set("Content-Type", "image/png");
+ res.set("Cache-Control", "public, max-age=300");
+ return res.send(rasterizeThumbnailSvgToPng(svg));
+ } catch (error) {
+ return res.status(500).send("\u7F29\u7565\u56FE\u52A0\u8F7D\u5931\u8D25");
+ }
+});
+app.get("/u/:ownerSlug/pages/:urlSlug", async (req, res) => {
+ return resolvePublishedRoute(req, res);
+});
+app.get("/u/:ownerSlug", async (req, res) => {
+ await userAuthReady;
+ if (!mindSpacePublications) return res.status(503).send("MindSpace \u672A\u542F\u7528");
+ try {
+ const data = await mindSpacePublications.getPublicHomepage(req.params.ownerSlug);
+ 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'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
+ );
+ res.set("Cache-Control", "public, max-age=60");
+ return res.send(publicHomepageHtml(data));
+ } catch (error) {
+ if (error?.code === "publication_not_found") return res.status(404).send("\u4E3B\u9875\u4E0D\u5B58\u5728");
+ return res.status(500).send("\u4E3B\u9875\u52A0\u8F7D\u5931\u8D25");
+ }
+});
+app.post(
+ "/u/:ownerSlug/pages/:urlSlug",
+ express2.urlencoded({ extended: false, limit: "2kb" }),
+ async (req, res) => resolvePublishedRoute(req, res, req.body?.password)
+);
+app.get("/s/:token", async (req, res) => {
+ await userAuthReady;
+ if (!mindSpacePublications) return res.status(503).send("MindSpace \u672A\u542F\u7528");
+ try {
+ const viewer = req.userSession && userAuth ? await userAuth.getMe(req.userToken) : null;
+ return sendPublishedPage(
+ req,
+ res,
+ await mindSpacePublications.resolvePrivateLink(req.params.token, viewer?.id, {
+ userAgent: req.get("user-agent"),
+ referrer: req.get("referer")
+ }),
+ {
+ embed: isPlazaEmbedRequest(req.query),
+ raw: String(req.query.view ?? "").toLowerCase() === "raw"
+ }
+ );
+ } catch (error) {
+ if (error?.code === "publication_not_found") return res.status(404).send("\u9875\u9762\u4E0D\u5B58\u5728\u6216\u5DF2\u4E0B\u7EBF");
+ return res.status(500).send("\u9875\u9762\u52A0\u8F7D\u5931\u8D25");
+ }
+});
+var USERNAME_SLUG = /^[a-z0-9_]{2,32}$/;
+async function resolvePublishDirKey(segment) {
+ const lower = String(segment ?? "").trim().toLowerCase();
+ if (!lower) return null;
+ if (PUBLISH_KEY_UUID.test(lower)) return lower;
+ if (USERNAME_SLUG.test(lower)) {
+ if (authPool) {
+ const [rows] = await authPool.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [lower]);
+ if (rows[0]?.id) return String(rows[0].id).toLowerCase();
+ }
+ return lower;
+ }
+ return null;
+}
+function sendPublishFile(req, res, filePath) {
+ if (!filePath.toLowerCase().endsWith(".html")) {
+ res.sendFile(filePath, (err) => {
+ if (err && !res.headersSent) res.status(404).json({ message: "\u6587\u4EF6\u4E0D\u5B58\u5728" });
+ });
+ return;
+ }
+ let html;
+ try {
+ html = fs23.readFileSync(filePath, "utf8");
+ } catch {
+ res.status(404).json({ message: "\u6587\u4EF6\u4E0D\u5B58\u5728" });
+ return;
+ }
+ const embed = isPlazaEmbedRequest(req.query);
+ if (embed) {
+ html = preparePublicationHtmlForEmbed(html);
+ allowPlazaEmbedFrame(res);
+ res.set("Content-Security-Policy", publishedPageCsp(html, { embed }));
+ }
+ const host = (req.headers["x-forwarded-host"] || req.headers.host || "").toString().split(",")[0].trim();
+ const isLocalHost = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i.test(host);
+ const fwdProto = (req.headers["x-forwarded-proto"] || "").toString().split(",")[0].trim();
+ 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 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));
+ fallbackImageUrl = `${pageDirUrl}${pngName}`;
+ }
+ try {
+ html = injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl });
+ } catch {
+ }
+ res.set("Content-Type", "text/html; charset=utf-8");
+ res.send(html);
+}
+var MISPLACED_PUBLIC_HTML_NAME = /^[a-z0-9][a-z0-9._-]{0,127}\.html$/i;
+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 candidates = [
+ path22.resolve(__dirname5, filename),
+ path22.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);
+ await ensureWorkspaceHtmlThumbnail(targetDir, `${PUBLIC_ZONE_DIR}/${filename}`).catch(() => {
+ });
+ console.warn(
+ `[MindSpace] recovered misplaced public HTML ${path22.relative(__dirname5, candidate)} -> ${path22.relative(__dirname5, destination)}`
+ );
+ return destination;
+ }
+ return null;
+}
+async function serveUserPublishFile(req, res, next) {
+ const parts = req.path.split("/").filter(Boolean);
+ if (parts.length < 1) {
+ res.status(404).json({ message: "\u672A\u627E\u5230\u9875\u9762" });
+ return;
+ }
+ const dirKey = await resolvePublishDirKey(parts[0]);
+ if (!dirKey) {
+ res.status(404).json({ message: "\u672A\u627E\u5230\u9875\u9762" });
+ return;
+ }
+ if (parts[0].toLowerCase() !== dirKey && USERNAME_SLUG.test(parts[0])) {
+ const rest2 = parts.slice(1).map(encodeURIComponent).join("/");
+ const target = `/${PUBLISH_ROOT_DIR}/${dirKey}${rest2 ? `/${rest2}` : "/"}`;
+ 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) {
+ res.status(403).json({ message: "\u7981\u6B62\u8BBF\u95EE" });
+ return;
+ }
+ if (!fs23.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)) {
+ const pngPath = ensureThumbnailPng(svgSibling);
+ if (pngPath && fs23.existsSync(pngPath)) {
+ res.set("Cache-Control", "public, max-age=300");
+ res.sendFile(pngPath);
+ return;
+ }
+ }
+ }
+ if (!fs23.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 canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${PUBLIC_ZONE_DIR}/${encodeURIComponent(rest[0])}`;
+ res.redirect(301, canonical);
+ return;
+ }
+ }
+ if (rest.length === 2 && rest[0] === PUBLIC_ZONE_DIR && rest[1].toLowerCase().endsWith(".html")) {
+ const similarRelativePath = await resolveClosestHtmlRelativePath(targetDir, `${PUBLIC_ZONE_DIR}/${rest[1]}`);
+ if (similarRelativePath) {
+ const canonical = `/${PUBLISH_ROOT_DIR}/${encodeURIComponent(username)}/${similarRelativePath.split("/").map((part) => encodeURIComponent(part)).join("/")}`;
+ res.redirect(301, canonical);
+ return;
+ }
+ }
+ const recoveredPublicHtml = await recoverMisplacedPublicHtml(targetDir, resolvedRoot, rest);
+ if (recoveredPublicHtml) {
+ sendPublishFile(req, res, recoveredPublicHtml);
+ return;
+ }
+ 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)) {
+ sendPublishFile(req, res, indexPath);
+ return;
+ }
+ res.status(404).json({ message: "\u76EE\u5F55\u4E2D\u6CA1\u6709 index.html" });
+ return;
+ }
+ sendPublishFile(req, res, resolvedPath);
+}
+app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {
+ await userAuthReady;
+ return serveUserPublishFile(req, res, next);
+});
+app.use("/temp", (req, res) => {
+ res.redirect(301, `/${PUBLISH_ROOT_DIR}${req.url}`);
+});
+app.use("/user", async (req, res, next) => {
+ await userAuthReady;
+ 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) {
+ return res.status(403).json({ message: "\u7981\u6B62\u8BBF\u95EE" });
+ }
+ if (!fs23.existsSync(targetDir)) {
+ return res.status(404).json({ message: "\u7528\u6237\u4E0D\u5B58\u5728" });
+ }
+ if (!fs23.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)) {
+ return res.sendFile(indexPath);
+ }
+ return res.status(404).json({ message: "\u76EE\u5F55\u4E2D\u6CA1\u6709 index.html" });
+ }
+ res.sendFile(resolvedPath, (err) => {
+ if (err) res.status(404).json({ message: "\u6587\u4EF6\u4E0D\u5B58\u5728" });
+ });
+});
+app.get(`/${PUBLISH_ROOT_DIR}/wiki/*`, (_req, res) => {
+ res.sendFile(path22.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" })
+);
+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();
+ res.type("text/plain").sendFile(filePath);
+});
+app.use(express2.static(path22.join(__dirname5, "dist"), { index: "index.html" }));
+app.get("*", (_req, res) => {
+ res.sendFile(path22.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}`);
+ 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`);
+ });
+});
diff --git a/public/plaza-covers/business-1bt5ajr.jpg b/public/plaza-covers/business-1bt5ajr.jpg
new file mode 100644
index 0000000..80de088
Binary files /dev/null and b/public/plaza-covers/business-1bt5ajr.jpg differ
diff --git a/public/plaza-covers/business-1dpghh8.jpg b/public/plaza-covers/business-1dpghh8.jpg
new file mode 100644
index 0000000..76d994a
Binary files /dev/null and b/public/plaza-covers/business-1dpghh8.jpg differ
diff --git a/public/plaza-covers/business-30-84q88u.jpg b/public/plaza-covers/business-30-84q88u.jpg
new file mode 100644
index 0000000..a22930d
Binary files /dev/null and b/public/plaza-covers/business-30-84q88u.jpg differ
diff --git a/public/plaza-covers/business-coffee-shop.jpg b/public/plaza-covers/business-coffee-shop.jpg
new file mode 100644
index 0000000..9117550
Binary files /dev/null and b/public/plaza-covers/business-coffee-shop.jpg differ
diff --git a/public/plaza-covers/business-e09bef.jpg b/public/plaza-covers/business-e09bef.jpg
new file mode 100644
index 0000000..80de088
Binary files /dev/null and b/public/plaza-covers/business-e09bef.jpg differ
diff --git a/public/plaza-covers/business-nfc9ph.jpg b/public/plaza-covers/business-nfc9ph.jpg
new file mode 100644
index 0000000..27b08b0
Binary files /dev/null and b/public/plaza-covers/business-nfc9ph.jpg differ
diff --git a/public/plaza-covers/business-pilates.jpg b/public/plaza-covers/business-pilates.jpg
new file mode 100644
index 0000000..e7ad1cc
Binary files /dev/null and b/public/plaza-covers/business-pilates.jpg differ
diff --git a/public/plaza-covers/business-sop-33nw2t.jpg b/public/plaza-covers/business-sop-33nw2t.jpg
new file mode 100644
index 0000000..27b08b0
Binary files /dev/null and b/public/plaza-covers/business-sop-33nw2t.jpg differ
diff --git a/public/plaza-covers/business-zfht2z.jpg b/public/plaza-covers/business-zfht2z.jpg
new file mode 100644
index 0000000..2da05d3
Binary files /dev/null and b/public/plaza-covers/business-zfht2z.jpg differ
diff --git a/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg b/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg
new file mode 100644
index 0000000..27b08b0
Binary files /dev/null and b/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg differ
diff --git a/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg b/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg
new file mode 100644
index 0000000..80de088
Binary files /dev/null and b/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg differ
diff --git a/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg b/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg
new file mode 100644
index 0000000..2da05d3
Binary files /dev/null and b/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg differ
diff --git a/public/plaza-covers/business-独立书店文创周边选品策略.jpg b/public/plaza-covers/business-独立书店文创周边选品策略.jpg
new file mode 100644
index 0000000..76d994a
Binary files /dev/null and b/public/plaza-covers/business-独立书店文创周边选品策略.jpg differ
diff --git a/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg b/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg
new file mode 100644
index 0000000..27b08b0
Binary files /dev/null and b/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg differ
diff --git a/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg b/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg
new file mode 100644
index 0000000..a22930d
Binary files /dev/null and b/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg differ
diff --git a/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg b/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg
new file mode 100644
index 0000000..80de088
Binary files /dev/null and b/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg differ
diff --git a/public/plaza-covers/creative-17bb2q5.jpg b/public/plaza-covers/creative-17bb2q5.jpg
new file mode 100644
index 0000000..3330d02
Binary files /dev/null and b/public/plaza-covers/creative-17bb2q5.jpg differ
diff --git a/public/plaza-covers/creative-1a69un3.jpg b/public/plaza-covers/creative-1a69un3.jpg
new file mode 100644
index 0000000..dbf1769
Binary files /dev/null and b/public/plaza-covers/creative-1a69un3.jpg differ
diff --git a/public/plaza-covers/creative-1wuxajv.jpg b/public/plaza-covers/creative-1wuxajv.jpg
new file mode 100644
index 0000000..1e93c59
Binary files /dev/null and b/public/plaza-covers/creative-1wuxajv.jpg differ
diff --git a/public/plaza-covers/creative-ai-1m59cjr.jpg b/public/plaza-covers/creative-ai-1m59cjr.jpg
new file mode 100644
index 0000000..dbf1769
Binary files /dev/null and b/public/plaza-covers/creative-ai-1m59cjr.jpg differ
diff --git a/public/plaza-covers/creative-ceramics-1o5zmj5.jpg b/public/plaza-covers/creative-ceramics-1o5zmj5.jpg
new file mode 100644
index 0000000..495339d
Binary files /dev/null and b/public/plaza-covers/creative-ceramics-1o5zmj5.jpg differ
diff --git a/public/plaza-covers/creative-cyber-city.jpg b/public/plaza-covers/creative-cyber-city.jpg
new file mode 100644
index 0000000..980e792
Binary files /dev/null and b/public/plaza-covers/creative-cyber-city.jpg differ
diff --git a/public/plaza-covers/creative-music-ep.jpg b/public/plaza-covers/creative-music-ep.jpg
new file mode 100644
index 0000000..980e792
Binary files /dev/null and b/public/plaza-covers/creative-music-ep.jpg differ
diff --git a/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg b/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg
new file mode 100644
index 0000000..3330d02
Binary files /dev/null and b/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg differ
diff --git a/public/plaza-covers/creative-vis-tkrnir.jpg b/public/plaza-covers/creative-vis-tkrnir.jpg
new file mode 100644
index 0000000..1e93c59
Binary files /dev/null and b/public/plaza-covers/creative-vis-tkrnir.jpg differ
diff --git a/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg b/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg
new file mode 100644
index 0000000..1e93c59
Binary files /dev/null and b/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg differ
diff --git a/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg b/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg
new file mode 100644
index 0000000..3330d02
Binary files /dev/null and b/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg differ
diff --git a/public/plaza-covers/creative-复古胶片人像调色预设包.jpg b/public/plaza-covers/creative-复古胶片人像调色预设包.jpg
new file mode 100644
index 0000000..dbf1769
Binary files /dev/null and b/public/plaza-covers/creative-复古胶片人像调色预设包.jpg differ
diff --git a/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg b/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg
new file mode 100644
index 0000000..495339d
Binary files /dev/null and b/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg differ
diff --git a/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg b/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg
new file mode 100644
index 0000000..3330d02
Binary files /dev/null and b/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg differ
diff --git a/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg b/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg
new file mode 100644
index 0000000..dbf1769
Binary files /dev/null and b/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg differ
diff --git a/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg b/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg
new file mode 100644
index 0000000..1e93c59
Binary files /dev/null and b/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg differ
diff --git a/public/plaza-covers/data-analysis-1293jxp.jpg b/public/plaza-covers/data-analysis-1293jxp.jpg
new file mode 100644
index 0000000..1b25426
Binary files /dev/null and b/public/plaza-covers/data-analysis-1293jxp.jpg differ
diff --git a/public/plaza-covers/data-analysis-a-b-1slrtg.jpg b/public/plaza-covers/data-analysis-a-b-1slrtg.jpg
new file mode 100644
index 0000000..0969588
Binary files /dev/null and b/public/plaza-covers/data-analysis-a-b-1slrtg.jpg differ
diff --git a/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg b/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg
new file mode 100644
index 0000000..0969588
Binary files /dev/null and b/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg differ
diff --git a/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg b/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg differ
diff --git a/public/plaza-covers/data-analysis-cohort.jpg b/public/plaza-covers/data-analysis-cohort.jpg
new file mode 100644
index 0000000..b5db113
Binary files /dev/null and b/public/plaza-covers/data-analysis-cohort.jpg differ
diff --git a/public/plaza-covers/data-analysis-g6tcgg.jpg b/public/plaza-covers/data-analysis-g6tcgg.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/data-analysis-g6tcgg.jpg differ
diff --git a/public/plaza-covers/data-analysis-gmv.jpg b/public/plaza-covers/data-analysis-gmv.jpg
new file mode 100644
index 0000000..5b146d7
Binary files /dev/null and b/public/plaza-covers/data-analysis-gmv.jpg differ
diff --git a/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg b/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg
new file mode 100644
index 0000000..a39faf3
Binary files /dev/null and b/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg differ
diff --git a/public/plaza-covers/data-analysis-sql-9sel2d.jpg b/public/plaza-covers/data-analysis-sql-9sel2d.jpg
new file mode 100644
index 0000000..1b25426
Binary files /dev/null and b/public/plaza-covers/data-analysis-sql-9sel2d.jpg differ
diff --git a/public/plaza-covers/data-analysis-ztijjg.jpg b/public/plaza-covers/data-analysis-ztijjg.jpg
new file mode 100644
index 0000000..0969588
Binary files /dev/null and b/public/plaza-covers/data-analysis-ztijjg.jpg differ
diff --git a/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg b/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg
new file mode 100644
index 0000000..1b25426
Binary files /dev/null and b/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg differ
diff --git a/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg b/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg differ
diff --git a/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg b/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg
new file mode 100644
index 0000000..1b25426
Binary files /dev/null and b/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg differ
diff --git a/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg b/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg
new file mode 100644
index 0000000..0969588
Binary files /dev/null and b/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg differ
diff --git a/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg b/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg
new file mode 100644
index 0000000..a39faf3
Binary files /dev/null and b/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg differ
diff --git a/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg b/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg differ
diff --git a/public/plaza-covers/lifestyle-100-40-l2vjup.jpg b/public/plaza-covers/lifestyle-100-40-l2vjup.jpg
new file mode 100644
index 0000000..e2e61e9
Binary files /dev/null and b/public/plaza-covers/lifestyle-100-40-l2vjup.jpg differ
diff --git a/public/plaza-covers/lifestyle-1o3s87z.jpg b/public/plaza-covers/lifestyle-1o3s87z.jpg
new file mode 100644
index 0000000..e9f9bda
Binary files /dev/null and b/public/plaza-covers/lifestyle-1o3s87z.jpg differ
diff --git a/public/plaza-covers/lifestyle-1wfbcos.jpg b/public/plaza-covers/lifestyle-1wfbcos.jpg
new file mode 100644
index 0000000..f73669e
Binary files /dev/null and b/public/plaza-covers/lifestyle-1wfbcos.jpg differ
diff --git a/public/plaza-covers/lifestyle-30-173irnz.jpg b/public/plaza-covers/lifestyle-30-173irnz.jpg
new file mode 100644
index 0000000..e9f9bda
Binary files /dev/null and b/public/plaza-covers/lifestyle-30-173irnz.jpg differ
diff --git a/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg b/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg
new file mode 100644
index 0000000..e9f9bda
Binary files /dev/null and b/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg differ
diff --git a/public/plaza-covers/lifestyle-7-1cwt66m.jpg b/public/plaza-covers/lifestyle-7-1cwt66m.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/public/plaza-covers/lifestyle-7-1cwt66m.jpg differ
diff --git a/public/plaza-covers/lifestyle-desk.jpg b/public/plaza-covers/lifestyle-desk.jpg
new file mode 100644
index 0000000..8599dcc
Binary files /dev/null and b/public/plaza-covers/lifestyle-desk.jpg differ
diff --git a/public/plaza-covers/lifestyle-m33c9i.jpg b/public/plaza-covers/lifestyle-m33c9i.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/public/plaza-covers/lifestyle-m33c9i.jpg differ
diff --git a/public/plaza-covers/lifestyle-o90i4e.jpg b/public/plaza-covers/lifestyle-o90i4e.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/public/plaza-covers/lifestyle-o90i4e.jpg differ
diff --git a/public/plaza-covers/lifestyle-writing.jpg b/public/plaza-covers/lifestyle-writing.jpg
new file mode 100644
index 0000000..8599dcc
Binary files /dev/null and b/public/plaza-covers/lifestyle-writing.jpg differ
diff --git a/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg b/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg
new file mode 100644
index 0000000..f73669e
Binary files /dev/null and b/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg differ
diff --git a/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg b/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg differ
diff --git a/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg b/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg
new file mode 100644
index 0000000..e9f9bda
Binary files /dev/null and b/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg differ
diff --git a/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg b/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg differ
diff --git a/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg b/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg
new file mode 100644
index 0000000..e2e61e9
Binary files /dev/null and b/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg differ
diff --git a/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg b/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg differ
diff --git a/public/plaza-covers/other-0-1000-35irqz.jpg b/public/plaza-covers/other-0-1000-35irqz.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/public/plaza-covers/other-0-1000-35irqz.jpg differ
diff --git a/public/plaza-covers/other-176h7rn.jpg b/public/plaza-covers/other-176h7rn.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/public/plaza-covers/other-176h7rn.jpg differ
diff --git a/public/plaza-covers/other-187ouof.jpg b/public/plaza-covers/other-187ouof.jpg
new file mode 100644
index 0000000..3c2e59b
Binary files /dev/null and b/public/plaza-covers/other-187ouof.jpg differ
diff --git a/public/plaza-covers/other-1nd2tz4.jpg b/public/plaza-covers/other-1nd2tz4.jpg
new file mode 100644
index 0000000..5dca65c
Binary files /dev/null and b/public/plaza-covers/other-1nd2tz4.jpg differ
diff --git a/public/plaza-covers/other-20-1de41cz.jpg b/public/plaza-covers/other-20-1de41cz.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/public/plaza-covers/other-20-1de41cz.jpg differ
diff --git a/public/plaza-covers/other-opensource.jpg b/public/plaza-covers/other-opensource.jpg
new file mode 100644
index 0000000..21dfb28
Binary files /dev/null and b/public/plaza-covers/other-opensource.jpg differ
diff --git a/public/plaza-covers/other-vr-60-1yt52pj.jpg b/public/plaza-covers/other-vr-60-1yt52pj.jpg
new file mode 100644
index 0000000..3c2e59b
Binary files /dev/null and b/public/plaza-covers/other-vr-60-1yt52pj.jpg differ
diff --git a/public/plaza-covers/other-vr-健身-60-天体验报告.jpg b/public/plaza-covers/other-vr-健身-60-天体验报告.jpg
new file mode 100644
index 0000000..3c2e59b
Binary files /dev/null and b/public/plaza-covers/other-vr-健身-60-天体验报告.jpg differ
diff --git a/public/plaza-covers/other-wishlist.jpg b/public/plaza-covers/other-wishlist.jpg
new file mode 100644
index 0000000..6bef7a3
Binary files /dev/null and b/public/plaza-covers/other-wishlist.jpg differ
diff --git a/public/plaza-covers/other-xu49pe.jpg b/public/plaza-covers/other-xu49pe.jpg
new file mode 100644
index 0000000..15cbd88
Binary files /dev/null and b/public/plaza-covers/other-xu49pe.jpg differ
diff --git a/public/plaza-covers/other-个人知识库搭建方法论.jpg b/public/plaza-covers/other-个人知识库搭建方法论.jpg
new file mode 100644
index 0000000..3c2e59b
Binary files /dev/null and b/public/plaza-covers/other-个人知识库搭建方法论.jpg differ
diff --git a/public/plaza-covers/other-二手交易向可持续生活转型.jpg b/public/plaza-covers/other-二手交易向可持续生活转型.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/public/plaza-covers/other-二手交易向可持续生活转型.jpg differ
diff --git a/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg b/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg differ
diff --git a/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg b/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg differ
diff --git a/public/plaza-covers/other-社群运营-从群聊到价值观.jpg b/public/plaza-covers/other-社群运营-从群聊到价值观.jpg
new file mode 100644
index 0000000..15cbd88
Binary files /dev/null and b/public/plaza-covers/other-社群运营-从群聊到价值观.jpg differ
diff --git a/public/plaza-covers/other-自由职业者财务规划入门.jpg b/public/plaza-covers/other-自由职业者财务规划入门.jpg
new file mode 100644
index 0000000..5dca65c
Binary files /dev/null and b/public/plaza-covers/other-自由职业者财务规划入门.jpg differ
diff --git a/public/plaza-covers/study-notes-6owsyr.jpg b/public/plaza-covers/study-notes-6owsyr.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-6owsyr.jpg differ
diff --git a/public/plaza-covers/study-notes-docker-1adiwh7.jpg b/public/plaza-covers/study-notes-docker-1adiwh7.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-docker-1adiwh7.jpg differ
diff --git a/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg b/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg differ
diff --git a/public/plaza-covers/study-notes-fobvok.jpg b/public/plaza-covers/study-notes-fobvok.jpg
new file mode 100644
index 0000000..15cbd88
Binary files /dev/null and b/public/plaza-covers/study-notes-fobvok.jpg differ
diff --git a/public/plaza-covers/study-notes-git-code-review-1avjty.jpg b/public/plaza-covers/study-notes-git-code-review-1avjty.jpg
new file mode 100644
index 0000000..edd5abf
Binary files /dev/null and b/public/plaza-covers/study-notes-git-code-review-1avjty.jpg differ
diff --git a/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg b/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg
new file mode 100644
index 0000000..edd5abf
Binary files /dev/null and b/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg differ
diff --git a/public/plaza-covers/study-notes-llm.jpg b/public/plaza-covers/study-notes-llm.jpg
new file mode 100644
index 0000000..32c7d0c
Binary files /dev/null and b/public/plaza-covers/study-notes-llm.jpg differ
diff --git a/public/plaza-covers/study-notes-pandas-1mg4mom.jpg b/public/plaza-covers/study-notes-pandas-1mg4mom.jpg
new file mode 100644
index 0000000..edd5abf
Binary files /dev/null and b/public/plaza-covers/study-notes-pandas-1mg4mom.jpg differ
diff --git a/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg b/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg
new file mode 100644
index 0000000..edd5abf
Binary files /dev/null and b/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg differ
diff --git a/public/plaza-covers/study-notes-pca-54364r.jpg b/public/plaza-covers/study-notes-pca-54364r.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-pca-54364r.jpg differ
diff --git a/public/plaza-covers/study-notes-rust.jpg b/public/plaza-covers/study-notes-rust.jpg
new file mode 100644
index 0000000..3fa0a55
Binary files /dev/null and b/public/plaza-covers/study-notes-rust.jpg differ
diff --git a/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg b/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg differ
diff --git a/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg b/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg differ
diff --git a/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg b/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg
new file mode 100644
index 0000000..15cbd88
Binary files /dev/null and b/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg differ
diff --git a/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg b/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg differ
diff --git a/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg b/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg differ
diff --git a/public/plaza-covers/travel-10-ap683v.jpg b/public/plaza-covers/travel-10-ap683v.jpg
new file mode 100644
index 0000000..d7437c5
Binary files /dev/null and b/public/plaza-covers/travel-10-ap683v.jpg differ
diff --git a/public/plaza-covers/travel-15qb47w.jpg b/public/plaza-covers/travel-15qb47w.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/public/plaza-covers/travel-15qb47w.jpg differ
diff --git a/public/plaza-covers/travel-3-1lbpfse.jpg b/public/plaza-covers/travel-3-1lbpfse.jpg
new file mode 100644
index 0000000..f5e5f1f
Binary files /dev/null and b/public/plaza-covers/travel-3-1lbpfse.jpg differ
diff --git a/public/plaza-covers/travel-4-70oznz.jpg b/public/plaza-covers/travel-4-70oznz.jpg
new file mode 100644
index 0000000..4490c59
Binary files /dev/null and b/public/plaza-covers/travel-4-70oznz.jpg differ
diff --git a/public/plaza-covers/travel-48-p5s92m.jpg b/public/plaza-covers/travel-48-p5s92m.jpg
new file mode 100644
index 0000000..f5e5f1f
Binary files /dev/null and b/public/plaza-covers/travel-48-p5s92m.jpg differ
diff --git a/public/plaza-covers/travel-6-1hwz8po.jpg b/public/plaza-covers/travel-6-1hwz8po.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/public/plaza-covers/travel-6-1hwz8po.jpg differ
diff --git a/public/plaza-covers/travel-fjj9j0.jpg b/public/plaza-covers/travel-fjj9j0.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/public/plaza-covers/travel-fjj9j0.jpg differ
diff --git a/public/plaza-covers/travel-kyoto.jpg b/public/plaza-covers/travel-kyoto.jpg
new file mode 100644
index 0000000..2bd1d1d
Binary files /dev/null and b/public/plaza-covers/travel-kyoto.jpg differ
diff --git a/public/plaza-covers/travel-malaysia.jpg b/public/plaza-covers/travel-malaysia.jpg
new file mode 100644
index 0000000..2bd1d1d
Binary files /dev/null and b/public/plaza-covers/travel-malaysia.jpg differ
diff --git a/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg b/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg differ
diff --git a/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg b/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg
new file mode 100644
index 0000000..d7437c5
Binary files /dev/null and b/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg differ
diff --git a/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg b/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg
new file mode 100644
index 0000000..f5e5f1f
Binary files /dev/null and b/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg differ
diff --git a/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg b/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg
new file mode 100644
index 0000000..4490c59
Binary files /dev/null and b/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg differ
diff --git a/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg b/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg differ
diff --git a/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg b/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg
new file mode 100644
index 0000000..f5e5f1f
Binary files /dev/null and b/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg differ
diff --git a/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg b/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg differ
diff --git a/public/plaza-covers/work-report-1y4i5h1.jpg b/public/plaza-covers/work-report-1y4i5h1.jpg
new file mode 100644
index 0000000..206f41a
Binary files /dev/null and b/public/plaza-covers/work-report-1y4i5h1.jpg differ
diff --git a/public/plaza-covers/work-report-2025-1fz5i3.jpg b/public/plaza-covers/work-report-2025-1fz5i3.jpg
new file mode 100644
index 0000000..884182f
Binary files /dev/null and b/public/plaza-covers/work-report-2025-1fz5i3.jpg differ
diff --git a/public/plaza-covers/work-report-72ek9j.jpg b/public/plaza-covers/work-report-72ek9j.jpg
new file mode 100644
index 0000000..00bf9c0
Binary files /dev/null and b/public/plaza-covers/work-report-72ek9j.jpg differ
diff --git a/public/plaza-covers/work-report-ai-service.jpg b/public/plaza-covers/work-report-ai-service.jpg
new file mode 100644
index 0000000..dc1d298
Binary files /dev/null and b/public/plaza-covers/work-report-ai-service.jpg differ
diff --git a/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg b/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg
new file mode 100644
index 0000000..884182f
Binary files /dev/null and b/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg differ
diff --git a/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg b/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg
new file mode 100644
index 0000000..884182f
Binary files /dev/null and b/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg differ
diff --git a/public/plaza-covers/work-report-disney.jpg b/public/plaza-covers/work-report-disney.jpg
new file mode 100644
index 0000000..49f866d
Binary files /dev/null and b/public/plaza-covers/work-report-disney.jpg differ
diff --git a/public/plaza-covers/work-report-explore.jpg b/public/plaza-covers/work-report-explore.jpg
new file mode 100644
index 0000000..ef2d817
Binary files /dev/null and b/public/plaza-covers/work-report-explore.jpg differ
diff --git a/public/plaza-covers/work-report-growth.jpg b/public/plaza-covers/work-report-growth.jpg
new file mode 100644
index 0000000..d8959b2
Binary files /dev/null and b/public/plaza-covers/work-report-growth.jpg differ
diff --git a/public/plaza-covers/work-report-hello.jpg b/public/plaza-covers/work-report-hello.jpg
new file mode 100644
index 0000000..5c69410
Binary files /dev/null and b/public/plaza-covers/work-report-hello.jpg differ
diff --git a/public/plaza-covers/work-report-mindspace-qwuy2q.jpg b/public/plaza-covers/work-report-mindspace-qwuy2q.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/work-report-mindspace-qwuy2q.jpg differ
diff --git a/public/plaza-covers/work-report-mindspace-公开发布指南.jpg b/public/plaza-covers/work-report-mindspace-公开发布指南.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/work-report-mindspace-公开发布指南.jpg differ
diff --git a/public/plaza-covers/work-report-okr-ivflp3.jpg b/public/plaza-covers/work-report-okr-ivflp3.jpg
new file mode 100644
index 0000000..00bf9c0
Binary files /dev/null and b/public/plaza-covers/work-report-okr-ivflp3.jpg differ
diff --git a/public/plaza-covers/work-report-q3-1v4dgld.jpg b/public/plaza-covers/work-report-q3-1v4dgld.jpg
new file mode 100644
index 0000000..509e7ba
Binary files /dev/null and b/public/plaza-covers/work-report-q3-1v4dgld.jpg differ
diff --git a/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg b/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg differ
diff --git a/public/plaza-covers/work-report-supply-chain.jpg b/public/plaza-covers/work-report-supply-chain.jpg
new file mode 100644
index 0000000..1782e04
Binary files /dev/null and b/public/plaza-covers/work-report-supply-chain.jpg differ
diff --git a/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg b/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg
new file mode 100644
index 0000000..00bf9c0
Binary files /dev/null and b/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg differ
diff --git a/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg b/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg
new file mode 100644
index 0000000..00bf9c0
Binary files /dev/null and b/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg differ
diff --git a/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg b/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg differ
diff --git a/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg b/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg
new file mode 100644
index 0000000..206f41a
Binary files /dev/null and b/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg differ
diff --git a/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg b/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg
new file mode 100644
index 0000000..884182f
Binary files /dev/null and b/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg differ
diff --git a/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg b/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg
new file mode 100644
index 0000000..509e7ba
Binary files /dev/null and b/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg differ