diff --git a/.env.example b/.env.example index 846efc0..36ccdee 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,11 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # Tool Gateway Queue v0(/agent/runs 后台任务队列,默认保守限流)。 # MEMIND_AGENT_RUN_QUEUE_CONCURRENCY=1 # MEMIND_AGENT_RUN_TIMEOUT_MS=900000 +# P6.1 外部 Tool Worker 接管入口。默认 Portal 内部 auto-dispatch 保持开启; +# 只有外部 worker 验证就绪后,才把 Portal 设为 0 并独立运行 scripts/agent-run-worker.mjs。 +# MEMIND_AGENT_RUN_AUTODISPATCH=1 +# MEMIND_AGENT_RUN_WORKER_POLL_MS=1000 +# MEMIND_AGENT_RUN_WORKER_BATCH_SIZE=1 # ===== memind_adm(独立后台服务)===== # 平台超管 /admin-api/* 与广场运营 /api/ops/v1/* 已从公网服务拆出,独立进程运行。 diff --git a/.runtime/portal/RUNBOOK.txt b/.runtime/portal/RUNBOOK.txt index 343ae83..c260cad 100644 --- a/.runtime/portal/RUNBOOK.txt +++ b/.runtime/portal/RUNBOOK.txt @@ -47,3 +47,5 @@ Streaming runtime operations: node scripts/check-tool-runtime.mjs node scripts/check-agent-code-run-entry.mjs curl -sk https://mm.tkmind.cn/api/runtime/status # includes toolRuntime.queue + node scripts/agent-run-worker.mjs --status + node scripts/agent-run-worker.mjs --once diff --git a/.runtime/portal/scripts/agent-run-worker.mjs b/.runtime/portal/scripts/agent-run-worker.mjs new file mode 100755 index 0000000..0ca5127 --- /dev/null +++ b/.runtime/portal/scripts/agent-run-worker.mjs @@ -0,0 +1,6873 @@ +#!/usr/bin/env node +import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url); + +// scripts/agent-run-worker.mjs +import fs7 from "node:fs"; +import path10 from "node:path"; +import { fileURLToPath as fileURLToPath5 } from "node:url"; + +// agent-run-gateway.mjs +import crypto from "node:crypto"; +var DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5e3, 15e3]; +var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["succeeded", "failed"]); +var CODE_TOOL_MODES = /* @__PURE__ */ new Set(["code", "code-task", "code_task", "code-tool", "code_tool", "code_tool_task"]); +var RUN_METADATA_KEY = "memindRun"; +var DEFAULT_MAX_CONCURRENT_RUNS = 1; +var DEFAULT_RUN_TIMEOUT_MS = 15 * 60 * 1e3; +function nowMs() { + return Date.now(); +} +function safeJsonParse(value, fallback = null) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} +function serializeMessage(message) { + return JSON.stringify(message ?? {}); +} +function positiveInteger(value, fallback) { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.floor(n); +} +function timeoutError(ms) { + const err = new Error(`agent run timed out after ${ms}ms`); + err.code = "AGENT_RUN_TIMEOUT"; + return err; +} +function envFlag(value, fallback = false) { + const raw = String(value ?? "").trim().toLowerCase(); + if (!raw) return fallback; + return ["1", "true", "yes", "on"].includes(raw); +} +function normalizeAgentRunToolMode(value) { + const normalized = String(value ?? "chat").trim().toLowerCase(); + if (!normalized || normalized === "chat") return "chat"; + if (CODE_TOOL_MODES.has(normalized)) return "code"; + throw new Error(`\u4E0D\u652F\u6301\u7684 tool_mode: ${value}`); +} +function normalizeTaskType(value) { + const normalized = String(value ?? "").trim(); + return normalized || null; +} +function withRunMetadata(userMessage, { toolMode = "chat", taskType = null } = {}) { + const message = userMessage && typeof userMessage === "object" && !Array.isArray(userMessage) ? { ...userMessage } : { value: userMessage }; + const metadata = message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata) ? { ...message.metadata } : {}; + metadata[RUN_METADATA_KEY] = { + ...metadata[RUN_METADATA_KEY] && typeof metadata[RUN_METADATA_KEY] === "object" ? metadata[RUN_METADATA_KEY] : {}, + toolMode: normalizeAgentRunToolMode(toolMode), + ...taskType ? { taskType } : {} + }; + return { ...message, metadata }; +} +function getRunOptionsFromMessage(userMessage) { + const metadata = userMessage?.metadata; + const runMetadata = metadata?.[RUN_METADATA_KEY] ?? metadata?.agentRun ?? {}; + let toolMode = "chat"; + try { + toolMode = normalizeAgentRunToolMode(runMetadata?.toolMode ?? metadata?.toolMode ?? "chat"); + } catch { + toolMode = "chat"; + } + return { + toolMode, + taskType: normalizeTaskType(runMetadata?.taskType ?? metadata?.taskType) + }; +} +function projectRun(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + sessionId: row.agent_session_id ?? null, + requestId: row.request_id, + status: row.status, + attempts: Number(row.attempts ?? 0), + error: row.error_message ?? null, + createdAt: Number(row.created_at ?? 0), + updatedAt: Number(row.updated_at ?? 0), + startedAt: row.started_at == null ? null : Number(row.started_at), + completedAt: row.completed_at == null ? null : Number(row.completed_at) + }; +} +function createAgentRunGateway({ + pool: pool2, + userAuth: userAuth2, + tkmindProxy: tkmindProxy2, + retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS, + autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true), + maxConcurrentRuns = positiveInteger( + process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY, + DEFAULT_MAX_CONCURRENT_RUNS + ), + runTimeoutMs = positiveInteger( + process.env.MEMIND_AGENT_RUN_TIMEOUT_MS, + DEFAULT_RUN_TIMEOUT_MS + ) +}) { + const inFlight = /* @__PURE__ */ new Set(); + const queuedDispatches = []; + const queuedDispatchSet = /* @__PURE__ */ new Set(); + function enqueueRun(runId) { + if (!runId || inFlight.has(runId) || queuedDispatchSet.has(runId)) return false; + queuedDispatchSet.add(runId); + queuedDispatches.push(runId); + return true; + } + function nextQueuedRunId() { + const runId = queuedDispatches.shift(); + if (runId) queuedDispatchSet.delete(runId); + return runId ?? null; + } + async function appendEvent(runId, type, data = null) { + await pool2.query( + `INSERT INTO h5_agent_run_events (id, run_id, event_type, data_json, created_at) + VALUES (?, ?, ?, ?, ?)`, + [ + crypto.randomUUID(), + runId, + type, + data == null ? null : JSON.stringify(data), + nowMs() + ] + ); + } + async function getRunById(runId) { + const [rows] = await pool2.query( + `SELECT * FROM h5_agent_runs WHERE id = ? LIMIT 1`, + [runId] + ); + return rows[0] ?? null; + } + async function getRunForUser(userId, runId) { + const [rows] = await pool2.query( + `SELECT * FROM h5_agent_runs WHERE id = ? AND user_id = ? LIMIT 1`, + [runId, userId] + ); + return projectRun(rows[0] ?? null); + } + async function getRunByRequest(userId, requestId) { + const [rows] = await pool2.query( + `SELECT * FROM h5_agent_runs WHERE user_id = ? AND request_id = ? LIMIT 1`, + [userId, requestId] + ); + return rows[0] ?? null; + } + async function createRun(userId, { + sessionId = null, + requestId, + userMessage, + toolMode = "chat", + taskType = null + }) { + const normalizedRequestId = String(requestId ?? "").trim(); + if (!normalizedRequestId) { + throw new Error("\u7F3A\u5C11 request_id"); + } + const normalizedToolMode = normalizeAgentRunToolMode(toolMode); + const normalizedTaskType = normalizeTaskType(taskType); + const existing = await getRunByRequest(userId, normalizedRequestId); + if (existing) { + if (autoDispatch && !TERMINAL_STATUSES.has(existing.status)) dispatchRun(existing.id); + return projectRun(existing); + } + const runId = crypto.randomUUID(); + const createdAt = nowMs(); + const runMessage = withRunMetadata(userMessage, { + toolMode: normalizedToolMode, + taskType: normalizedTaskType + }); + await pool2.query( + `INSERT INTO h5_agent_runs + (id, user_id, agent_session_id, request_id, status, attempts, + user_message_json, error_message, created_at, updated_at, started_at, completed_at) + VALUES (?, ?, ?, ?, 'queued', 0, ?, NULL, ?, ?, NULL, NULL)`, + [ + runId, + userId, + sessionId || null, + normalizedRequestId, + serializeMessage(runMessage), + createdAt, + createdAt + ] + ); + await appendEvent(runId, "queued", { + sessionId: sessionId || null, + toolMode: normalizedToolMode, + taskType: normalizedTaskType + }); + if (autoDispatch) dispatchRun(runId); + return projectRun(await getRunById(runId)); + } + async function markRun(runId, status, fields = {}) { + const updates = ["status = ?", "updated_at = ?"]; + const values = [status, nowMs()]; + for (const [key, value] of Object.entries(fields)) { + updates.push(`${key} = ?`); + values.push(value); + } + values.push(runId); + await pool2.query( + `UPDATE h5_agent_runs SET ${updates.join(", ")} WHERE id = ?`, + values + ); + await appendEvent(runId, status, fields); + } + async function runWithTimeout(runId, task) { + let timer = null; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(timeoutError(runTimeoutMs)), runTimeoutMs); + }); + try { + return await Promise.race([task(), timeout]); + } finally { + if (timer) clearTimeout(timer); + } + } + async function executeRun(row, runId) { + const userMessage = safeJsonParse(row.user_message_json, {}); + const runOptions = getRunOptionsFromMessage(userMessage); + let sessionId = row.agent_session_id ?? null; + if (!sessionId) { + const sessionOptions = {}; + if (runOptions.toolMode === "code" && userAuth2?.getCodeAgentSessionPolicy) { + sessionOptions.sessionPolicy = await userAuth2.getCodeAgentSessionPolicy(row.user_id); + } + const session = await tkmindProxy2.startSessionForUser(row.user_id, sessionOptions); + sessionId = session.id; + await pool2.query( + `UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`, + [sessionId, nowMs(), runId] + ); + await appendEvent(runId, "session_started", { + sessionId, + toolMode: runOptions.toolMode, + taskType: runOptions.taskType + }); + } + await tkmindProxy2.submitSessionReplyForUser( + row.user_id, + sessionId, + row.request_id, + userMessage, + { toolMode: runOptions.toolMode } + ); + return { sessionId }; + } + async function processRun(runId) { + const row = await getRunById(runId); + if (!row || TERMINAL_STATUSES.has(row.status)) return; + const nextAttempt = Number(row.attempts ?? 0) + 1; + const [claim] = await pool2.query( + `UPDATE h5_agent_runs + SET status = 'running', attempts = ?, started_at = COALESCE(started_at, ?), updated_at = ?, error_message = NULL + WHERE id = ? AND status IN ('queued', 'retryable')`, + [nextAttempt, nowMs(), nowMs(), runId] + ); + if (Number(claim?.affectedRows ?? 0) === 0) return; + await appendEvent(runId, "running", { attempt: nextAttempt }); + try { + const { sessionId } = await runWithTimeout(runId, () => executeRun(row, runId)); + await markRun(runId, "succeeded", { + agent_session_id: sessionId, + completed_at: nowMs() + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const timedOut = err?.code === "AGENT_RUN_TIMEOUT"; + if (timedOut) { + await appendEvent(runId, "timeout", { timeoutMs: runTimeoutMs }); + } + const retryable = !timedOut && nextAttempt < retryDelaysMs.length; + await markRun(runId, retryable ? "retryable" : "failed", { + error_message: message, + completed_at: retryable ? null : nowMs() + }); + if (retryable) { + setTimeout(() => dispatchRun(runId), retryDelaysMs[nextAttempt - 1]); + } + } + } + function drainQueue() { + while (inFlight.size < maxConcurrentRuns) { + const runId = nextQueuedRunId(); + if (!runId) break; + inFlight.add(runId); + void processRun(runId).finally(() => { + inFlight.delete(runId); + drainQueue(); + }); + } + } + function dispatchRun(runId) { + if (!enqueueRun(runId)) return; + drainQueue(); + } + async function getQueueStatus() { + const [rows] = await pool2.query( + `SELECT status, COUNT(*) AS count + FROM h5_agent_runs + WHERE status IN ('queued', 'running', 'retryable') + GROUP BY status` + ); + const statusCounts = {}; + for (const row of rows) { + statusCounts[row.status] = Number(row.count ?? 0); + } + return { + autoDispatch, + maxConcurrentRuns, + runTimeoutMs, + inFlight: inFlight.size, + pendingDispatches: queuedDispatches.length, + statusCounts, + terminalStatuses: [...TERMINAL_STATUSES] + }; + } + async function dispatchQueuedRuns({ limit = maxConcurrentRuns } = {}) { + const dispatchLimit = Math.max(1, Number(limit) || maxConcurrentRuns); + const available = Math.max(0, maxConcurrentRuns - inFlight.size - queuedDispatches.length); + if (available <= 0) { + return { + considered: 0, + dispatched: 0, + queue: await getQueueStatus() + }; + } + const take = Math.min(dispatchLimit, available); + const [rows] = await pool2.query( + `SELECT id + FROM h5_agent_runs + WHERE status IN ('queued', 'retryable') + ORDER BY updated_at ASC + LIMIT ?`, + [take] + ); + for (const row of rows) { + dispatchRun(row.id); + } + return { + considered: rows.length, + dispatched: rows.length, + queue: await getQueueStatus() + }; + } + return { + createRun, + getRunForUser, + dispatchRun, + getQueueStatus, + dispatchQueuedRuns + }; +} + +// db.mjs +import mysql from "mysql2/promise"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +// mindspace.mjs +import crypto2 from "node:crypto"; +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: "public", + name: "\u516C\u5F00\u533A", + visibilityPolicy: "public_candidate", + aiAccessPolicy: "selected_assets", + publishPolicy: "security_scan_required", + sortOrder: 20 + }, + { + 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 + } +]); +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; +} + +// 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 resolvePoolOptions() { + const connectionLimit = Math.max(1, Number(process.env.MYSQL_POOL_SIZE ?? 50)); + const queueLimit = Math.max(0, Number(process.env.MYSQL_POOL_QUEUE_LIMIT ?? 0)); + return { waitForConnections: true, connectionLimit, queueLimit }; +} +function createDbPool() { + if (!isDatabaseConfigured()) { + throw new Error("MySQL \u672A\u914D\u7F6E\uFF0C\u8BF7\u8BBE\u7F6E DATABASE_URL \u6216 MYSQL_* \u73AF\u5883\u53D8\u91CF"); + } + const poolOptions = resolvePoolOptions(); + if (process.env.DATABASE_URL) { + return mysql.createPool({ uri: process.env.DATABASE_URL, ...poolOptions }); + } + 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", + ...poolOptions + }); +} + +// tkmind-proxy.mjs +import fs3 from "node:fs"; +import path6 from "node:path"; +import { Readable, Transform as Transform2, Writable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +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 finishBillingKey(event) { + const requestId = event.request_id ?? event.chat_request_id; + if (requestId) return String(requestId); + const state = event.token_state ?? {}; + const input = state.accumulatedInputTokens ?? state.accumulated_input_tokens ?? state.inputTokens ?? 0; + const output = state.accumulatedOutputTokens ?? state.accumulated_output_tokens ?? state.outputTokens ?? 0; + return `${input}:${output}`; +} +function createSseBillingTransform({ onFinish }) { + let buffer = ""; + const billedFinishKeys = /* @__PURE__ */ new Set(); + const billFinishOnce = (event) => { + if (event?.type !== "Finish" || !event.token_state) return; + const key = finishBillingKey(event); + if (billedFinishKeys.has(key)) return; + billedFinishKeys.add(key); + void onFinish(event).catch(() => { + }); + }; + return new Transform({ + transform(chunk, _encoding, callback) { + buffer += chunk.toString("utf8"); + 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 { + billFinishOnce(JSON.parse(dataLine)); + } catch { + } + } + callback(null, chunk); + }, + flush(callback) { + if (buffer.trim()) { + for (const event of parseSseChunk(`${buffer} + +`)) { + billFinishOnce(event); + } + } + 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: "POST", pattern: /^\/agent\/runs$/ }, + { method: "GET", pattern: /^\/agent\/runs\/[^/]+$/ }, + { method: "GET", pattern: /^\/agent\/runs\/[^/]+\/events$/ }, + { method: "GET", pattern: /^\/sessions$/ }, + { method: "GET", pattern: /^\/sessions\/[^/]+$/ }, + { method: "DELETE", pattern: /^\/sessions\/[^/]+$/ }, + { 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 path11 = String(pathname ?? ""); + return path11.startsWith("/mindspace/") || path11.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(overridePath) { + const normalized = String(overridePath ?? "").trim(); + if (normalized) return normalized; + return path2.join(path2.dirname(fileURLToPath2(import.meta.url)), "mindspace-sandbox-mcp.mjs"); +} +function resolveSandboxMcpNodeExecPath(overridePath) { + const normalized = String(overridePath ?? "").trim(); + if (normalized) return normalized; + return process.execPath; +} +var CAPABILITY_CATALOG = [ + { + key: "shell", + 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, toolMode = "chat" } = {}) { + 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: resolveSandboxMcpNodeExecPath(sandboxMcp.nodeExecPath), + // sandboxRoot passed as argv[2] so it works even if goosed doesn't forward envs + args: [sandboxMcp.serverPath, sandboxMcp.sandboxRoot], + // envs (goosed field name) as belt-and-suspenders backup + 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)); + } + } + const enableCodeExecutionExtension = /^(1|true|yes)$/i.test( + process.env.TKMIND_ENABLE_CODE_EXECUTION_EXTENSION ?? "" + ); + if (capabilities.code_sandbox && enableCodeExecutionExtension) { + extensions.push(makeExtension("platform", "code_execution", [])); + } + if (capabilities.chat_recall) { + 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", [])); + } + const codeToolMode = toolMode === "code" || toolMode === "code-task"; + if (codeToolMode && capabilities.aider) { + extensions.push({ + ...makeExtension("platform", "aider", []), + timeout_ms: Number(process.env.MEMIND_AIDER_TIMEOUT_MS ?? 6e5), + metadata: { runtime_scope: "code_tool_task" } + }); + } + if (codeToolMode && capabilities.openhands) { + extensions.push({ + ...makeExtension("platform", "openhands", []), + timeout_ms: Number(process.env.MEMIND_OPENHANDS_TIMEOUT_MS ?? 9e5), + metadata: { runtime_scope: "code_tool_task" } + }); + } + return { + extensionOverrides: extensions, + enableContextMemory: Boolean( + capabilities.context_memory || capabilities.chat_recall || capabilities.static_publish + ), + gooseMode: resolveAgentGooseMode(capabilities, policies) + }; +} +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 fs 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 DOCX_SKILL_NAME = "docx-generate"; +var PUBLISH_ROOT_DIR = "MindSpace"; +var PUBLIC_ZONE_DIR = "public"; +var PUBLISH_SKILL_DIR = path3.join(__dirname2, "skills", PUBLISH_SKILL_NAME); +var DOCX_SKILL_DIR = path3.join(__dirname2, "skills", DOCX_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 +- \u4EC5\u5728\u5F00\u573A\u6216\u7528\u6237\u6253\u62DB\u547C\u65F6\u4F7F\u7528\u65F6\u6BB5\u95EE\u5019\uFF0C\u4E14\u987B\u4E0E\u300CTKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6\u300D\u4E00\u81F4\uFF08\u5982\u300C${name}\uFF0C\u65E9\u4E0A\u597D\u300D\uFF09\uFF1B\u666E\u901A\u56DE\u590D\u76F4\u63A5\u4F5C\u7B54\uFF0C\u4E0D\u8981\u6BCF\u6761\u90FD\u52A0\u95EE\u5019\uFF1B\u7981\u6B62\u628A\u7528\u6237\u53EB\u4F5C TKMind +- \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://mm.tkmind.cn").replace(/\/$/, ""); +} +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 resolveGoosedSandboxRoot(h5Root, user, env = process.env) { + const canonicalRoot = String( + env.GOOSED_SANDBOX_PUBLISH_ROOT ?? env.MEMIND_SHARED_PUBLISH_ROOT ?? "" + ).trim(); + if (canonicalRoot) { + return path3.join(path3.resolve(canonicalRoot), resolvePublishKey(user)); + } + return resolvePublishDir(h5Root, user); +} +function resolveLegacyPublishDir(h5Root, user) { + if (!user?.username) return null; + return path3.join(h5Root, PUBLISH_ROOT_DIR, resolveUsernameSlug(user)); +} +function mergePublishTrees(sourceDir, targetDir) { + if (!fs.existsSync(sourceDir)) return; + for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) { + const from = path3.join(sourceDir, entry.name); + const to = path3.join(targetDir, entry.name); + if (entry.isDirectory()) { + fs.mkdirSync(to, { recursive: true }); + mergePublishTrees(from, to); + } else if (!fs.existsSync(to)) { + fs.copyFileSync(from, to); + } + } +} +function migrateUserPublishDir(h5Root, user, legacyUsersRoot = null) { + const target = resolvePublishDir(h5Root, user); + fs.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\`\u3001\`generate_docx\`\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. \u4E0B\u8F7D\u6309\u94AE\u7684\u76F8\u5BF9\u94FE\u63A5\u76EE\u6807\uFF08\u5982 \`public/report.docx\`\uFF09\u5FC5\u987B\u901A\u8FC7 \`generate_docx\` \u843D\u76D8\uFF0C\u4E14 basename \u4E0E HTML \u4E00\u81F4 +7. \u5B8C\u6210\u540E\u6309\u300C\u56DE\u590D\u683C\u5F0F\u300D\u8FD4\u56DE\u53EF\u70B9\u51FB\u94FE\u63A5 + +## \u4F34\u751F\u4E0B\u8F7D\u6587\u4EF6\uFF08\u5FC5\u987B\uFF09 + +- \u76F8\u5BF9\u8DEF\u5F84\u4E0B\u8F7D\u94FE\u63A5\uFF08\`.docx\` / \`.pdf\` \u7B49\uFF09\u5FC5\u987B\u5728 HTML \u540C\u76EE\u5F55\u6216\u5B50\u76EE\u5F55\u771F\u5B9E\u5B58\u5728 +- Word \u6587\u6863\u5FC5\u987B\u7528 sandbox-fs \u7684 \`generate_docx\` \u751F\u6210\uFF1B\u7981\u6B62\u7528 \`computercontroller\` / shell \u751F\u6210\u751F\u4EA7\u4E0B\u8F7D\u6587\u4EF6\u4F5C\u4E3A\u4EA4\u4ED8\u4F9D\u636E +- \u6539 HTML \u6587\u4EF6\u540D\u65F6\u540C\u6B65 rename/copy \u4F34\u751F\u6587\u4EF6\uFF1B\u4ECE \`oa/\` \u590D\u5236\u5230 \`public/\` \u518D\u94FE\u63A5 +- \u53EF\u8DD1 \`node scripts/check-mindspace-public-links.mjs\` \u81EA\u68C0 + +## Word \u4E0B\u8F7D\u9875\uFF08\u5FC5\u987B\uFF09 + +- \u5982\u679C\u7528\u6237\u660E\u786E\u8981\u6C42\u9875\u9762\u91CC**\u53EF\u4E0B\u8F7D Word / docx**\uFF0C\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`docx-generate\` +- \u7528 \`.agents/skills/docx-generate/generate_docx.py\` \u751F\u6210 \`public/\u6587\u4EF6\u540D.docx\` +- \u751F\u6210\u540E\u5FC5\u987B\u7528 \`list_dir public\`\uFF08\u6216\u7B49\u4EF7\u65B9\u5F0F\uFF09\u786E\u8BA4\u76EE\u6807 \`.docx\` \u5DF2\u5B58\u5728\uFF0C\u518D\u5199 \`public/\u9875\u9762.html\` +- HTML \u4E2D\u53EA\u80FD\u4F7F\u7528\u540C\u76EE\u5F55\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u6587\u6863\uFF0C\u4F8B\u5982 \`\u4E0B\u8F7D Word \u6587\u6863\` +- **\u7981\u6B62**\u53EA\u5199\u4E0B\u8F7D\u6309\u94AE\u6216 \`.docx\` \u94FE\u63A5\uFF0C\u5374\u6CA1\u6709\u5148\u628A\u76EE\u6807\u6587\u6863\u843D\u76D8 + +## \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 +- \u9875\u811A\u5E73\u53F0\u8054\u7CFB\u884C**\u5FC5\u987B**\u4F7F\u7528 \`data-mindspace-page-tag="platform-brand"\` \u6807\u8BB0\uFF0C\u4E14\u90AE\u7BB1/\u57DF\u540D\u53EA\u7528 **tkmind.cn**\uFF08\u5982 \`contact@tkmind.cn\`\uFF09\uFF0C**\u7981\u6B62** \`tkmind.ai\` + +\`\`\`html +

\u{1F4E7} contact@tkmind.cn

+\`\`\` + +- \u5E26 \`data-mindspace-page-tag\` \u7684\u533A\u57DF\u4E3A\u5E73\u53F0\u56FA\u5B9A\u4FE1\u606F\uFF1A\u7528\u6237\u5728\u7F16\u8F91\u6A21\u5F0F\u4E2D\u4E0D\u53EF\u89C1\u3001\u4E0D\u53EF\u6539\uFF1B\u9884\u89C8\u4E0E\u53D1\u5E03\u540E\u6B63\u5E38\u663E\u793A + +## \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 +- **\u7981\u6B62**\u5728 HTML \u4E2D\u7528 \`data:...;base64,...\` \u5185\u5D4C Word/PDF\uFF1B\u4E8C\u8FDB\u5236\u6587\u4EF6\u5355\u72EC\u843D\u76D8\u540E\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\uFF08\u89C1 \`docx-generate\` \u6280\u80FD\uFF09 +- \u7528\u6237\u8981\u6C42 Word / docx \u4E0B\u8F7D\u65F6\uFF0C\u7981\u6B62\u8DF3\u8FC7 \`docx-generate\` \u76F4\u63A5\u5728 HTML \u4E2D\u4F2A\u9020\u4E0B\u8F7D\u94FE\u63A5 + +## \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 + +## Word \u4E0B\u8F7D\u9875 + +- \u82E5\u7528\u6237\u8981\u6C42\u9875\u9762\u91CC\u53EF\u4E0B\u8F7D Word / docx\uFF1A\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`docx-generate\` +- \u5148\u751F\u6210\u5E76\u786E\u8BA4 \`public/*.docx\` \u5DF2\u5B58\u5728\uFF0C\u518D\u5199 \`public/*.html\` +- HTML \u4E2D\u53EA\u80FD\u7528\u540C\u76EE\u5F55\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863\uFF0C\u7981\u6B62\u53EA\u5199\u4E0B\u8F7D\u6309\u94AE\u5374\u6CA1\u6709\u5148\u628A \`.docx\` \u843D\u76D8 +`; +} +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", + "- \u5982\u679C\u7528\u6237\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF1A\u5148 `load_skill` \u2192 `docx-generate`\uFF0C\u751F\u6210\u5E76\u786E\u8BA4 `public/*.docx` \u5DF2\u5B58\u5728\uFF0C\u518D\u5199 HTML \u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5", + "- **\u7981\u6B62**\u7528 shell / cat / heredoc / echo / cp \u5199\u5165 HTML\uFF1Bshell \u5728\u5BB9\u5668\u5185\u6267\u884C\uFF0C\u6587\u4EF6\u4E0D\u4F1A\u51FA\u73B0\u5728\u516C\u7F51 MindSpace \u8DEF\u5F84", + "- \u7528 `apps__create_app` \u8BBE\u8BA1\u9875\u9762\u65F6\uFF0C\u6700\u540E\u4ECD\u8981\u6309 `static-page-publish` skill \u628A\u5185\u5BB9 write_file \u843D\u5230 `public/`\uFF0C\u624D\u6709\u516C\u7F51\u94FE\u63A5", + "- **\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\uFF1B\u6309\u672C\u4F1A\u8BDD\u7ED9\u51FA\u7684\u516C\u7F51\u524D\u7F00\u6A21\u677F\u62FC\u63A5\u771F\u5B9E\u5730\u5740", + "- \u4E0B\u8F7D\u9644\u4EF6\uFF1AWord \u7528 `generate_docx` \u5199\u5165 `public/*.docx`\uFF0C\u76F8\u5BF9\u94FE\u63A5\u6587\u4EF6\u5FC5\u987B\u5DF2\u5728 `public/` \u843D\u76D8\uFF0Cbasename \u4E0E HTML \u4E00\u81F4" + ); + 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`", + "- **Word \u4E0B\u8F7D\u9875\uFF08\u5FC5\u987B\u4EB2\u81EA\u5B8C\u6210\uFF09**\uFF1A\u82E5\u7528\u6237\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 `load_skill` \u2192 `docx-generate`\uFF0C\u751F\u6210\u5E76\u786E\u8BA4 `public/*.docx` \u5DF2\u5B58\u5728\uFF0C\u518D\u5728 HTML \u4E2D\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5", + "- **\u7528\u6237\u53EF\u89C1\u56DE\u590D**\uFF1A\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0 load_skill\u3001\u6280\u80FD\u66F4\u65B0\u3001\u9875\u811A\u6807\u8BB0\u3001mindspace-cover \u7B49\u5185\u90E8\u5B9E\u73B0\uFF1B\u5B8C\u6210\u540E\u76F4\u63A5\u7ED9\u51FA\u9875\u9762\u94FE\u63A5\u6216\u7ED3\u679C", + "- **\u7981\u6B62**\u7528 shell \u5199\u5165 HTML\uFF1B**\u7981\u6B62**\u8BA9\u7528\u6237\u300C\u624B\u52A8\u4FDD\u5B58\u5230 public \u76EE\u5F55\u300D\u6216\u8BF4\u300C\u6211\u65E0\u6CD5\u751F\u6210\u9875\u9762\u300D\u2014\u2014\u9664\u975E write_file \u5DF2\u5931\u8D25\u5E76\u62A5\u544A\u9519\u8BEF", + "- \u5B8C\u6210\u540E\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u516C\u7F51\u94FE\u63A5 `[\u6807\u9898](URL)`\uFF1B\u5199\u5165 `public/\u9875\u9762.html` \u65F6 URL \u4E3A `.../MindSpace/<\u7528\u6237ID>/public/\u9875\u9762.html`", + "- \u82E5\u5148\u7528 `apps__create_app` \u8BBE\u8BA1/\u9884\u89C8\uFF0C\u6700\u540E\u4ECD\u8981\u628A\u5185\u5BB9 write_file \u843D\u5230 `public/\u9875\u9762.html` \u624D\u6709\u516C\u7F51\u94FE\u63A5", + "- **\u9700\u8981\u67E5\u5B9E\u65F6/\u771F\u5B9E\u4E16\u754C\u4FE1\u606F\u65F6**\uFF1A\u5148 `load_skill` \u2192 `web`\uFF0C\u4F18\u5148 `web_search`/`fetch_url`\uFF1B\u56FD\u5185 google.com \u4E0D\u53EF\u8FBE\uFF0C\u591A\u6B21\u65E0\u679C\u65F6\u6539\u8D70 Bing/360 \u7B49\u53EF\u8FBE\u641C\u7D22\u6E90", + "- **\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`\uFF0C\u6309\u6A21\u677F\u62FC\u771F\u5B9E\u5730\u5740", + "- \u4E0B\u8F7D\u94FE\u63A5\uFF08\u5982 `report.docx`\uFF09\u5FC5\u987B\u4E0E HTML \u540C\u76EE\u5F55\u4E14\u6587\u4EF6\u540D\u4E00\u81F4\uFF1BWord \u5FC5\u987B\u7528 sandbox-fs `generate_docx` \u751F\u6210\uFF0C\u4E0D\u8981\u7528 `computercontroller` / shell \u4F5C\u4E3A\u4EA4\u4ED8\u4F9D\u636E", + `- \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); + fs.writeFileSync(hintsPath, content, "utf8"); + if (fs.existsSync(legacyPath)) { + fs.unlinkSync(legacyPath); + } + return hintsPath; +} +function ensureDocxSkillInstalled(publishDir) { + if (!fs.existsSync(DOCX_SKILL_DIR)) return null; + const skillRoot = path3.join(publishDir, ".agents", "skills", DOCX_SKILL_NAME); + fs.rmSync(skillRoot, { recursive: true, force: true }); + fs.mkdirSync(path3.dirname(skillRoot), { recursive: true }); + fs.cpSync(DOCX_SKILL_DIR, skillRoot, { recursive: true }); + return path3.join(skillRoot, "SKILL.md"); +} +function ensurePublishSkillInstalled(publishDir, context) { + ensureDocxSkillInstalled(publishDir); + const skillRoot = path3.join(publishDir, ".agents", "skills", PUBLISH_SKILL_NAME); + fs.mkdirSync(skillRoot, { recursive: true }); + const skillPath = path3.join(skillRoot, "SKILL.md"); + const content = renderPublishSkill(context); + const existing = fs.existsSync(skillPath) ? fs.readFileSync(skillPath, "utf8") : ""; + if (existing !== content) { + fs.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); + fs.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 fs2 from "node:fs"; +import path4 from "node:path"; +var USER_MEMORY_PROFILE_FILENAME = ".tkmind-profile.json"; +var USER_MEMORY_PROFILE_VERSION = 1; +var DEFAULT_TIMEZONE = "Asia/Shanghai"; +function buildInitialUserMemoryProfile({ + userId, + displayName, + 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 (!fs2.existsSync(profilePath)) return null; + try { + const raw = fs2.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() + }; + fs2.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 + }); + fs2.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 renderStoredUserMemoryLines(memories) { + const items = Array.isArray(memories) ? memories : []; + if (items.length === 0) return ["- \uFF08\u6682\u65E0\u5DF2\u6C89\u6DC0\u7684\u5BF9\u8BDD\u8BB0\u5FC6\uFF09"]; + return items.slice(0, 50).map((item) => { + const label = item?.label ? `[${item.label}] ` : ""; + const text = String(item?.text ?? item?.memory_text ?? item?.memoryText ?? "").trim(); + return `- ${label}${text}`; + }).filter((line) => line.trim() !== "-"); +} +function renderStoredUserMemoriesForHarness(memories) { + return [ + "## TKMind \u5DF2\u6C89\u6DC0\u7528\u6237\u8BB0\u5FC6", + "", + "\u4EE5\u4E0B\u5185\u5BB9\u6765\u81EA\u7528\u6237\u5386\u53F2\u5BF9\u8BDD\u7684\u957F\u671F\u8BB0\u5FC6\u62BD\u53D6\uFF0C\u4EC5\u7528\u4E8E\u6539\u5584\u5F53\u524D\u7528\u6237\u4F53\u9A8C\u3002", + "\u4E0D\u8981\u628A\u8FD9\u4E9B\u5185\u5BB9\u900F\u9732\u7ED9\u5176\u4ED6\u7528\u6237\uFF1B\u5982\u679C\u7528\u6237\u8981\u6C42\u5FD8\u8BB0\u6216\u5220\u9664\uFF0C\u5E94\u9075\u4ECE\u5E76\u66F4\u65B0\u5BF9\u5E94\u8BB0\u5FC6\u3002", + "", + ...renderStoredUserMemoryLines(memories) + ].join("\n"); +} +function formatDateParts(now, timezone) { + const formatter = new Intl.DateTimeFormat("en-CA", { + timeZone: timezone, + year: "numeric", + month: "2-digit", + day: "2-digit" + }); + const parts = Object.fromEntries( + formatter.formatToParts(new Date(now)).map((part) => [part.type, part.value]) + ); + return { + year: parts.year ?? "0000", + month: parts.month ?? "01", + day: parts.day ?? "01" + }; +} +function formatLocalClockParts(now, timezone) { + const formatter = new Intl.DateTimeFormat("en-GB", { + timeZone: timezone, + hour: "2-digit", + minute: "2-digit", + hour12: false + }); + const parts = Object.fromEntries( + formatter.formatToParts(new Date(now)).map((part) => [part.type, part.value]) + ); + return { + hour: Number(parts.hour ?? 0), + minute: Number(parts.minute ?? 0) + }; +} +function resolveTimeOfDayPeriod(hour) { + const h = Number(hour); + if (h < 5) return { period: "\u51CC\u6668", greeting: "\u4F60\u597D" }; + if (h < 9) return { period: "\u65E9\u4E0A", greeting: "\u65E9\u4E0A\u597D" }; + if (h < 12) return { period: "\u4E0A\u5348", greeting: "\u4E0A\u5348\u597D" }; + if (h < 14) return { period: "\u4E2D\u5348", greeting: "\u4E2D\u5348\u597D" }; + if (h < 18) return { period: "\u4E0B\u5348", greeting: "\u4E0B\u5348\u597D" }; + return { period: "\u665A\u4E0A", greeting: "\u665A\u4E0A\u597D" }; +} +function buildCurrentTimeAgentPrefix({ now = Date.now(), timezone = DEFAULT_TIMEZONE } = {}) { + return [ + "\u3010TKMind \u65F6\u95F4\u57FA\u51C6 - \u4EC5\u4F9B\u56DE\u7B54\u65F6\u95F4/\u65E5\u671F/\u65F6\u6BB5\u95EE\u5019\uFF0C\u4E0D\u8981\u5411\u7528\u6237\u590D\u8FF0\u3011", + renderCurrentTimeAnchor({ now, timezone }), + "" + ].join("\n"); +} +function renderCurrentTimeAnchor({ now = Date.now(), timezone = DEFAULT_TIMEZONE } = {}) { + const { year, month, day } = formatDateParts(now, timezone); + const { hour, minute } = formatLocalClockParts(now, timezone); + const { period, greeting } = resolveTimeOfDayPeriod(hour); + const clock = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; + const weekday = new Intl.DateTimeFormat("zh-CN", { + timeZone: timezone, + weekday: "long" + }).format(new Date(now)); + return [ + "## TKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6", + "", + `- \u5F53\u524D\u65F6\u533A\uFF1A${timezone}`, + `- \u5F53\u524D\u65E5\u671F\uFF1A${year}-${month}-${day}\uFF08${weekday}\uFF09`, + `- \u5F53\u524D\u65F6\u523B\uFF1A${clock}\uFF08${period}\uFF09`, + `- \u82E5\u9700\u65F6\u6BB5\u95EE\u5019\u53EF\u53C2\u8003\uFF1A${greeting}\uFF08\u4EC5\u65B0\u4F1A\u8BDD\u5F00\u573A\u6216\u7528\u6237\u4E3B\u52A8\u6253\u62DB\u547C\u65F6\u4F7F\u7528\uFF0C\u666E\u901A\u4EFB\u52A1\u56DE\u590D\u4E0D\u8981\u6BCF\u6761\u90FD\u52A0\uFF09`, + "- \u56DE\u7B54\u4E2D\u6D89\u53CA\u201C\u4ECA\u5929 / \u660E\u5929 / \u540E\u5929 / \u5468\u51E0 / \u65E9\u4E0A / \u4E0B\u5348 / \u665A\u4E0A\u201D\u7B49\u65F6\u95F4\u8868\u8FF0\u65F6\uFF0C\u5FC5\u987B\u4EE5\u4E0A\u8FF0\u65E5\u671F\u4E0E\u65F6\u523B\u4E3A\u51C6\uFF0C\u7981\u6B62\u81EA\u884C\u5047\u8BBE\u5F53\u524D\u65F6\u95F4\u6216\u4F7F\u7528 UTC \u7B49\u5176\u5B83\u65F6\u533A\u3002" + ].join("\n"); +} +function buildSessionMemoryEntries({ + workingDir, + sessionPolicy, + sandboxConstraints = null, + userContext = null, + userMemories = null, + now = Date.now() +}) { + const entries = []; + const timezone = String( + userContext?.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? DEFAULT_TIMEZONE + ).trim() || DEFAULT_TIMEZONE; + if (sandboxConstraints?.trim()) { + entries.push({ + 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 + }); + } + entries.push({ + title: "TKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6", + content: renderCurrentTimeAnchor({ now, timezone }) + }); + const scheduleTools = (sessionPolicy?.extensionOverrides ?? []).find((ext) => ext.name === "sandbox-fs"); + if ((scheduleTools?.available_tools ?? []).includes("schedule_create_item")) { + entries.push({ + title: "TKMind \u65E5\u7A0B\u5199\u5165\u89C4\u5219", + content: [ + "\u521B\u5EFA\u6216\u63D0\u9192\u5F85\u529E/\u65E5\u7A0B\u65F6\uFF1A", + "- \u5FC5\u987B\u4F7F\u7528 startLocal / endLocal / remindLocal\uFF0C\u683C\u5F0F YYYY-MM-DD HH:mm\uFF08\u7528\u6237\u65F6\u533A\u5899\u4E0A\u65F6\u949F\uFF09\u3002", + "- \u7981\u6B62\u81EA\u884C\u4F30\u7B97 Unix \u6BEB\u79D2\u65F6\u95F4\u6233\uFF1B\u4F20\u9519\u4F1A\u88AB\u5DE5\u5177\u62D2\u7EDD\u3002", + "- \u5199\u5165\u540E\u8C03\u7528 schedule_list_items \u6838\u5BF9\u65E5\u671F\u662F\u5426\u4E0E\u7528\u6237\u8868\u8FF0\u4E00\u81F4\u3002" + ].join("\n") + }); + } + 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 ?? {}) + }); + } + if (Array.isArray(userMemories) && userMemories.length > 0) { + entries.push({ + title: "TKMind \u5DF2\u6C89\u6DC0\u7528\u6237\u8BB0\u5FC6", + content: renderStoredUserMemoriesForHarness(userMemories) + }); + } + return entries; +} +function hasMemoryStore(sessionPolicy) { + 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, + userMemories = 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, + userMemories + }); + 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 + }) + }); + } +} + +// imgproxy-signer.mjs +import crypto3 from "node:crypto"; +function createImgproxySigner(key, salt) { + const keyBuf = Buffer.from(key, "hex"); + const saltBuf = Buffer.from(salt, "hex"); + function signPath(path11) { + const signaturePayload = Buffer.concat([saltBuf, Buffer.from(path11)]); + const signature = crypto3.createHmac("sha256", keyBuf).update(signaturePayload).digest(); + const signatureB64 = signature.toString("base64url"); + return `/${signatureB64}${path11}`; + } + function buildUrl(baseUrl, storagePath, preset = "display") { + if (!baseUrl || !storagePath) { + throw new Error("baseUrl and storagePath are required"); + } + let width, height, quality, format; + switch (preset) { + case "vision": + width = 768; + height = 768; + quality = 60; + format = "jpg"; + break; + case "thumb": + width = 256; + height = 256; + quality = 70; + format = "jpg"; + break; + case "display": + default: + width = 1280; + height = 1280; + quality = 85; + format = "jpg"; + break; + } + const unsignedPath = `/rs:fit:${width}:${height}:0/q:${quality}/plain/local:///${storagePath}@${format}`; + const signedPath = signPath(unsignedPath); + const baseUrlNormalized = String(baseUrl).replace(/\/$/, ""); + return `${baseUrlNormalized}${signedPath}`; + } + return { buildUrl, signPath }; +} + +// tkmind-proxy.mjs +var insecureDispatcher = new Agent({ + connect: { rejectUnauthorized: false } +}); +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"); +} +var PUBLIC_IMAGE_STORAGE_KEY_PATTERN = /^users\/([^/]+)\/images\/(\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)$/i; +function extractPublicImageStorageKey(rawUrl) { + const value = String(rawUrl ?? "").trim(); + if (!value) return null; + const directMatch = value.match(/(^|\/)(users\/[^?#"'<>@\s]+\/images\/\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)/i); + if (directMatch?.[2]) return directMatch[2]; + const plainIndex = value.indexOf("plain/local:///"); + if (plainIndex >= 0) { + const tail = value.slice(plainIndex + "plain/local:///".length); + const storageKey = tail.replace(/@[a-z0-9]+(?:[?#].*)?$/i, "").replace(/[?#].*$/, ""); + return storageKey || null; + } + return null; +} +function buildPublicStandardImageUrl(rawUrl, userId) { + const storageKey = extractPublicImageStorageKey(rawUrl); + if (!storageKey) return null; + const match = storageKey.match(PUBLIC_IMAGE_STORAGE_KEY_PATTERN); + if (!match || match[1] !== String(userId ?? "")) return null; + return buildPublicUrl(resolvePublicBaseUrl(), userId, `public/images/${match[2]}`); +} +async function apiFetch(target, apiSecret, pathname, init = {}) { + const url = new URL(pathname, target); + const headers = { + ...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 + }); +} +function createRuntimeRouter({ + targets, + targetHealthy, + redisUrl = process.env.MEMIND_RUNTIME_REDIS_URL, + namespace = process.env.MEMIND_RUNTIME_REDIS_NAMESPACE ?? "memind:runtime" +}) { + if (!redisUrl || targets.length <= 1) return null; + let clientPromise = null; + let warned = false; + const workerIdForTarget = (target) => { + const idx = targets.indexOf(target); + return idx >= 0 ? `goosed-${idx + 1}` : `goosed-${Buffer.from(String(target)).toString("base64url")}`; + }; + const key = (...parts) => [namespace, ...parts].join(":"); + const getClient = async () => { + if (!clientPromise) { + clientPromise = import("redis").then(async ({ createClient }) => { + const client = createClient({ url: redisUrl }); + client.on("error", (err) => { + if (!warned) { + warned = true; + console.warn("[RuntimeRouter] Redis error:", err instanceof Error ? err.message : err); + } + }); + await client.connect(); + console.log("[RuntimeRouter] Redis scheduler enabled"); + return client; + }).catch((err) => { + clientPromise = null; + if (!warned) { + warned = true; + console.warn("[RuntimeRouter] Redis disabled:", err instanceof Error ? err.message : err); + } + return null; + }); + } + return clientPromise; + }; + const readNumber = (value) => { + const n = Number(value ?? 0); + return Number.isFinite(n) ? n : 0; + }; + const scoreWorker = async (client, target) => { + const workerId = workerIdForTarget(target); + const values = await client.mGet([ + key("worker", workerId, "active_streams"), + key("worker", workerId, "active_sessions"), + key("worker", workerId, "ewma_first_token_ms"), + key("worker", workerId, "error_rate"), + key("worker", workerId, "memory_pressure"), + key("worker", workerId, "cpu_load"), + key("worker", workerId, "fd_pressure"), + key("worker", workerId, "drain") + ]); + if (/^(1|true|yes)$/i.test(String(values[7] ?? ""))) return Number.POSITIVE_INFINITY; + return readNumber(values[0]) * 3 + readNumber(values[1]) + readNumber(values[2]) * 0.01 + readNumber(values[3]) * 5 + readNumber(values[4]) * 2 + readNumber(values[5]) * 2 + readNumber(values[6]) * 2; + }; + const workerScoreFromValues = (values = []) => readNumber(values[0]) * 3 + readNumber(values[1]) + readNumber(values[2]) * 0.01 + readNumber(values[3]) * 5 + readNumber(values[4]) * 2 + readNumber(values[12]) * 2 + readNumber(values[13]) * 2; + return { + async pickTarget(fallbackPick, orderedTargets = targets) { + const client = await getClient(); + if (!client) return fallbackPick(); + let winner = null; + for (const target of orderedTargets) { + if (!await targetHealthy(target)) continue; + const score = await scoreWorker(client, target).catch(() => Number.POSITIVE_INFINITY); + if (!Number.isFinite(score)) continue; + if (!winner || score < winner.score) winner = { target, score }; + } + return winner?.target ?? fallbackPick(); + }, + async registerSession(sessionId, target) { + const client = await getClient(); + if (!client || !sessionId || !target) return; + const workerId = workerIdForTarget(target); + await client.multi().set(key("session", sessionId, "worker"), workerId, { EX: 60 * 60 * 24 * 30 }).set(key("session", sessionId, "target"), target, { EX: 60 * 60 * 24 * 30 }).exec().catch(() => null); + }, + async resolveSessionTarget(sessionId) { + const client = await getClient(); + if (!client || !sessionId) return null; + const target = await client.get(key("session", sessionId, "target")).catch(() => null); + return target && targets.includes(target) ? target : null; + }, + async streamStarted(sessionId, target) { + const client = await getClient(); + if (!client || !target) return; + const workerId = workerIdForTarget(target); + const streamKey = sessionId ? key("stream", sessionId, "status") : null; + const now = String(Date.now()); + const multi = client.multi().incr(key("worker", workerId, "active_streams")).incr(key("worker", workerId, "stream_open_count")).set(key("worker", workerId, "last_stream_started_at"), now).set(key("worker", workerId, "heartbeat"), now, { EX: 30 }); + if (streamKey) multi.set(streamKey, "active", { EX: 60 * 60 }); + await multi.exec().catch(() => null); + }, + async streamEnded(sessionId, target, { status = "closed" } = {}) { + const client = await getClient(); + if (!client || !target) return; + const workerId = workerIdForTarget(target); + const streamKey = sessionId ? key("stream", sessionId, "status") : null; + const activeKey = key("worker", workerId, "active_streams"); + const now = String(Date.now()); + const nextValue = await client.decr(activeKey).catch(() => null); + const multi = client.multi().set(key("worker", workerId, "last_stream_ended_at"), now).set(key("worker", workerId, "heartbeat"), now, { EX: 30 }); + if (Number(nextValue ?? 0) < 0) multi.set(activeKey, "0"); + if (status === "aborted") multi.incr(key("worker", workerId, "stream_abort_count")); + if (status === "error") multi.incr(key("worker", workerId, "stream_error_count")); + if (streamKey) multi.set(streamKey, status, { EX: 600 }); + await multi.exec().catch(() => null); + }, + async firstTokenObserved(target, latencyMs) { + const client = await getClient(); + if (!client || !target) return; + const workerId = workerIdForTarget(target); + const sample = Math.max(0, Math.round(Number(latencyMs) || 0)); + const ewmaKey = key("worker", workerId, "ewma_first_token_ms"); + const prev = readNumber(await client.get(ewmaKey).catch(() => null)); + const next = prev > 0 ? Math.round(prev * 0.8 + sample * 0.2) : sample; + const now = String(Date.now()); + await client.multi().set(ewmaKey, String(next)).set(key("worker", workerId, "last_first_token_ms"), String(sample)).set(key("worker", workerId, "last_first_token_at"), now).incr(key("worker", workerId, "first_token_count")).set(key("worker", workerId, "heartbeat"), now, { EX: 30 }).exec().catch(() => null); + }, + async getStatus() { + const client = await getClient(); + const workers = []; + for (const target of targets) { + const workerId = workerIdForTarget(target); + const values = client ? await client.mGet([ + key("worker", workerId, "active_streams"), + key("worker", workerId, "active_sessions"), + key("worker", workerId, "ewma_first_token_ms"), + key("worker", workerId, "error_rate"), + key("worker", workerId, "memory_pressure"), + key("worker", workerId, "heartbeat"), + key("worker", workerId, "drain"), + key("worker", workerId, "stream_open_count"), + key("worker", workerId, "stream_abort_count"), + key("worker", workerId, "stream_error_count"), + key("worker", workerId, "last_stream_started_at"), + key("worker", workerId, "last_stream_ended_at"), + key("worker", workerId, "cpu_load"), + key("worker", workerId, "fd_pressure"), + key("worker", workerId, "fd_count"), + key("worker", workerId, "container_pids"), + key("worker", workerId, "container_health"), + key("worker", workerId, "metrics_sampled_at"), + key("worker", workerId, "last_first_token_ms"), + key("worker", workerId, "last_first_token_at"), + key("worker", workerId, "first_token_count") + ]).catch(() => []) : []; + workers.push({ + id: workerId, + target, + activeStreams: readNumber(values?.[0]), + activeSessions: readNumber(values?.[1]), + ewmaFirstTokenMs: readNumber(values?.[2]), + errorRate: readNumber(values?.[3]), + memoryPressure: readNumber(values?.[4]), + heartbeat: values?.[5] ? Number(values[5]) : null, + drain: /^(1|true|yes)$/i.test(String(values?.[6] ?? "")), + streamOpenCount: readNumber(values?.[7]), + streamAbortCount: readNumber(values?.[8]), + streamErrorCount: readNumber(values?.[9]), + lastStreamStartedAt: values?.[10] ? Number(values[10]) : null, + lastStreamEndedAt: values?.[11] ? Number(values[11]) : null, + cpuLoad: readNumber(values?.[12]), + fdPressure: readNumber(values?.[13]), + fdCount: readNumber(values?.[14]), + containerPids: readNumber(values?.[15]), + containerHealth: values?.[16] ?? null, + metricsSampledAt: values?.[17] ? Number(values[17]) : null, + lastFirstTokenMs: readNumber(values?.[18]), + lastFirstTokenAt: values?.[19] ? Number(values[19]) : null, + firstTokenCount: readNumber(values?.[20]), + score: workerScoreFromValues(values) + }); + } + return { + enabled: Boolean(client), + namespace, + workers + }; + } + }; +} +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.+)$/; +var PUBLIC_HTML_LINK_PATTERN = /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi; +var PUBLIC_HTML_MARKDOWN_LINK_PATTERN = /\[([^\]\n]*)\]\((https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html))\)/gi; +var USER_IDENTITY_BLOCK_PATTERN = /^\[用户身份\][\s\S]*?(?:\n{2,}|$)/; +var IMAGE_URL_LINES_PATTERN = /\n*\[图片\d+]: [^\n]+/g; +var TKMIND_VISION_NOTE_PATTERN = /\n*【TKMind 图片分析结果[\s\S]*$/; +function decodePathSegment(segment) { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} +function encodeUrlPath(relativePath) { + return String(relativePath ?? "").split("/").filter(Boolean).map((part) => encodeURIComponent(part)).join("/"); +} +function normalizeStaticHtmlRelativePath(relativePath) { + const parts = String(relativePath ?? "").replace(/^\/+/, "").split("/").filter((part) => part && part !== "." && part !== ".."); + if (parts.length === 0) return ""; + if (parts[0].toLowerCase() === "public") return ["public", ...parts.slice(1)].join("/"); + if (parts.length === 1 && parts[0].toLowerCase().endsWith(".html")) return `public/${parts[0]}`; + return parts.join("/"); +} +function canonicalizeStaticPageUrl(publicUrl, originalRelativePath, canonicalRelativePath) { + const originalClean = String(originalRelativePath ?? "").replace(/^\/+/, ""); + if (!canonicalRelativePath || canonicalRelativePath === originalClean) return publicUrl; + const suffix = encodeUrlPath(originalClean).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return String(publicUrl).replace(new RegExp(`${suffix}$`), encodeUrlPath(canonicalRelativePath)); +} +function buildMissingPublicHtmlNotice(filename) { + return `\uFF08\u9875\u9762\u751F\u6210\u672A\u5B8C\u6210\uFF0C\u5DF2\u963B\u6B62\u663E\u793A\u5931\u6548\u94FE\u63A5\uFF1A${filename || "\u9875\u9762"}\u3002\uFF09`; +} +function publicHtmlExistsForUser(owner, relativePath, currentUser) { + const normalizedOwner = String(owner ?? "").trim().toLowerCase(); + const normalizedUserId = String(currentUser?.id ?? "").trim().toLowerCase(); + const normalizedUsername = String(currentUser?.username ?? "").trim().toLowerCase(); + if (!normalizedOwner) return false; + if (normalizedOwner !== normalizedUserId && normalizedOwner !== normalizedUsername) return true; + const normalizedRelativePath = normalizeStaticHtmlRelativePath(relativePath); + if (!normalizedRelativePath || !normalizedRelativePath.toLowerCase().endsWith(".html")) return false; + const root2 = path6.resolve(process.cwd(), PUBLISH_ROOT_DIR, normalizedOwner); + const target = path6.resolve(root2, normalizedRelativePath); + if (target !== root2 && !target.startsWith(`${root2}${path6.sep}`)) return false; + return fs3.existsSync(target) && fs3.statSync(target).isFile(); +} +function sanitizeOwnPublicHtmlUrl(publicUrl, owner, rawRelativePath, currentUser) { + const relativePath = decodePathSegment(rawRelativePath); + const canonicalRelativePath = normalizeStaticHtmlRelativePath(relativePath); + if (!publicHtmlExistsForUser(owner, canonicalRelativePath, currentUser)) { + return { + ok: false, + notice: buildMissingPublicHtmlNotice(path6.posix.basename(canonicalRelativePath || relativePath)) + }; + } + return { + ok: true, + url: canonicalizeStaticPageUrl(publicUrl, relativePath, canonicalRelativePath) + }; +} +function sanitizePublicHtmlLinksInText(text, currentUser) { + let next = String(text ?? "").replace( + PUBLIC_HTML_MARKDOWN_LINK_PATTERN, + (match, label, url, owner, rawRelativePath) => { + const result = sanitizeOwnPublicHtmlUrl(url, owner, rawRelativePath, currentUser); + if (!result.ok) return result.notice; + return label ? `[${label}](${result.url})` : result.url; + } + ); + return next.replace(PUBLIC_HTML_LINK_PATTERN, (match, owner, rawRelativePath) => { + const result = sanitizeOwnPublicHtmlUrl(match, owner, rawRelativePath, currentUser); + return result.ok ? result.url : result.notice; + }); +} +function sanitizeUserVisibleMessageText(text, currentUser) { + return sanitizePublicHtmlLinksInText( + String(text ?? "").replace(USER_IDENTITY_BLOCK_PATTERN, "").replace(TKMIND_VISION_NOTE_PATTERN, "").replace(IMAGE_URL_LINES_PATTERN, "").trim(), + currentUser + ); +} +function sanitizeSessionMessagePublicHtmlLinks(message, currentUser) { + if (!message || !Array.isArray(message.content)) return message; + let changed = false; + const imageUrls = extractImageUrlsFromMessage(message); + const content = message.content.map((item) => { + if (item?.type !== "text" || typeof item.text !== "string") return item; + const nextText = sanitizeUserVisibleMessageText(item.text, currentUser); + if (nextText === item.text) return item; + changed = true; + return { ...item, text: nextText }; + }); + const currentDisplayText = typeof message.metadata?.displayText === "string" ? message.metadata.displayText : null; + const nextDisplayText = currentDisplayText == null ? null : sanitizeUserVisibleMessageText(currentDisplayText, currentUser); + const metadataChanged = currentDisplayText != null && nextDisplayText !== currentDisplayText || !Array.isArray(message.metadata?.imageUrls) && imageUrls.length > 0; + if (!changed && !metadataChanged) return message; + return { + ...message, + content, + metadata: { + ...message.metadata ?? {}, + ...nextDisplayText != null ? { displayText: nextDisplayText } : {}, + ...!Array.isArray(message.metadata?.imageUrls) && imageUrls.length > 0 ? { imageUrls } : {} + } + }; +} +function sanitizeSessionConversationPublicHtmlLinks(conversation, currentUser) { + if (!Array.isArray(conversation)) return conversation; + return conversation.map((message) => sanitizeSessionMessagePublicHtmlLinks(message, currentUser)); +} +function createSessionEventSanitizer(currentUser, { onEvent } = {}) { + let buffer = ""; + const flushChunk = (controller, chunk) => { + if (!chunk) return; + const block = String(chunk); + if (!block.includes("data: ")) { + controller.push(block); + return; + } + const lines = block.split("\n"); + const sanitizedLines = lines.map((line) => { + if (!line.startsWith("data: ")) return line; + const raw = line.slice(6); + let event; + try { + event = JSON.parse(raw); + } catch { + return line; + } + if (typeof onEvent === "function") { + try { + onEvent(event); + } catch { + } + } + if (event?.type === "Message" && event.message) { + event.message = sanitizeSessionMessagePublicHtmlLinks(event.message, currentUser); + } else if (event?.type === "UpdateConversation" && Array.isArray(event.conversation)) { + event.conversation = sanitizeSessionConversationPublicHtmlLinks(event.conversation, currentUser); + } + return `data: ${JSON.stringify(event)}`; + }); + controller.push(sanitizedLines.join("\n")); + }; + return new Transform2({ + transform(chunk, _encoding, callback) { + buffer += chunk.toString("utf8"); + let boundary = buffer.indexOf("\n\n"); + while (boundary >= 0) { + const block = buffer.slice(0, boundary + 2); + buffer = buffer.slice(boundary + 2); + flushChunk(this, block); + boundary = buffer.indexOf("\n\n"); + } + callback(); + }, + flush(callback) { + flushChunk(this, buffer); + buffer = ""; + callback(); + } + }); +} +function extractImageUrlsFromMessage(userMessage) { + const urls = []; + const imageUrls = userMessage?.metadata?.imageUrls; + 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 prependAgentTextToUserMessage(body, transformText) { + const originalText = firstUserText(body?.user_message); + if (!originalText?.trim()) return body; + const agentText = transformText(originalText); + 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 + } + } + }; +} +function injectCurrentTimeAnchor(body, timezone) { + const tz = String(timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? "Asia/Shanghai").trim() || "Asia/Shanghai"; + return prependAgentTextToUserMessage(body, (originalText) => { + if (originalText.includes("TKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6")) return originalText; + return `${buildCurrentTimeAgentPrefix({ timezone: tz })}${originalText}`; + }); +} +function injectTaskRoutingHint(body, sessionPolicy) { + return prependAgentTextToUserMessage( + body, + (originalText) => buildTaskRoutingAgentText(originalText, sessionPolicy) + ); +} +async function buildVisionPayload({ + userMessage, + userId, + publishLayout, + localFetchAsset, + llmProviderService, + imgproxySigner = null +}) { + void imgproxySigner; + if (!llmProviderService || !userId) return null; + const rawImageUrls = extractImageUrlsFromMessage(userMessage); + if (rawImageUrls.length === 0) return null; + const originalDisplayText = typeof userMessage?.metadata?.displayText === "string" && userMessage.metadata.displayText.trim() ? userMessage.metadata.displayText : Array.isArray(userMessage?.content) ? userMessage.content.filter((item) => item?.type === "text" && typeof item.text === "string").map((item) => item.text).join("\n").trim() : ""; + const imageItems = []; + for (const rawUrl of rawImageUrls) { + try { + const match = rawUrl.match(/\/mindspace\/v1\/assets\/([^/?#]+)(?:\/download)?/); + let buffer; + let mimeType = "image/jpeg"; + const fetchableUrl = (() => { + if (/^https?:\/\//i.test(rawUrl)) return rawUrl; + if (rawUrl.startsWith("/")) { + const baseUrl = process.env.H5_PUBLIC_BASE_URL || "http://127.0.0.1:8081"; + return new URL(rawUrl, baseUrl).toString(); + } + return rawUrl; + })(); + try { + const response = await undiciFetch(fetchableUrl); + if (!response.ok) { + throw new Error(`Vision fetch failed: ${response.status}`); + } + buffer = Buffer.from(await response.arrayBuffer()); + mimeType = response.headers.get("content-type") || "image/jpeg"; + } catch (fetchErr) { + if (!match || !localFetchAsset) { + throw fetchErr; + } + const assetId = decodeURIComponent(match[1]); + console.warn(`Vision fetch fallback for asset ${assetId}:`, fetchErr instanceof Error ? fetchErr.message : fetchErr); + const asset = await localFetchAsset(userId, assetId); + buffer = asset.buffer; + mimeType = asset.mimeType; + } + let relativePath = rawUrl; + try { + const parsed = new URL(rawUrl); + relativePath = parsed.pathname + parsed.search; + } catch { + } + const publicStandardUrl = buildPublicStandardImageUrl(rawUrl, userId); + imageItems.push({ + mimeType, + data: buffer.toString("base64"), + relativePath, + rawUrl, + embedUrl: publicStandardUrl ?? relativePath + }); + } catch (err) { + console.warn("Vision image fetch skipped:", err instanceof Error ? err.message : err); + } + } + if (imageItems.length === 0) return null; + const visionDescription = await llmProviderService.analyzeImagesWithVision(imageItems, "\u8BF7\u8BE6\u7EC6\u63CF\u8FF0\u56FE\u7247\u7684\u89C6\u89C9\u5185\u5BB9\uFF1A\u4E3B\u4F53\u3001\u989C\u8272\u3001\u98CE\u683C\u3001\u6784\u56FE\uFF0C\u4E0D\u8981\u751F\u6210\u4EE3\u7801\u6216\u9875\u9762\u65B9\u6848").catch(() => null); + const pathList = imageItems.map((item, i) => `\u56FE\u7247${i + 1}: \u56FE\u7247${i + 1}`).join(" "); + const 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} + +` : "") + `\u5199\u4F5C\u7EA6\u675F\uFF1A\u4E0D\u5F97\u6539\u5199\u56FE\u7247\u91CC\u4EBA\u7269\u7684\u5E74\u9F84\u3001\u6027\u522B\u3001\u4EBA\u6570\u6216\u4E3B\u4F53\u5173\u7CFB\uFF1B\u5982\u679C\u7528\u6237\u660E\u786E\u8981\u6C42\u513F\u7AE5\u8BED\u6C14\u6216\u7AE5\u8DA3\u98CE\u683C\uFF0C\u4E5F\u53EA\u80FD\u8C03\u6574\u8868\u8FBE\u65B9\u5F0F\uFF0C\u4E0D\u80FD\u628A\u56FE\u7247\u4E3B\u4F53\u6539\u5199\u6210\u513F\u7AE5\u573A\u666F\u3002 +\u56FE\u7247 HTML \u5D4C\u5165\u8DEF\u5F84\uFF08\u76F4\u63A5\u5199\u5165 \u6807\u7B7E\uFF1B\u4EE5\u4E0B\u90FD\u662F\u65E0\u9700 cookie \u7684\u516C\u5F00\u538B\u7F29\u6807\u51C6\u56FE\u7247\uFF0C\u7981\u6B62\u4F7F\u7528 local://\u3001/users/ \u79C1\u6709\u8DEF\u5F84\u3001\u539F\u56FE\u5730\u5740\u6216\u9700\u8981\u767B\u5F55\u6001\u7684\u4E0B\u8F7D\u94FE\u63A5\uFF09\uFF1A +${pathList} +\u6267\u884C\u8981\u6C42\uFF1A\u5FC5\u987B\u5148\u8C03\u7528 load_skill \u2192 static-page-publish\uFF08\u6BCF\u6B21\u751F\u6210\u9875\u9762\u90FD\u8981\u8C03\u7528\uFF0C\u4E0D\u53EF\u7701\u7565\uFF09\uFF0C\u518D\u7528 write_file \u5199\u5165 public/\u9875\u9762.html\uFF08\u7981\u6B62\u7528 shell/cat/heredoc \u5199 HTML\uFF0C\u5BB9\u5668\u5185\u6587\u4EF6\u4E0D\u4F1A\u51FA\u73B0\u5728\u516C\u7F51\u94FE\u63A5\uFF09\uFF1B` + (publicUrlPrefix ? `\u5B8C\u6210\u540E\u5411\u7528\u6237\u7ED9\u51FA Markdown \u53EF\u70B9\u51FB\u94FE\u63A5\uFF0C\u683C\u5F0F\uFF1A[\u9875\u9762\u6807\u9898](${publicUrlPrefix}<\u6587\u4EF6\u540D>.html)\uFF1B` : "\u5B8C\u6210\u540E\u6309\u6280\u80FD\u8BF4\u660E\u91CC\u7684\u94FE\u63A5\u683C\u5F0F\u7ED9\u7528\u6237\u4E00\u4E2A Markdown \u53EF\u70B9\u51FB\u94FE\u63A5\uFF1B") + "\u4E0D\u8981\u5411\u7528\u6237\u5C55\u793A HTML \u4EE3\u7801\u5757\u3002"; + let updatedContent = Array.isArray(userMessage?.content) ? [...userMessage.content] : []; + for (const item of imageItems) { + updatedContent = updatedContent.map((c) => { + if (c?.type !== "text" || typeof c.text !== "string") return c; + const updated = c.text.replaceAll(item.rawUrl, item.embedUrl); + 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, + metadata: { + ...userMessage.metadata ?? {}, + ...originalDisplayText ? { displayText: originalDisplayText } : {} + } + }, + billableImageCount: visionDescription ? 1 : 0 + }; +} +function createTkmindProxy({ + apiTarget, + apiTargets: apiTargets2, + apiSecret, + userAuth: userAuth2, + llmProviderService, + localFetchAsset, + subscriptionService, + sessionSnapshotService, + conversationMemoryService +}) { + const targets = apiTargets2?.length ? apiTargets2 : apiTarget ? [apiTarget] : []; + const primaryTarget = targets[0] ?? apiTarget ?? ""; + let rrIdx = 0; + let imgproxySigner = null; + if (process.env.IMGPROXY_BASE_URL && process.env.IMGPROXY_SIGNING_KEY && process.env.IMGPROXY_SIGNING_SALT) { + try { + imgproxySigner = createImgproxySigner( + process.env.IMGPROXY_SIGNING_KEY, + process.env.IMGPROXY_SIGNING_SALT + ); + console.log("[TKMindProxy] imgproxy signer initialized"); + } catch (err) { + console.warn("[TKMindProxy] imgproxy signer init failed:", err instanceof Error ? err.message : err); + } + } + async function targetHealthy(target) { + try { + const upstream = await apiFetch(target, apiSecret, "/status", { + method: "GET", + signal: AbortSignal.timeout(1500) + }); + return upstream.ok; + } catch { + return false; + } + } + const runtimeRouter = createRuntimeRouter({ targets, targetHealthy }); + function isGeneratedSessionName(name) { + const normalized = typeof name === "string" ? name.trim() : ""; + return /^20\d{6}(?:[_-]\d+)?$/.test(normalized); + } + function hasDefaultSessionName(session) { + if (session?.user_set_name) return false; + const name = typeof session?.name === "string" ? session.name.trim() : ""; + return name === "" || name === "New Chat" || name === "\u65B0\u5BF9\u8BDD" || isGeneratedSessionName(name); + } + async function enrichSessionHistory(sessions) { + if (!sessionSnapshotService?.isEnabled?.()) return; + const needsSummary = sessions.filter( + (session) => hasDefaultSessionName(session) || Number(session?.message_count ?? 0) === 0 + ); + if (needsSummary.length === 0) return; + try { + const summaries = await sessionSnapshotService.getHistorySummaries(needsSummary.map((s) => s.id)); + for (const session of needsSummary) { + const summary = summaries.get(session.id); + if (!summary) continue; + if (summary.title && hasDefaultSessionName(session)) { + session.name = summary.title; + } + if (Number(session?.message_count ?? 0) === 0 && Number(summary.messageCount ?? 0) > 0) { + session.message_count = Number(summary.messageCount); + } + } + } catch { + } + } + function projectSessionSummary(session) { + return { + id: session?.id, + name: typeof session?.name === "string" ? session.name : "", + message_count: Number(session?.message_count ?? 0), + created_at: session?.created_at ?? void 0, + updated_at: session?.updated_at ?? void 0, + user_set_name: Boolean(session?.user_set_name), + recipe: session?.recipe ?? null + }; + } + function sortSessionsByRecent(left, right) { + const leftTime = Date.parse(left?.updated_at ?? left?.created_at ?? "") || 0; + const rightTime = Date.parse(right?.updated_at ?? right?.created_at ?? "") || 0; + return rightTime - leftTime; + } + function matchesSessionQuery(summary, rawQuery) { + const query = String(rawQuery ?? "").trim().toLowerCase(); + if (!query) return true; + const haystacks = [ + summary?.name, + summary?.recipe?.title, + summary?.id + ].filter((value) => typeof value === "string" && value.trim()).map((value) => value.toLowerCase()); + return haystacks.some((value) => value.includes(query)); + } + function paginateSessionConversation(conversation, beforeValue, limitValue) { + const visibleConversation = Array.isArray(conversation) ? conversation.filter((message) => message?.metadata?.userVisible) : []; + const total = visibleConversation.length; + const before = Math.max(Number(beforeValue ?? 0) || 0, 0); + const requestedLimit = Number(limitValue ?? 0) || 0; + if (requestedLimit <= 0) { + return { + conversation: visibleConversation, + page: { + total, + before: 0, + limit: total, + returned: total, + has_more_before: false + } + }; + } + const limit = Math.min(Math.max(requestedLimit, 1), 200); + const end = Math.max(total - before, 0); + const start = Math.max(end - limit, 0); + const paged = visibleConversation.slice(start, end); + return { + conversation: paged, + page: { + total, + before, + limit, + returned: paged.length, + has_more_before: start > 0 + } + }; + } + async function pickTarget() { + if (targets.length <= 1) return primaryTarget; + const fallbackPick = async () => { + 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; + }; + if (runtimeRouter) { + const orderedTargets = targets.map((_, i) => targets[(rrIdx + i) % targets.length]); + rrIdx = (rrIdx + 1) % targets.length; + return runtimeRouter.pickTarget(fallbackPick, orderedTargets); + } + return fallbackPick(); + } + async function rememberSessionTarget(sessionId, target) { + if (!runtimeRouter || !sessionId || !target) return; + await runtimeRouter.registerSession(sessionId, target).catch(() => null); + } + async function markStreamStarted(sessionId, target) { + if (!runtimeRouter || !sessionId || !target) return; + await runtimeRouter.streamStarted(sessionId, target).catch(() => null); + } + async function markStreamEnded(sessionId, target, options = {}) { + if (!runtimeRouter || !sessionId || !target) return; + await runtimeRouter.streamEnded(sessionId, target, options).catch(() => null); + } + function markFirstTokenObserved(target, latencyMs) { + if (!runtimeRouter || !target) return; + void runtimeRouter.firstTokenObserved(target, latencyMs).catch(() => null); + } + async function getRuntimeStatus() { + const targetStatuses = []; + for (const target of targets) { + targetStatuses.push({ + target, + healthy: await targetHealthy(target) + }); + } + const routerStatus = runtimeRouter ? await runtimeRouter.getStatus().catch((err) => ({ + enabled: false, + error: err instanceof Error ? err.message : String(err) + })) : { enabled: false, reason: "MEMIND_RUNTIME_REDIS_URL not configured" }; + return { + publicBaseUrl: process.env.H5_PUBLIC_BASE_URL ?? null, + router: routerStatus, + toolRuntime: { + defaultMode: "chat", + codeToolMode: "code", + chatInjectsCodeTools: false, + codeRunsEnabled: ["1", "true", "yes", "on"].includes( + String(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED ?? "").trim().toLowerCase() + ), + aiderTimeoutMs: Number(process.env.MEMIND_AIDER_TIMEOUT_MS ?? 6e5), + openhandsTimeoutMs: Number(process.env.MEMIND_OPENHANDS_TIMEOUT_MS ?? 9e5) + }, + targets: targetStatuses + }; + } + async function startSessionForUser(userId, { + workingDir, + sessionPolicy = null, + recipe = null + } = {}) { + const resolvedWorkingDir = workingDir ?? await userAuth2.resolveWorkingDir(userId); + const resolvedSessionPolicy = sessionPolicy ?? await userAuth2.getAgentSessionPolicy(userId); + const startTarget = await pickTarget(); + const upstream = await apiFetch(startTarget, apiSecret, "/agent/start", { + method: "POST", + body: JSON.stringify({ + ...resolvedWorkingDir ? { working_dir: resolvedWorkingDir } : {}, + enable_context_memory: resolvedSessionPolicy?.enableContextMemory, + ...resolvedSessionPolicy?.extensionOverrides ? { extension_overrides: resolvedSessionPolicy.extensionOverrides } : {}, + ...recipe ? { recipe } : {} + }) + }); + const text = await upstream.text(); + if (!upstream.ok) { + throw new Error(text || `\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25 (${upstream.status})`); + } + const session = JSON.parse(text); + if (!session?.id) { + throw new Error("\u521B\u5EFA\u4F1A\u8BDD\u5931\u8D25\uFF1A\u7F3A\u5C11 session id"); + } + await rememberSessionTarget(session.id, startTarget); + await userAuth2.registerAgentSession(userId, session.id, startTarget); + if (resolvedSessionPolicy?.gooseMode) { + const modeRes = await apiFetch(startTarget, apiSecret, "/agent/update_session", { + method: "POST", + body: JSON.stringify({ + session_id: session.id, + goose_mode: resolvedSessionPolicy.gooseMode + }) + }); + if (!modeRes.ok) { + const modeText = await modeRes.text().catch(() => ""); + throw new Error(modeText || `\u8BBE\u7F6E\u4F1A\u8BDD\u6A21\u5F0F\u5931\u8D25 (${modeRes.status})`); + } + } + const publishLayout = await userAuth2.getUserPublishLayout(userId); + const userMemories = conversationMemoryService?.listMemories ? await conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => []) : []; + await reconcileAgentSession( + (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init), + session.id, + { + workingDir: resolvedWorkingDir, + sessionPolicy: resolvedSessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + userMemories, + userContext: publishLayout ? { + userId, + displayName: publishLayout.displayName, + username: publishLayout.username, + slug: publishLayout.slug + } : null + } + ); + await applySessionLlmProvider(session.id); + return session; + } + async function resolveTarget(sessionId) { + if (targets.length <= 1 || !sessionId) return primaryTarget; + try { + const routedTarget = await runtimeRouter?.resolveSessionTarget(sessionId); + if (routedTarget) return routedTarget; + const { target, node } = await userAuth2.getSessionTarget(sessionId); + if (target && targets.includes(target)) return target; + return targets[node] ?? primaryTarget; + } catch { + return primaryTarget; + } + } + async function applySessionLlmProvider(sessionId) { + if (!llmProviderService || !sessionId) return null; + try { + const target = await resolveTarget(sessionId); + return await llmProviderService.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 (!llmProviderService || !sessionId) return null; + const target = await resolveTarget(sessionId); + return llmProviderService.applyLocalFallbackForSession( + sessionId, + (url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init) + ); + } + const visionActiveSessions = /* @__PURE__ */ new Set(); + async function applyVisionProviderForSession(sessionId) { + if (!llmProviderService || !sessionId) return null; + const target = await resolveTarget(sessionId); + return llmProviderService.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, + imgproxySigner + }); + } + async function getSessionPolicyForToolMode(userId, toolMode = "chat") { + if (toolMode === "code" && userAuth2.getCodeAgentSessionPolicy) { + return userAuth2.getCodeAgentSessionPolicy(userId); + } + return userAuth2.getAgentSessionPolicy(userId); + } + async function reconcileSessionPolicyForUser(userId, sessionId, { toolMode = "chat" } = {}) { + if (!userId || !sessionId) return; + const target = await resolveTarget(sessionId); + const workingDir = await userAuth2.resolveWorkingDir(userId); + const sessionPolicy = await getSessionPolicyForToolMode(userId, toolMode); + const publishLayout = await userAuth2.getUserPublishLayout(userId); + const userMemories = conversationMemoryService?.listMemories ? await conversationMemoryService.listMemories(userId, { limit: 40 }).catch(() => []) : []; + await reconcileAgentSession( + (pathname, init) => apiFetch(target, apiSecret, pathname, init), + sessionId, + { + workingDir, + sessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + tolerateInvalidWorkingDir: true, + userMemories, + userContext: publishLayout ? { + userId, + displayName: publishLayout.displayName, + username: publishLayout.username, + slug: publishLayout.slug + } : null + } + ); + } + async function submitSessionReplyForUser(userId, sessionId, requestId, userMessage, { toolMode = "chat" } = {}) { + if (!userId || !sessionId) throw new Error("\u7F3A\u5C11\u4F1A\u8BDD\u4FE1\u606F"); + const owns = await userAuth2.ownsSession(userId, sessionId); + if (!owns) throw new Error("\u65E0\u6743\u8BBF\u95EE\u8BE5\u4F1A\u8BDD"); + const gate = await userAuth2.canUseChat(userId); + if (!gate.ok) { + const err = new Error(gate.message ?? "\u4F59\u989D\u4E0D\u8DB3\uFF0C\u8BF7\u5145\u503C\u540E\u7EE7\u7EED\u4F7F\u7528"); + err.code = gate.code; + err.status = 402; + throw err; + } + await reconcileSessionPolicyForUser(userId, sessionId, { toolMode }); + await applySessionLlmProvider(sessionId); + const user = await userAuth2.getUserById(userId); + if (!user) throw new Error("\u7528\u6237\u4E0D\u5B58\u5728"); + let finalUserMessage = userMessage; + if (llmProviderService && messageHasImages(userMessage) && await llmProviderService.hasVisionKey()) { + const publishLayout = await userAuth2.getUserPublishLayout(userId).catch(() => null); + const visionResult = await buildVisionBody(userMessage, userId, publishLayout).catch(() => null); + if (visionResult?.userMessage) { + finalUserMessage = visionResult.userMessage; + } + if (visionResult?.billableImageCount > 0 && subscriptionService) { + await subscriptionService.consumeImageQuota(userId, visionResult.billableImageCount).catch((err) => { + console.warn( + "Subscription image quota consume skipped:", + err instanceof Error ? err.message : err + ); + }); + } + } + const policyState = await userAuth2.resolveUserPolicies(user); + let body = { + request_id: requestId, + user_message: finalUserMessage + }; + if (!policyState.unrestricted) { + body = injectTaskRoutingHint( + body, + await userAuth2.getAgentSessionPolicy(userId) + ); + } + const target = await resolveTarget(sessionId); + const upstream = await apiFetch( + target, + apiSecret, + `/sessions/${encodeURIComponent(sessionId)}/reply`, + { + method: "POST", + body: JSON.stringify(body) + } + ); + if (!upstream.ok) { + const text = await upstream.text().catch(() => ""); + throw new Error(text || `\u53D1\u9001\u5931\u8D25 (${upstream.status})`); + } + return { ok: true }; + } + const requireUser = async (req, res, next) => { + try { + const session = req.userSession; + 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 rememberSessionTarget(session.id, startTarget); + await userAuth2.registerAgentSession( + req.currentUser.id, + session.id, + startTarget + ); + if (sessionPolicy.gooseMode) { + const modeRes = await apiFetch(startTarget, apiSecret, "/agent/update_session", { + method: "POST", + body: JSON.stringify({ + session_id: session.id, + goose_mode: sessionPolicy.gooseMode + }) + }); + if (!modeRes.ok) { + const modeText = await modeRes.text().catch(() => ""); + res.status(modeRes.status).send(modeText || "\u8BBE\u7F6E\u4F1A\u8BDD\u6A21\u5F0F\u5931\u8D25"); + return; + } + } + const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser.id); + const userMemories = conversationMemoryService?.listMemories ? await conversationMemoryService.listMemories(req.currentUser.id, { limit: 40 }).catch(() => []) : []; + const api = (pathname, init) => apiFetch(startTarget, apiSecret, pathname, init); + try { + await reconcileAgentSession(api, session.id, { + workingDir, + sessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + userMemories, + 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); + const userMemories = conversationMemoryService?.listMemories ? await conversationMemoryService.listMemories(req.currentUser.id, { limit: 40 }).catch(() => []) : []; + try { + await reconcileAgentSession( + (pathname, init) => apiFetch(resumeTarget, apiSecret, pathname, init), + sessionId, + { + workingDir, + sessionPolicy, + sandboxConstraints: publishLayout?.constraints ?? null, + tolerateInvalidWorkingDir: true, + userMemories, + 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 offset = Math.max(Number(req.query?.offset ?? 0) || 0, 0); + const requestedLimit = Number(req.query?.limit ?? 20) || 20; + const limit = Math.min(Math.max(requestedLimit, 1), 100); + const query = String(req.query?.query ?? "").trim(); + const owned = await userAuth2.listOwnedSessionIds(req.currentUser.id); + const sessionsById = /* @__PURE__ */ new Map(); + let healthyTargets = 0; + let lastFailure = null; + for (const target of targets) { + try { + const upstream = await apiFetch(target, apiSecret, "/sessions", { + method: "GET", + signal: AbortSignal.timeout(12e3) + }); + 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"); + } + const sessions = [...sessionsById.values()].sort(sortSessionsByRecent); + await enrichSessionHistory(sessions); + if (typeof userAuth2.getSessionOrigins === "function" && sessions.length > 0) { + try { + const origins = await userAuth2.getSessionOrigins(sessions.map((s) => s.id)); + for (const session of sessions) { + session.origin = origins.get(session.id) ?? "h5"; + } + } catch { + } + } + const summaries = sessions.map(projectSessionSummary).filter((item) => matchesSessionQuery(item, query)); + const paged = summaries.slice(offset, offset + limit); + res.json({ + sessions: paged, + page: { + total: summaries.length, + offset, + limit, + has_more: offset + paged.length < summaries.length, + query + } + }); + } catch (err) { + res.status(500).json({ + message: sanitizeUserFacingProxyMessage( + 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, onEvent } = {}) => { + const upstreamAbort = new AbortController(); + const streamRequestedAt = Date.now(); + let clientClosed = false; + const abortUpstream = () => { + clientClosed = true; + upstreamAbort.abort(); + }; + req.once("close", abortUpstream); + try { + const pathname = `/sessions/${sessionId}/events`; + const sessionTarget = await resolveTarget(sessionId); + const upstream = await apiFetch(sessionTarget, apiSecret, pathname, { + method: "GET", + signal: upstreamAbort.signal, + headers: { + Accept: "text/event-stream", + "Last-Event-ID": req.get("last-event-id") ?? "" + } + }); + if (clientClosed) return; + 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; charset=utf-8"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders?.(); + let pendingBalance = null; + const billingTransform = createSseBillingTransform({ + onFinish: async (event) => { + const billingRequestId = event.request_id ?? event.chat_request_id ?? null; + const result = await userAuth2.billSessionUsage( + req.currentUser.id, + sessionId, + event.token_state, + billingRequestId + ); + 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); + let firstChunkSeen = false; + const firstTokenProbe = new Transform2({ + transform(chunk, _encoding, callback) { + if (!firstChunkSeen && chunk?.length > 0) { + firstChunkSeen = true; + markFirstTokenObserved(sessionTarget, Date.now() - streamRequestedAt); + } + callback(null, chunk); + } + }); + const linkSanitizer = createSessionEventSanitizer(req.currentUser, { onEvent }); + const waitForDrain = () => new Promise((resolve) => res.once("drain", resolve)); + const writeClientChunk = async (chunk) => { + if (res.writableEnded || clientClosed) return; + let needsDrain = !res.write(chunk); + if (pendingBalance != null && !res.writableEnded && !clientClosed) { + needsDrain = !res.write(appendBalanceEvent(pendingBalance)) || needsDrain; + pendingBalance = null; + } + if (needsDrain && !res.writableEnded && !clientClosed) { + await waitForDrain(); + } + }; + const clientSink = new Writable({ + write(chunk, _encoding, callback) { + writeClientChunk(chunk).then(() => callback(), callback); + } + }); + const keepalive = setInterval(() => { + if (!res.writableEnded && !clientClosed) { + const ok = res.write(": keepalive\n\n"); + if (!ok) source.pause(); + } + }, 2e4); + res.on("drain", () => source.resume()); + await markStreamStarted(sessionId, sessionTarget); + let streamCloseStatus = "closed"; + try { + try { + await pipeline(source, firstTokenProbe, linkSanitizer, billingTransform, clientSink); + } catch (err) { + streamCloseStatus = clientClosed || upstreamAbort.signal.aborted ? "aborted" : "error"; + throw err; + } + } finally { + clearInterval(keepalive); + req.off("close", abortUpstream); + await markStreamEnded(sessionId, sessionTarget, { status: streamCloseStatus }); + if (!res.writableEnded) res.end(); + } + } catch (err) { + req.off("close", abortUpstream); + if (clientClosed || upstreamAbort.signal.aborted) { + if (!res.writableEnded) res.end(); + return; + } + 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 isAgentRunPath = req.method === "POST" && pathname === "/agent/runs" || req.method === "GET" && /^\/agent\/runs\/[^/]+$/.test(pathname) || req.method === "GET" && /^\/agent\/runs\/[^/]+\/events$/.test(pathname); + if (isAgentRunPath) { + res.status(503).json({ + message: "Agent Run \u63A5\u53E3\u9700\u5728 Portal \u672C\u5730\u5904\u7406\uFF0C\u8BF7\u786E\u8BA4 server.mjs \u5DF2\u66F4\u65B0\u5E76\u91CD\u542F\u540E\u7AEF", + code: "AGENT_RUNS_NATIVE_REQUIRED" + }); + return; + } + const policyState = await userAuth2.resolveUserPolicies(req.currentUser); + const capabilityState = await userAuth2.resolveUserCapabilities(req.currentUser); + const gate = evaluateProxyRequest(req.method, pathname, policyState.policies, { + 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\/([^/]+)/); + if (isReplyPath) { + res.status(410).json({ + message: "\u804A\u5929\u63D0\u4EA4\u5165\u53E3\u5DF2\u7EDF\u4E00\u4E3A POST /agent/runs", + code: "AGENT_RUNS_REQUIRED" + }); + return; + } + let baseBody = req.body; + if (isReplyPath && !policyState.unrestricted) { + const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser.id).catch(() => null); + baseBody = injectCurrentTimeAnchor(req.body, publishLayout?.timezone); + baseBody = injectTaskRoutingHint( + baseBody, + await userAuth2.getAgentSessionPolicy(req.currentUser.id) + ); + } + if (isReplyPath && llmProviderService) { + const sessionId = sessionMatch?.[1]; + if (sessionId) { + const hasImages = messageHasImages(req.body?.user_message); + if (hasImages && await llmProviderService.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 && subscriptionService) { + await subscriptionService.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") ?? "" + } + }); + if (req.method === "GET" && /^\/sessions\/[^/]+$/.test(pathname) && upstream.ok) { + const payload = await upstream.json().catch(() => null); + if (payload && Array.isArray(payload.conversation)) { + const sanitizedConversation = sanitizeSessionConversationPublicHtmlLinks( + payload.conversation, + req.currentUser + ); + const { conversation, page } = paginateSessionConversation( + sanitizedConversation, + req.query?.history_before, + req.query?.history_limit + ); + payload.conversation = conversation; + payload.conversation_page = page; + } + res.status(upstream.status).json(payload ?? {}); + return; + } + sendProxyResponse(res, upstream); + } catch (err) { + res.status(502).json({ + 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, + startSessionForUser, + getRuntimeStatus, + submitSessionReplyForUser, + 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 fs6 from "node:fs"; +import path9 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 +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 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 + }; +} + +// user-space.mjs +import fs4 from "node:fs"; +import path7 from "node:path"; +var UPLOAD_ZONE_CODES = ["oa", "public"]; +function resolveMindspaceStorageRoot(h5Root, env = process.env) { + return path7.resolve(env.MINDSPACE_STORAGE_ROOT ?? path7.join(h5Root, "data", "mindspace")); +} +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 +- \u4EC5\u5728\u5F00\u573A\u6216\u7528\u6237\u6253\u62DB\u547C\u65F6\u4F7F\u7528\u65F6\u6BB5\u95EE\u5019\uFF0C\u4E14\u987B\u4E0E\u300CTKMind \u5F53\u524D\u65F6\u95F4\u57FA\u51C6\u300D\u4E00\u81F4\uFF08\u5982\u300C${name}\uFF0C\u65E9\u4E0A\u597D\u300D\uFF09\uFF1B\u666E\u901A\u56DE\u590D\u76F4\u63A5\u4F5C\u7B54\uFF0C\u4E0D\u8981\u6BCF\u6761\u90FD\u52A0\u95EE\u5019\uFF1B\u7981\u6B62\u628A\u7528\u6237\u53EB\u4F5C TKMind +- \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/\u516C\u5F00\u6587\u4EF6\u7BA1\u7406\u4E0E\u9759\u6001\u9875\u9762\u751F\u6210 +`; +} +function resolveZoneDir(workspaceRoot, categoryCode) { + return path7.join(workspaceRoot, categoryCode); +} +function resolveZoneFilePath(workspaceRoot, categoryCode, filename) { + return path7.join(resolveZoneDir(workspaceRoot, categoryCode), filename); +} +function zoneLabel(categoryCode) { + return SYSTEM_CATEGORIES.find((item) => item.code === categoryCode)?.name ?? categoryCode; +} +function ensureUserZoneDirs(workspaceRoot) { + for (const code of UPLOAD_ZONE_CODES) { + fs4.mkdirSync(resolveZoneDir(workspaceRoot, code), { recursive: true }); + } +} +function mirrorAssetToZone({ workspaceRoot, categoryCode, filename, sourcePath }) { + if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return null; + if (!sourcePath || !fs4.existsSync(sourcePath)) return null; + ensureUserZoneDirs(workspaceRoot); + const dest = resolveZoneFilePath(workspaceRoot, categoryCode, filename); + fs4.mkdirSync(path7.dirname(dest), { recursive: true }); + fs4.copyFileSync(sourcePath, dest); + return dest; +} +function isPathInsideUserWorkspace(workspaceRoot, requestedPath) { + const base = path7.resolve(workspaceRoot); + const resolved = path7.resolve(requestedPath); + return resolved === base || resolved.startsWith(`${base}${path7.sep}`); +} +function renderUserSpaceHints({ username, workspaceRoot, displayName, slug }) { + const addressName = resolveUserAddressName({ displayName, username, slug }); + 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 +- \u5982\u679C\u9875\u9762\u91CC\u8FD8\u8981\u63D0\u4F9B Word / docx \u4E0B\u8F7D\uFF1A\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`docx-generate\`\uFF0C\u751F\u6210 \`public/\u6587\u4EF6\u540D.docx\` \u5E76\u786E\u8BA4\u6587\u4EF6\u5B58\u5728\uFF0C\u518D\u5199 HTML \u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863 +- \u9875\u9762\u9700\u8981 Word / docx \u4E0B\u8F7D\u65F6\uFF0C\u5FC5\u987B\u7528 sandbox-fs \u7684 \`generate_docx\` \u5199\u5165 \`public/*.docx\`\uFF0C\u4E0D\u8981\u7528 shell/computercontroller \u751F\u6210\u751F\u4EA7\u4E0B\u8F7D\u6587\u4EF6 +- \u82E5\u7528 \`apps__create_app\` \u8BBE\u8BA1/\u9884\u89C8\u9875\u9762\uFF1A\u8FD9\u4E00\u6B65\u53EA\u662F\u5728 Apps \u7A97\u53E3\u5185\u521B\u5EFA\u4EA4\u4E92\u5F0F App\uFF0C**\u8FD8\u6CA1\u6709\u516C\u7F51\u94FE\u63A5**\uFF1B\u5FC5\u987B\u7D27\u63A5\u7740\u6309 \`static-page-publish\` skill \u628A\u6700\u7EC8\u5185\u5BB9 \`write_file\` \u843D\u5230 \`public/*.html\`\uFF0C\u624D\u80FD\u7ED9\u51FA\u771F\u5B9E\u53EF\u8BBF\u95EE\u7684\u94FE\u63A5 +- \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\` +- \u7ED9\u94FE\u63A5\u65F6\u6309 \`static-page-publish\` skill \u91CC\u7684\u516C\u7F51\u524D\u7F00\u6A21\u677F\u62FC\u63A5\u771F\u5B9E\u5730\u5740\uFF0C\u4E0D\u786E\u5B9A\u57DF\u540D\u65F6\u7528\u76F8\u5BF9\u8DEF\u5F84 \`public/xxx.html\` \u8BF4\u660E\uFF0C\u4E0D\u8981\u731C\u4E00\u4E2A\u57DF\u540D\u5145\u6570 +- **\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/\` \u4E0B\u4EFB\u610F\u5C42\u7EA7\u7684\u652F\u6301\u7C7B\u578B\u6587\u4EF6\uFF08docx\u3001md\u3001txt\u3001csv\u3001pdf\u3001\u56FE\u7247\u7B49\uFF09\u4F1A**\u81EA\u52A8\u540C\u6B65**\u5230 MindSpace \u8D44\u4EA7\u5E93\uFF08\u542B\u5B50\u76EE\u5F55\uFF0C\u5982 \`oa/\u8BD7\u6B4C\u6563\u6587/\u590F\u65E5\u7684\u8BD7\u7BC7.md\`\uFF09 +- \u6253\u5F00\u5BF9\u5E94\u5206\u533A\u6216\u4FDD\u5B58\u6587\u4EF6\u540E\u4F1A\u51FA\u73B0\u5728\u754C\u9762\u4E2D\uFF0C\u53EF\u76F4\u63A5\u9884\u89C8\u6216\u4E0B\u8F7D +- \u751F\u6210 docx/pdf \u7B49 OA \u8D44\u6599\u65F6\uFF0C\u53EF\u76F4\u63A5 \`write_file\` \u5230 \`oa/<\u5B50\u76EE\u5F55>/<\u6587\u4EF6\u540D>\`\uFF0C\u65E0\u9700\u624B\u52A8\u590D\u5236\u5230\u6839\u76EE\u5F55 +- \u7528\u6237\u4E0A\u4F20\u7684\u6587\u4EF6\u4ECD\u4EE5\u754C\u9762\u5165\u5E93\u4E3A\u51C6\uFF0C\u5E76\u955C\u50CF\u5230\u4E0A\u8FF0\u5206\u533A +`; +} +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", + "- **Word \u4E0B\u8F7D\u9875**\uFF1A\u82E5\u7528\u6237\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 `load_skill` \u2192 `docx-generate` \u751F\u6210 `public/*.docx` \u5E76\u786E\u8BA4\u6587\u4EF6\u5B58\u5728\uFF0C\u518D\u5728 HTML \u91CC\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863", + "- \u7528 `apps__create_app` \u8BBE\u8BA1\u9875\u9762\u65F6\uFF0C\u6700\u540E\u4E00\u6B65\u4ECD\u8981\u6309 `static-page-publish` skill \u628A\u5185\u5BB9 `write_file` \u843D\u5230 `public/*.html`\uFF0C\u518D\u7ED9\u51FA\u4E0B\u65B9\u524D\u7F00\u62FC\u51FA\u7684\u771F\u5B9E\u94FE\u63A5\uFF0C\u4E0D\u8981\u505C\u5728 App \u9636\u6BB5\u5C31\u56DE\u590D\u94FE\u63A5", + "- **\u9700\u8981\u67E5\u5B9E\u65F6/\u771F\u5B9E\u4E16\u754C\u4FE1\u606F\uFF08\u5982\u673A\u6784\u540D\u5355\u3001\u65B0\u95FB\u3001\u884C\u60C5\uFF09\u65F6**\uFF1A\u5148 `load_skill` \u2192 `web`\uFF0C\u4F18\u5148\u7528\u5176 `web_search`/`fetch_url`\uFF1B\u56FD\u5185\u73AF\u5883\u4E0B google.com \u4E0D\u53EF\u8FBE\uFF0C`web_search` \u591A\u6B21\u65E0\u679C\u65F6\u6539\u8D70 Bing/360 \u7B49\u53EF\u8FBE\u641C\u7D22\u6E90\uFF0C\u907F\u514D\u5BF9\u53CD\u722C\u7AD9\u70B9\u53CD\u590D\u786C\u6293", + "- **Word \u4E0B\u8F7D\u9875**\uFF1A\u82E5\u7528\u6237\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 `load_skill` \u2192 `docx-generate` \u751F\u6210 `public/*.docx` \u5E76\u786E\u8BA4\u6587\u4EF6\u5B58\u5728\uFF0C\u518D\u5728 HTML \u91CC\u7528\u76F8\u5BF9\u8DEF\u5F84\u94FE\u63A5\u8BE5\u6587\u6863\uFF1B\u4E0D\u8981\u7528 shell/computercontroller \u751F\u6210\u751F\u4EA7\u4E0B\u8F7D\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 = path7.join(workspaceRoot, WORKSPACE_HINTS_FILENAME2); + const legacyPath = path7.join(workspaceRoot, LEGACY_WORKSPACE_HINTS_FILENAME2); + const content = renderUserSpaceHints({ ...context, workspaceRoot }); + fs4.writeFileSync(hintsPath, content, "utf8"); + if (fs4.existsSync(legacyPath)) { + fs4.unlinkSync(legacyPath); + } + return hintsPath; +} +async function syncUserZonesFromAssets(pool2, storageRoot, userId, workspaceRoot) { + ensureUserZoneDirs(workspaceRoot); + const placeholders = UPLOAD_ZONE_CODES.map(() => "?").join(", "); + const [rows] = await pool2.query( + `SELECT a.original_filename, c.category_code, v.storage_key + FROM h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + 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 = path7.join(storageRoot, row.storage_key); + mirrorAssetToZone({ + workspaceRoot, + categoryCode: row.category_code, + filename: row.original_filename, + sourcePath + }); + } + return { workspaceRoot, mirrored: rows.length }; +} +async function ensureUserSpaceLayout({ + pool: pool2, + storageRoot, + userId, + username, + displayName, + publicBaseUrl, + slug, + workspaceRoot +}) { + ensureUserZoneDirs(workspaceRoot); + if (pool2 && userId) { + await syncUserZonesFromAssets(pool2, 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 fs5 from "node:fs"; +import path8 from "node:path"; +import { fileURLToPath as fileURLToPath4 } from "node:url"; +var __dirname3 = path8.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, + "docx-generate": 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 = path8.join(h5Root, "skills"); + if (!fs5.existsSync(skillsRoot)) return []; + const catalog = []; + for (const entry of fs5.readdirSync(skillsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const skillPath = path8.join(skillsRoot, entry.name, "SKILL.md"); + if (!fs5.existsSync(skillPath)) continue; + const raw = fs5.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) { + fs5.mkdirSync(destDir, { recursive: true }); + for (const entry of fs5.readdirSync(srcDir, { withFileTypes: true })) { + const from = path8.join(srcDir, entry.name); + const to = path8.join(destDir, entry.name); + if (entry.isDirectory()) { + copySkillTree(from, to); + } else { + fs5.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 = path8.join(publishDir, ".agents", "skills"); + if (fs5.existsSync(agentsSkills)) { + for (const entry of fs5.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)) { + fs5.rmSync(path8.join(agentsSkills, entry.name), { recursive: true, force: true }); + } + } + } + for (const item of catalog) { + if (!enabled.has(item.name)) continue; + const srcDir = path8.join(h5Root, "skills", item.dirName); + const destDir = path8.join(agentsSkills, item.name); + if (item.name === PUBLISH_SKILL_NAME && user) { + ensurePublishSkillInstalled(publishDir, { + slug: String(user.id).trim().toLowerCase(), + username: user.username ? String(user.username).trim().toLowerCase() : void 0, + publicBaseUrl, + publishDir + }); + continue; + } + if (fs5.existsSync(srcDir)) { + if (fs5.existsSync(destDir)) fs5.rmSync(destDir, { recursive: true, force: true }); + copySkillTree(srcDir, destDir); + } + } +} + +// user-auth.mjs +var USER_COOKIE = "tkmind_user_session"; +function safeEqual(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 safeEqual(hashPasswordArgon2id(password, row.salt), row.password_hash); + } + return safeEqual(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(pool2, options = {}) { + const usersRoot = path9.resolve(options.usersRoot ?? "/tmp/tkmind_go_users"); + const h5Root = path9.resolve(options.h5Root ?? path9.join(usersRoot, "..")); + const env = options.env ?? process.env; + const storageRoot = resolveMindspaceStorageRoot(h5Root, env); + const publicBaseUrl = resolvePublicBaseUrl(env); + const skillCatalog = listPlatformSkillCatalog(h5Root); + const defaultSignupBalanceCents = Number(options.defaultSignupBalanceCents ?? 500); + const lowBalanceGiftThresholdCents = Number(options.lowBalanceGiftThresholdCents ?? 100); + const lowBalanceGiftAmountCents = Number(options.lowBalanceGiftAmountCents ?? 1e3); + 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(pool2); + let rechargeNotifier = typeof options.onRechargeNotification === "function" ? options.onRechargeNotification : null; + const subscriptionService = 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 pool2.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 pool2.query( + `UPDATE h5_login_sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`, + [now, userId] + ); + }; + const ensureWorkspace = (workspaceRoot) => { + fs6.mkdirSync(workspaceRoot, { recursive: true }); + }; + const isAdminRole = (user) => user?.role === "admin"; + const getUserById = async (userId) => { + const [rows] = await pool2.query( + `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, + u.plan_type, u.workspace_root, + s.quota_bytes, s.used_bytes, s.reserved_bytes, + 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: pool2, + 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 && fs6.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 pool2.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 = path9.resolve(user.workspace_root); + const target = path9.resolve(layout.publishDir); + if (current !== target) { + const now = Date.now(); + await pool2.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [ + layout.publishDir, + now, + user.id + ]); + await pool2.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [user.id]); + await pool2.query( + `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, + [user.id, layout.publishDir] + ); + } + 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 shouldEnableLowBalanceGift = ({ isAdmin = false, initialBalanceCents }) => { + return !isAdmin && Number(initialBalanceCents) === defaultSignupBalanceCents && defaultSignupBalanceCents > 0; + }; + const grantLowBalanceGiftIfNeeded = async (conn, { userId, currentBalance, nextBalance, now }) => { + if (lowBalanceGiftAmountCents <= 0 || lowBalanceGiftThresholdCents < 0 || nextBalance > lowBalanceGiftThresholdCents) { + return { gifted: false, balanceAfter: nextBalance }; + } + const [rows] = await conn.query( + `SELECT low_balance_gift_eligible, low_balance_gift_granted_at + FROM h5_users + WHERE id = ? + FOR UPDATE`, + [userId] + ); + const user = rows[0]; + if (!user || !Boolean(user.low_balance_gift_eligible) || user.low_balance_gift_granted_at != null) { + return { gifted: false, balanceAfter: nextBalance }; + } + const giftedBalance = nextBalance + lowBalanceGiftAmountCents; + await conn.query( + `UPDATE h5_user_wallets + SET balance_cents = ?, updated_at = ? + WHERE user_id = ?`, + [giftedBalance, now, userId] + ); + await conn.query( + `UPDATE h5_users + SET low_balance_gift_eligible = 0, + low_balance_gift_granted_at = ?, + status = CASE WHEN status = 'suspended' THEN 'active' ELSE status END, + updated_at = ? + WHERE id = ?`, + [now, now, userId] + ); + await conn.query( + `INSERT INTO h5_billing_ledger + (user_id, type, amount_cents, tokens, note, operator_id, created_at) + VALUES (?, 'adjust', ?, 0, ?, NULL, ?)`, + [userId, lowBalanceGiftAmountCents, "\u65B0\u7528\u6237\u4F4E\u4F59\u989D\u81EA\u52A8\u8D60\u9001", 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', 'low_balance_gift', ?, ?, ?, 'unread', NULL, ?, ?)`, + [ + crypto4.randomUUID(), + userId, + "\u65B0\u7528\u6237\u989D\u5EA6\u5DF2\u81EA\u52A8\u8865\u9001", + `\u68C0\u6D4B\u5230\u4F60\u7684\u4F59\u989D\u5DF2\u4F4E\u4E8E \xA5${(lowBalanceGiftThresholdCents / 100).toFixed(2)}\uFF0C\u7CFB\u7EDF\u5DF2\u81EA\u52A8\u8D60\u9001 \xA5${(lowBalanceGiftAmountCents / 100).toFixed(2)} \u65B0\u7528\u6237\u989D\u5EA6\u3002\u672C\u798F\u5229\u4EC5\u53EF\u9886\u53D6\u4E00\u6B21\u3002`, + JSON.stringify({ + triggerBalanceCents: nextBalance, + previousBalanceCents: currentBalance, + giftAmountCents: lowBalanceGiftAmountCents, + thresholdCents: lowBalanceGiftThresholdCents + }), + now, + now + ] + ); + return { gifted: true, balanceAfter: giftedBalance }; + }; + 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 pool2.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, low_balance_gift_eligible, low_balance_gift_granted_at, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'user', 'active', 'free', ?, 1, NULL, ?, ?)`, + [ + 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 (subscriptionService) { + subscriptionService.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 pool2.query( + `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, + u.plan_type, u.workspace_root, + u.salt, u.password_hash, u.password_algorithm, w.balance_cents, w.tokens_used + 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 pool2.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 pool2.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 pool2.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 pool2.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 pool2.query( + `SELECT s.user_id, s.expires_at, u.role, u.status + FROM h5_login_sessions s + JOIN h5_users u ON u.id = s.user_id + 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 pool2.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 pool2.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 pool2.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 pool2.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 pool2.query( + `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) + VALUES ('role', 'user', ?, ?, ?) + ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`, + [name, enabled ? 1 : 0, now] + ); + } + }; + const registerAgentSession = async (userId, agentSessionId, goosedTarget = 0) => { + const isLegacyIndex = typeof goosedTarget === "number" || /^\d+$/.test(String(goosedTarget)); + const goosedNode = isLegacyIndex ? Number(goosedTarget) : 0; + const targetUrl = isLegacyIndex ? null : String(goosedTarget); + await pool2.query( + `INSERT INTO h5_user_sessions (agent_session_id, user_id, goosed_node, goosed_target, created_at) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), + goosed_node = VALUES(goosed_node), goosed_target = VALUES(goosed_target)`, + [agentSessionId, userId, goosedNode, targetUrl, Date.now()] + ); + }; + const getSessionNode = async (agentSessionId) => { + const [rows] = await pool2.query( + `SELECT goosed_node FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, + [agentSessionId] + ); + return rows[0]?.goosed_node ?? 0; + }; + const getSessionTarget = async (agentSessionId) => { + const [rows] = await pool2.query( + `SELECT goosed_node, goosed_target FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`, + [agentSessionId] + ); + return { target: rows[0]?.goosed_target ?? null, node: rows[0]?.goosed_node ?? 0 }; + }; + const ownsSession = async (userId, agentSessionId) => { + const [rows] = await pool2.query( + `SELECT 1 FROM h5_user_sessions WHERE agent_session_id = ? AND user_id = ? LIMIT 1`, + [agentSessionId, userId] + ); + return rows.length > 0; + }; + const listOwnedSessionIds = async (userId) => { + const [rows] = await pool2.query( + `SELECT agent_session_id FROM h5_user_sessions WHERE user_id = ?`, + [userId] + ); + return new Set(rows.map((row) => row.agent_session_id)); + }; + const setSessionOrigin = async (agentSessionId, origin) => { + if (!agentSessionId || origin !== "h5" && origin !== "wechat") return; + await pool2.query( + `UPDATE h5_user_sessions SET origin = ? WHERE agent_session_id = ?`, + [origin, agentSessionId] + ); + }; + const getSessionOrigins = async (agentSessionIds = []) => { + const ids = [...new Set(agentSessionIds)].filter(Boolean); + if (ids.length === 0) return /* @__PURE__ */ new Map(); + const [rows] = await pool2.query( + `SELECT agent_session_id, origin FROM h5_user_sessions WHERE agent_session_id IN (?)`, + [ids] + ); + return new Map(rows.map((row) => [row.agent_session_id, row.origin])); + }; + const unregisterAgentSession = async (userId, agentSessionId) => { + await pool2.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 (subscriptionService) { + const sub = await subscriptionService.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 pool2.query( + `SELECT COUNT(*) AS total FROM h5_users u ${where}`, + params + ); + const [rows] = await pool2.query( + `SELECT u.id, u.username, u.slug, u.email, u.display_name, u.role, u.status, + u.plan_type, u.workspace_root, + s.quota_bytes, s.used_bytes, s.reserved_bytes, + 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 root2 = (await publishLayoutFor({ id: userId, username: normalized })).publishDir; + const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password); + const now = Date.now(); + const conn = await pool2.getConnection(); + const initialBalanceCents = Number(balanceCents ?? defaultSignupBalanceCents); + 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, low_balance_gift_eligible, low_balance_gift_granted_at, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', 'free', ?, ?, NULL, ?, ?)`, + [ + userId, + normalized, + normalized, + email?.trim().toLowerCase() || null, + displayName?.trim() || normalized, + salt, + passwordHash, + passwordAlgorithm, + isAdmin ? "admin" : "user", + root2, + shouldEnableLowBalanceGift({ isAdmin, initialBalanceCents }) ? 1 : 0, + now, + now + ] + ); + await conn.query( + `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) + VALUES (?, ?, 0, ?)`, + [userId, initialBalanceCents, now] + ); + await conn.query( + `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, + [userId, root2] + ); + if (!isAdmin) { + await initializeDefaultSpace(conn, userId, { + quotaBytes: Number(process.env.MINDSPACE_FREE_QUOTA_BYTES ?? 5 * 1024 * 1024), + now + }); + } + await conn.commit(); + ensureWorkspace(root2); + if (subscriptionService && !isAdmin) { + subscriptionService.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 root2 = path9.resolve(patch.workspaceRoot); + fields.push("workspace_root = ?"); + values.push(root2); + ensureWorkspace(root2); + await pool2.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [userId]); + await pool2.query( + `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, + [userId, root2] + ); + } + 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 pool2.query(`UPDATE h5_users SET ${fields.join(", ")} WHERE id = ?`, values); + } + if (patch.status === "disabled" || patch.status === "suspended") { + await revokeAllSessionsForUser(userId, now); + } + if (patch.balanceCents !== void 0) { + await pool2.query( + `INSERT INTO h5_user_wallets (user_id, balance_cents, tokens_used, updated_at) + VALUES (?, ?, 0, ?) + ON DUPLICATE KEY UPDATE balance_cents = VALUES(balance_cents), updated_at = VALUES(updated_at)`, + [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 pool2.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 pool2.query( + `UPDATE h5_user_spaces + SET quota_bytes = ?, updated_at = ? + WHERE user_id = ?`, + [quotaBytes, now, userId] + ); + } else { + await initializeDefaultSpace(pool2, 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 pool2.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 pool2.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 pool2.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, + dedupeKey: paymentOrderId ? `recharge:${paymentOrderId}` : null, + 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 pool2.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 config = loadBillingConfig(); + const normalizedRequestId = requestId ? String(requestId).trim() || null : null; + const now = Date.now(); + const conn = await pool2.getConnection(); + try { + await conn.beginTransaction(); + if (normalizedRequestId) { + const [existingUsage] = await conn.query( + `SELECT cost_cents FROM h5_usage_records WHERE request_id = ? LIMIT 1`, + [normalizedRequestId] + ); + if (existingUsage[0]) { + const [walletRows] = await conn.query( + `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ?`, + [userId] + ); + await conn.commit(); + return { + ok: true, + costCents: 0, + balanceCents: walletRows[0] ? Number(walletRows[0].balance_cents) : null, + tokensUsed: walletRows[0] ? Number(walletRows[0].tokens_used ?? 0) : null, + deltaInputTokens: 0, + deltaOutputTokens: 0 + }; + } + } + await conn.query( + `INSERT INTO h5_session_billing_state + (agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at) + VALUES (?, ?, NULL, 0, 0, ?) + ON DUPLICATE KEY UPDATE agent_session_id = agent_session_id`, + [agentSessionId, userId, now] + ); + const [stateRows] = await conn.query( + `SELECT last_accumulated_cost, last_input_tokens, last_output_tokens + FROM h5_session_billing_state + WHERE agent_session_id = ? + FOR UPDATE`, + [agentSessionId] + ); + const stateRow = stateRows[0]; + const previous = stateRow ? { + lastAccumulatedCost: stateRow.last_accumulated_cost, + lastInputTokens: Number(stateRow.last_input_tokens ?? 0), + lastOutputTokens: Number(stateRow.last_output_tokens ?? 0) + } : null; + if (previous && tokenState.accumulatedInputTokens <= Number(previous.lastInputTokens ?? 0) && tokenState.accumulatedOutputTokens <= Number(previous.lastOutputTokens ?? 0)) { + const [walletRows] = await conn.query( + `SELECT balance_cents, tokens_used FROM h5_user_wallets WHERE user_id = ?`, + [userId] + ); + await conn.commit(); + return { + ok: true, + costCents: 0, + balanceCents: walletRows[0] ? Number(walletRows[0].balance_cents) : null, + tokensUsed: walletRows[0] ? Number(walletRows[0].tokens_used ?? 0) : null, + deltaInputTokens: 0, + deltaOutputTokens: 0 + }; + } + let costCents = computeDeltaCostCents(previous, tokenState, config); + const deltaIn = Math.max( + 0, + tokenState.accumulatedInputTokens - Number(previous?.lastInputTokens ?? 0) + ); + const deltaOut = Math.max( + 0, + tokenState.accumulatedOutputTokens - Number(previous?.lastOutputTokens ?? 0) + ); + const deltaTokens = deltaIn + deltaOut; + await conn.query( + `INSERT INTO h5_session_billing_state + (agent_session_id, user_id, last_accumulated_cost, last_input_tokens, last_output_tokens, updated_at) + 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 && subscriptionService) { + const coverage = await subscriptionService.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 w.balance_cents, w.tokens_used, u.status + FROM h5_user_wallets w + JOIN h5_users u ON u.id = w.user_id + WHERE w.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, + normalizedRequestId, + 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 + ] + ); + const lowBalanceGift = await grantLowBalanceGiftIfNeeded(conn, { + userId, + currentBalance, + nextBalance, + now + }); + balanceAfter = lowBalanceGift.balanceAfter; + if (balanceAfter <= 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 pool2.query( + `SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id, + r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at + FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id + ${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 pool2.query( + `SELECT COUNT(*) AS total FROM h5_usage_records r ${where}`, + params + ); + const [rows] = await pool2.query( + `SELECT r.id, r.user_id, u.username, r.agent_session_id, r.request_id, + r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at + FROM h5_usage_records r JOIN h5_users u ON u.id = r.user_id + ${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 pool2.query(`SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens, l.session_id, l.note, l.created_at FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id ${where2} ORDER BY l.created_at DESC LIMIT ${safeLimit}`, params); + return rows2.map(mapRow); + } + const safePageSize = Math.min(Math.max(Number(pageSize) || 50, 1), 200); + const safePage = Math.max(Number(page) || 1, 1); + const offset = (safePage - 1) * safePageSize; + const countParams = []; + const where = buildWhere(countParams); + const [[{ total }]] = await pool2.query( + `SELECT COUNT(*) AS total FROM h5_billing_ledger l ${where}`, + countParams + ); + const dataParams = []; + const dataWhere = buildWhere(dataParams); + const [rows] = await pool2.query( + `SELECT l.id, l.user_id, u.username, l.type, l.amount_cents, l.tokens, + l.session_id, l.note, l.created_at + FROM h5_billing_ledger l JOIN h5_users u ON u.id = l.user_id + ${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 pool2.query( + `SELECT u.id, u.username, u.display_name, u.role, u.status, + COALESCE(w.balance_cents, 0) AS balance_cents + FROM h5_users u + 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 pool2.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 pool2.query( + `SELECT id FROM h5_users WHERE username = ? AND role = 'admin' LIMIT 1`, + [adminUsername] + ); + if (rows.length === 0) return; + const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(adminPassword); + const now = Date.now(); + await pool2.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 pool2.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('role', 'user', ?, ?, ?) + ON DUPLICATE KEY UPDATE capability_key = capability_key`, + [key, allowed ? 1 : 0, now] + ); + } + }; + const upgradeMemoryStoreCapability = async () => { + const now = Date.now(); + await pool2.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('role', 'user', 'memory_store', 1, ?) + ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`, + [now] + ); + }; + const upgradeDefaultUserCapabilities = async () => { + const now = Date.now(); + for (const key of ["skills", "chat_recall"]) { + if (!DEFAULT_USER_CAPABILITIES[key]) continue; + await pool2.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('role', 'user', ?, 1, ?) + ON DUPLICATE KEY UPDATE allowed = 1, updated_at = VALUES(updated_at)`, + [key, now] + ); + } + }; + const upgradeDefaultUserSkills = async () => { + const now = Date.now(); + for (const [name, enabled] of Object.entries(DEFAULT_USER_SKILLS)) { + if (!enabled) continue; + await pool2.query( + `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) + VALUES ('role', 'user', ?, 1, ?) + ON DUPLICATE KEY UPDATE enabled = 1, updated_at = VALUES(updated_at)`, + [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 pool2.query( + `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at) + VALUES ('role', 'user', ?, ?, ?) + ON DUPLICATE KEY UPDATE policy_key = policy_key`, + [key, serializePolicyValue(key, value), now] + ); + } + }; + const listPolicyEntries = async (subjectType, subjectId) => { + const [rows] = await pool2.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 pool2.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, { toolMode = "chat" } = {}) => { + 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, toolMode }), + policies: {}, + unrestricted: true, + toolMode + }; + } + 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 = { + // When goosed runs in a container its filesystem is split from the portal's, + // so the host paths the portal would otherwise send (node binary, MCP script) + // are not resolvable. These env overrides let the portal send container-canonical + // paths instead. Unset (e.g. local dev, co-located native goosed) => fall back to + // the portal's own paths, so behavior is unchanged. + serverPath: resolveSandboxMcpServerPath(process.env.GOOSED_MCP_SERVER_PATH), + sandboxRoot: resolveGoosedSandboxRoot(h5Root, user), + userId: user.id, + nodeExecPath: process.env.GOOSED_MCP_NODE_PATH + }; + } catch (err) { + console.warn("[getAgentSessionPolicy] sandbox MCP setup failed, falling back:", err?.message); + } + } + return { + ...buildAgentExtensionPolicy(effectiveCapabilities, { + unrestricted: false, + policies: policyState.policies, + sandboxMcp, + toolMode + }), + capabilities: effectiveCapabilities, + policies: policyState.policies, + unrestricted: false, + toolMode + }; + }; + const getCodeAgentSessionPolicy = async (userId) => getAgentSessionPolicy(userId, { toolMode: "code" }); + 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 pool2.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('role', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`, + [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 pool2.query( + `INSERT INTO h5_capability_grants (subject_type, subject_id, capability_key, allowed, updated_at) + VALUES ('user', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE allowed = VALUES(allowed), updated_at = VALUES(updated_at)`, + [userId, key, effectiveAllowed ? 1 : 0, now] + ); + } + return getUserCapabilities(userId); + }; + const clearUserCapabilityOverrides = async (userId) => { + await pool2.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 pool2.query( + `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at) + VALUES ('role', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`, + [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 pool2.query( + `INSERT INTO h5_user_policies (subject_type, subject_id, policy_key, policy_value, updated_at) + VALUES ('user', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE policy_value = VALUES(policy_value), updated_at = VALUES(updated_at)`, + [userId, key, serializePolicyValue(key, value), now] + ); + } + return getUserPolicies(userId); + }; + const clearUserPolicyOverrides = async (userId) => { + await pool2.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 pool2.query( + `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) + VALUES ('role', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`, + [role, name, enabled ? 1 : 0, now] + ); + } + const [users] = await pool2.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 pool2.query( + `INSERT INTO h5_user_skill_grants (subject_type, subject_id, skill_name, enabled, updated_at) + VALUES ('user', ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), updated_at = VALUES(updated_at)`, + [userId, name, enabled ? 1 : 0, now] + ); + } + await syncUserSkillsForUser(user); + return getUserSkills(userId); + }; + const clearUserSkillOverrides = async (userId) => { + await pool2.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 pool2.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 pool2.query(`UPDATE h5_users SET workspace_root = ?, updated_at = ? WHERE id = ?`, [ + adminLayout.publishDir, + now, + adminId + ]); + await pool2.query(`DELETE FROM h5_user_path_grants WHERE user_id = ?`, [adminId]); + await pool2.query( + `INSERT INTO h5_user_path_grants (user_id, path, mode) VALUES (?, ?, 'readwrite')`, + [adminId, adminLayout.publishDir] + ); + await pool2.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 pool2.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 pool2.query( + `SELECT wi.user_id, wi.app_id, u.status + FROM h5_user_wechat_identities wi + JOIN h5_users u ON u.id = wi.user_id + WHERE wi.unionid = ? + LIMIT 1`, + [unionid] + ); + return rows[0] ?? null; + }; + const getWechatBindingForUser = async (userId, appId) => { + const [rows] = await pool2.query( + `SELECT id, nickname, avatar_url, last_login_at, created_at + FROM h5_user_wechat_identities + WHERE user_id = ? AND app_id = ? + LIMIT 1`, + [userId, appId] + ); + return rows[0] ?? null; + }; + const getWechatOpenidForUser = async (userId, appId) => { + const [rows] = await pool2.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 pool2.query( + `SELECT wi.user_id, wi.nickname, u.username, u.slug, u.display_name, u.status + FROM h5_user_wechat_identities wi + JOIN h5_users u ON u.id = wi.user_id + 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 pool2.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 pool2.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 pool2.query(`DELETE FROM h5_wechat_agent_routes WHERE app_id = ? AND openid = ?`, [ + appId, + openid + ]); + }; + const touchWechatAgentRoute = async (appId, openid, now = Date.now()) => { + if (!appId || !openid) return; + await pool2.query( + `UPDATE h5_wechat_agent_routes SET updated_at = ? WHERE app_id = ? AND openid = ?`, + [now, appId, openid] + ); + }; + const recordWechatMpMessage = async ({ + appId, + openid, + msgId, + now = Date.now() + }) => { + if (!appId || !openid || !msgId) return { inserted: true }; + const [result] = await pool2.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 pool2.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 pool2.query( + `UPDATE h5_wechat_mp_messages + SET status = ?, agent_session_id = COALESCE(?, agent_session_id), updated_at = ? + WHERE app_id = ? AND openid = ? AND msg_id = ?`, + [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 pool2.query( + `INSERT INTO h5_wechat_mp_message_details + (id, app_id, openid, user_id, msg_id, msg_type, display_text, agent_text, + media_id, media_url, media_public_url, media_format, + 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 pool2.query( + `INSERT INTO h5_user_wechat_identities + (id, user_id, app_id, openid, unionid, nickname, avatar_url, last_login_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + crypto4.randomUUID(), + 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 pool2.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 pool2.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 pool2.query( + `INSERT INTO h5_wechat_pending_binds + (token, app_id, openid, unionid, nickname, avatar_url, return_to, + utm_source, utm_medium, utm_campaign, expires_at, created_at) + 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 pool2.query( + `SELECT token, app_id, openid, unionid, nickname, avatar_url, return_to, + utm_source, utm_medium, utm_campaign, expires_at + FROM h5_wechat_pending_binds + 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 pool2.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 pool2.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [ + candidate + ]); + if (!rows[0]) return candidate; + candidate = `wx_${suffix.slice(0, Math.max(1, 8 - attempt))}${crypto4.randomBytes(2).toString("hex")}`.slice( + 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 pool2.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, low_balance_gift_eligible, + low_balance_gift_granted_at, created_at, updated_at) + VALUES (?, ?, ?, NULL, ?, ?, ?, ?, 'user', 'active', 'free', ?, 'wechat', 1, NULL, ?, ?)`, + [ + 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 (subscriptionService) { + subscriptionService.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, + touchWechatAgentRoute, + recordWechatMpMessage, + finishWechatMpMessage, + insertWechatMpMessageDetail, + resetPassword, + verify, + revoke, + revokeAllSessionsForUser, + getMe, + listPathGrants, + resolveWorkingDir, + getUserPublishLayout, + isPathAllowed, + repairAllUserPublishDirs, + registerAgentSession, + getSessionNode, + getSessionTarget, + unregisterAgentSession, + ownsSession, + listOwnedSessionIds, + setSessionOrigin, + getSessionOrigins, + canUseChat, + getUserById, + getUserPublic, + listUsers, + createUser, + updateUser, + purchaseSpaceQuota, + recharge, + billSessionUsage, + listUsageRecords, + listBillingLedger, + getAdminSummary, + ensureAdminUser, + seedRoleCapabilityDefaults, + resolveUserCapabilities, + getAgentSessionPolicy, + getCodeAgentSessionPolicy, + 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 + }; +} + +// scripts/agent-run-worker.mjs +var root = path10.join(path10.dirname(fileURLToPath5(import.meta.url)), ".."); +function loadEnvFile(filePath) { + if (!fs7.existsSync(filePath)) return; + for (const line of fs7.readFileSync(filePath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} +function parseTargets() { + const csv = String(process.env.TKMIND_API_TARGETS ?? "").trim(); + if (csv) { + return csv.split(",").map((item) => item.trim()).filter(Boolean); + } + return [process.env.TKMIND_API_TARGET ?? "https://127.0.0.1:18006"]; +} +function parseArgs(argv) { + const args2 = { + once: false, + status: false, + pollMs: Number(process.env.MEMIND_AGENT_RUN_WORKER_POLL_MS ?? 1e3), + limit: Number(process.env.MEMIND_AGENT_RUN_WORKER_BATCH_SIZE ?? 1) + }; + for (let i = 0; i < argv.length; i += 1) { + const item = argv[i]; + if (item === "--once") args2.once = true; + else if (item === "--status") args2.status = true; + else if (item === "--poll-ms") args2.pollMs = Number(argv[++i] ?? args2.pollMs); + else if (item === "--limit") args2.limit = Number(argv[++i] ?? args2.limit); + else if (item === "--help" || item === "-h") args2.help = true; + } + return args2; +} +function printHelp() { + console.log([ + "Usage:", + " node scripts/agent-run-worker.mjs [--once] [--status] [--poll-ms 1000] [--limit 1]", + "", + "Notes:", + " - Processes existing h5_agent_runs queued/retryable rows through the Tool Gateway queue.", + " - Does not create agent runs by itself.", + " - Set MEMIND_AGENT_RUN_AUTODISPATCH=0 on Portal only when this worker is ready to take over." + ].join("\n")); +} +loadEnvFile(process.env.MEMIND_ENV_FILE || path10.join(root, ".env")); +loadEnvFile(path10.join(root, ".env.local")); +var args = parseArgs(process.argv.slice(2)); +if (args.help) { + printHelp(); + process.exit(0); +} +var pool = createDbPool(); +var userAuth = createUserAuth(pool, { + usersRoot: process.env.H5_USERS_ROOT ?? path10.join(root, "users"), + h5Root: root +}); +var apiTargets = parseTargets(); +var tkmindProxy = createTkmindProxy({ + apiTarget: apiTargets[0], + apiTargets, + apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? "local-dev-secret", + userAuth +}); +var gateway = createAgentRunGateway({ + pool, + userAuth, + tkmindProxy, + autoDispatch: false, + maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1), + runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1e3) +}); +var stopping = false; +process.on("SIGINT", () => { + stopping = true; +}); +process.on("SIGTERM", () => { + stopping = true; +}); +async function runOnce() { + const result = await gateway.dispatchQueuedRuns({ limit: args.limit }); + console.log(JSON.stringify({ + ok: true, + mode: args.status ? "status" : "dispatch", + dispatched: args.status ? 0 : result.dispatched, + queue: result.queue + })); +} +try { + if (args.status) { + console.log(JSON.stringify({ ok: true, queue: await gateway.getQueueStatus() }, null, 2)); + } else if (args.once) { + await runOnce(); + } else { + console.log(JSON.stringify({ ok: true, worker: "agent-run-worker", pollMs: args.pollMs, limit: args.limit })); + while (!stopping) { + await runOnce(); + await new Promise((resolve) => setTimeout(resolve, Math.max(200, args.pollMs))); + } + } +} finally { + await pool.end().catch(() => { + }); +} diff --git a/.runtime/portal/server.mjs b/.runtime/portal/server.mjs index 7afe562..0610c15 100644 --- a/.runtime/portal/server.mjs +++ b/.runtime/portal/server.mjs @@ -6534,6 +6534,11 @@ function timeoutError(ms) { err.code = "AGENT_RUN_TIMEOUT"; return err; } +function envFlag(value, fallback = false) { + const raw = String(value ?? "").trim().toLowerCase(); + if (!raw) return fallback; + return ["1", "true", "yes", "on"].includes(raw); +} function normalizeAgentRunToolMode(value) { const normalized = String(value ?? "chat").trim().toLowerCase(); if (!normalized || normalized === "chat") return "chat"; @@ -6589,7 +6594,7 @@ function createAgentRunGateway({ userAuth: userAuth2, tkmindProxy: tkmindProxy2, retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS, - autoDispatch = true, + autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true), maxConcurrentRuns = positiveInteger( process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY, DEFAULT_MAX_CONCURRENT_RUNS @@ -6810,6 +6815,7 @@ function createAgentRunGateway({ statusCounts[row.status] = Number(row.count ?? 0); } return { + autoDispatch, maxConcurrentRuns, runTimeoutMs, inFlight: inFlight.size, @@ -6818,22 +6824,51 @@ function createAgentRunGateway({ terminalStatuses: [...TERMINAL_STATUSES] }; } + async function dispatchQueuedRuns({ limit = maxConcurrentRuns } = {}) { + const dispatchLimit = Math.max(1, Number(limit) || maxConcurrentRuns); + const available = Math.max(0, maxConcurrentRuns - inFlight.size - queuedDispatches.length); + if (available <= 0) { + return { + considered: 0, + dispatched: 0, + queue: await getQueueStatus() + }; + } + const take = Math.min(dispatchLimit, available); + const [rows] = await pool2.query( + `SELECT id + FROM h5_agent_runs + WHERE status IN ('queued', 'retryable') + ORDER BY updated_at ASC + LIMIT ?`, + [take] + ); + for (const row of rows) { + dispatchRun(row.id); + } + return { + considered: rows.length, + dispatched: rows.length, + queue: await getQueueStatus() + }; + } return { createRun, getRunForUser, dispatchRun, - getQueueStatus + getQueueStatus, + dispatchQueuedRuns }; } // agent-run-routes.mjs -function envFlag(value) { +function envFlag2(value) { return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase()); } function createPostAgentRunsHandler({ userAuth: userAuth2, agentRunGateway: agentRunGateway2, - codeRunsEnabled = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED) + codeRunsEnabled = envFlag2(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED) }) { return async function postAgentRuns(request, response) { try { @@ -35041,6 +35076,9 @@ async function bootstrapUserAuth() { pool: pool2, userAuth, tkmindProxy, + autoDispatch: ["1", "true", "yes", "on"].includes( + String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? "1").trim().toLowerCase() + ), maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1), runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1e3) }); diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index f716115..8299dda 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -35,6 +35,12 @@ function timeoutError(ms) { return err; } +function envFlag(value, fallback = false) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw) return fallback; + return ['1', 'true', 'yes', 'on'].includes(raw); +} + export function normalizeAgentRunToolMode(value) { const normalized = String(value ?? 'chat').trim().toLowerCase(); if (!normalized || normalized === 'chat') return 'chat'; @@ -101,7 +107,7 @@ export function createAgentRunGateway({ userAuth, tkmindProxy, retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS, - autoDispatch = true, + autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true), maxConcurrentRuns = positiveInteger( process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY, DEFAULT_MAX_CONCURRENT_RUNS, @@ -340,6 +346,7 @@ export function createAgentRunGateway({ statusCounts[row.status] = Number(row.count ?? 0); } return { + autoDispatch, maxConcurrentRuns, runTimeoutMs, inFlight: inFlight.size, @@ -349,10 +356,40 @@ export function createAgentRunGateway({ }; } + async function dispatchQueuedRuns({ limit = maxConcurrentRuns } = {}) { + const dispatchLimit = Math.max(1, Number(limit) || maxConcurrentRuns); + const available = Math.max(0, maxConcurrentRuns - inFlight.size - queuedDispatches.length); + if (available <= 0) { + return { + considered: 0, + dispatched: 0, + queue: await getQueueStatus(), + }; + } + const take = Math.min(dispatchLimit, available); + const [rows] = await pool.query( + `SELECT id + FROM h5_agent_runs + WHERE status IN ('queued', 'retryable') + ORDER BY updated_at ASC + LIMIT ?`, + [take], + ); + for (const row of rows) { + dispatchRun(row.id); + } + return { + considered: rows.length, + dispatched: rows.length, + queue: await getQueueStatus(), + }; + } + return { createRun, getRunForUser, dispatchRun, getQueueStatus, + dispatchQueuedRuns, }; } diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 40a46a7..51a866c 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -30,6 +30,14 @@ function createFakePool() { } return [[...counts].map(([status, count]) => ({ status, count }))]; } + if (sql.includes('SELECT id') && sql.includes("status IN ('queued', 'retryable')")) { + const limit = Number(params[0] ?? 1); + return [[...runs.values()] + .filter((row) => ['queued', 'retryable'].includes(row.status)) + .sort((a, b) => Number(a.updated_at) - Number(b.updated_at)) + .slice(0, limit) + .map((row) => ({ id: row.id }))]; + } if (sql.includes('INSERT INTO h5_agent_runs')) { const [ id, @@ -338,3 +346,77 @@ test('agent run queue status reports active database and local queue state', asy assert.equal(status.pendingDispatches, 0); assert.equal(status.statusCounts.queued, 1); }); + +test('external worker dispatches queued runs through the same queue controls', async () => { + const pool = createFakePool(); + const submitted = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: 'session-worker' }; + }, + async submitSessionReplyForUser(_userId, _sessionId, requestId) { + submitted.push(requestId); + }, + }, + autoDispatch: false, + retryDelaysMs: [], + maxConcurrentRuns: 1, + }); + + const run = await gateway.createRun('user-1', { + requestId: 'req-worker', + userMessage: { role: 'user', content: [] }, + }); + assert.equal(pool.runs.get(run.id).status, 'queued'); + + const result = await gateway.dispatchQueuedRuns({ limit: 10 }); + assert.equal(result.dispatched, 1); + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.deepEqual(submitted, ['req-worker']); +}); + +test('external worker does not dispatch more runs when local queue is full', async () => { + const pool = createFakePool(); + const release = []; + const gateway = createAgentRunGateway({ + pool, + userAuth: {}, + tkmindProxy: { + async startSessionForUser() { + return { id: `session-${release.length + 1}` }; + }, + async submitSessionReplyForUser() { + await new Promise((resolve) => release.push(resolve)); + }, + }, + autoDispatch: false, + retryDelaysMs: [], + maxConcurrentRuns: 1, + }); + + const run1 = await gateway.createRun('user-1', { + requestId: 'req-full-1', + userMessage: { role: 'user', content: [] }, + }); + const run2 = await gateway.createRun('user-1', { + requestId: 'req-full-2', + userMessage: { role: 'user', content: [] }, + }); + + try { + const first = await gateway.dispatchQueuedRuns({ limit: 10 }); + assert.equal(first.dispatched, 1); + await waitFor(() => pool.runs.get(run1.id)?.status === 'running'); + assert.equal((await gateway.getQueueStatus()).inFlight, 1); + const second = await gateway.dispatchQueuedRuns({ limit: 10 }); + assert.equal(second.dispatched, 0); + assert.equal(pool.runs.get(run2.id).status, 'queued'); + } finally { + while (release.length > 0) release.shift()(); + } + await waitFor(() => pool.runs.get(run1.id)?.status === 'succeeded'); + assert.equal(pool.runs.get(run2.id).status, 'queued'); +}); diff --git a/scripts/agent-run-worker.mjs b/scripts/agent-run-worker.mjs new file mode 100644 index 0000000..8391f1e --- /dev/null +++ b/scripts/agent-run-worker.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createAgentRunGateway } from '../agent-run-gateway.mjs'; +import { createDbPool } from '../db.mjs'; +import { createTkmindProxy } from '../tkmind-proxy.mjs'; +import { createUserAuth } from '../user-auth.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +function parseTargets() { + const csv = String(process.env.TKMIND_API_TARGETS ?? '').trim(); + if (csv) { + return csv.split(',').map((item) => item.trim()).filter(Boolean); + } + return [process.env.TKMIND_API_TARGET ?? 'https://127.0.0.1:18006']; +} + +function parseArgs(argv) { + const args = { + once: false, + status: false, + pollMs: Number(process.env.MEMIND_AGENT_RUN_WORKER_POLL_MS ?? 1000), + limit: Number(process.env.MEMIND_AGENT_RUN_WORKER_BATCH_SIZE ?? 1), + }; + for (let i = 0; i < argv.length; i += 1) { + const item = argv[i]; + if (item === '--once') args.once = true; + else if (item === '--status') args.status = true; + else if (item === '--poll-ms') args.pollMs = Number(argv[++i] ?? args.pollMs); + else if (item === '--limit') args.limit = Number(argv[++i] ?? args.limit); + else if (item === '--help' || item === '-h') args.help = true; + } + return args; +} + +function printHelp() { + console.log([ + 'Usage:', + ' node scripts/agent-run-worker.mjs [--once] [--status] [--poll-ms 1000] [--limit 1]', + '', + 'Notes:', + ' - Processes existing h5_agent_runs queued/retryable rows through the Tool Gateway queue.', + ' - Does not create agent runs by itself.', + ' - Set MEMIND_AGENT_RUN_AUTODISPATCH=0 on Portal only when this worker is ready to take over.', + ].join('\n')); +} + +loadEnvFile(process.env.MEMIND_ENV_FILE || path.join(root, '.env')); +loadEnvFile(path.join(root, '.env.local')); + +const args = parseArgs(process.argv.slice(2)); +if (args.help) { + printHelp(); + process.exit(0); +} + +const pool = createDbPool(); +const userAuth = createUserAuth(pool, { + usersRoot: process.env.H5_USERS_ROOT ?? path.join(root, 'users'), + h5Root: root, +}); +const apiTargets = parseTargets(); +const tkmindProxy = createTkmindProxy({ + apiTarget: apiTargets[0], + apiTargets, + apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret', + userAuth, +}); +const gateway = createAgentRunGateway({ + pool, + userAuth, + tkmindProxy, + autoDispatch: false, + maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1), + runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000), +}); + +let stopping = false; +process.on('SIGINT', () => { stopping = true; }); +process.on('SIGTERM', () => { stopping = true; }); + +async function runOnce() { + const result = await gateway.dispatchQueuedRuns({ limit: args.limit }); + console.log(JSON.stringify({ + ok: true, + mode: args.status ? 'status' : 'dispatch', + dispatched: args.status ? 0 : result.dispatched, + queue: result.queue, + })); +} + +try { + if (args.status) { + console.log(JSON.stringify({ ok: true, queue: await gateway.getQueueStatus() }, null, 2)); + } else if (args.once) { + await runOnce(); + } else { + console.log(JSON.stringify({ ok: true, worker: 'agent-run-worker', pollMs: args.pollMs, limit: args.limit })); + while (!stopping) { + await runOnce(); + await new Promise((resolve) => setTimeout(resolve, Math.max(200, args.pollMs))); + } + } +} finally { + await pool.end().catch(() => {}); +} diff --git a/scripts/build-portal-runtime.mjs b/scripts/build-portal-runtime.mjs index ecc5cbb..4e8f50a 100755 --- a/scripts/build-portal-runtime.mjs +++ b/scripts/build-portal-runtime.mjs @@ -119,6 +119,26 @@ async function bundleSandboxMcp() { await run(esbuildBin, args); } +async function bundleAgentRunWorker() { + if (!(await exists(esbuildBin))) { + throw new Error(`未找到 esbuild: ${esbuildBin}`); + } + console.log('==> 打包 agent run worker 为单文件 runtime'); + const args = [ + 'scripts/agent-run-worker.mjs', + '--bundle', + '--platform=node', + '--format=esm', + `--target=${runtimeNodeTarget}`, + '--outfile=.runtime/portal/scripts/agent-run-worker.mjs', + '--banner:js=import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);', + ]; + for (const pkg of externalPackages) { + args.push(`--external:${pkg}`); + } + await run(esbuildBin, args); +} + async function copyRuntimeAssets() { console.log('==> 拷贝运行时静态资源'); await remove(runtimeRoot); @@ -347,6 +367,8 @@ async function writeMetadata() { ' node scripts/check-tool-runtime.mjs', ' node scripts/check-agent-code-run-entry.mjs', ' curl -sk https://mm.tkmind.cn/api/runtime/status # includes toolRuntime.queue', + ' node scripts/agent-run-worker.mjs --status', + ' node scripts/agent-run-worker.mjs --once', '', ].join('\n'), ); @@ -357,6 +379,7 @@ async function main() { await copyRuntimeAssets(); await bundleServer(); await bundleSandboxMcp(); + await bundleAgentRunWorker(); if (runtimeInstallMode === 'bundle-node-modules' && !skipNodeModules) { await copyNodeModules(); } @@ -368,6 +391,7 @@ async function main() { await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-drain.mjs'), 0o755); await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-worker-metrics.mjs'), 0o755); await fs.chmod(path.join(runtimeRoot, 'scripts', 'runtime-slo-report.mjs'), 0o755); + await fs.chmod(path.join(runtimeRoot, 'scripts', 'agent-run-worker.mjs'), 0o755); await fs.chmod(path.join(runtimeRoot, 'scripts', 'install-runtime-metrics-agent.sh'), 0o755); await fs.chmod(path.join(runtimeRoot, 'scripts', 'check-tool-runtime.mjs'), 0o755); await fs.chmod(path.join(runtimeRoot, 'scripts', 'memind-portal-tunnel.sh'), 0o755); diff --git a/server.mjs b/server.mjs index 85b8d5b..3ddfae6 100644 --- a/server.mjs +++ b/server.mjs @@ -572,6 +572,9 @@ async function bootstrapUserAuth() { pool, userAuth, tkmindProxy, + autoDispatch: ['1', 'true', 'yes', 'on'].includes( + String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(), + ), maxConcurrentRuns: Number(process.env.MEMIND_AGENT_RUN_QUEUE_CONCURRENCY ?? 1), runTimeoutMs: Number(process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1000), });