9404 lines
380 KiB
JavaScript
Executable File
9404 lines
380 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);
|
|
|
|
// scripts/agent-run-worker.mjs
|
|
import fs9 from "node:fs";
|
|
import path12 from "node:path";
|
|
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
|
|
|
// agent-run-gateway.mjs
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
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;
|
|
var DEFAULT_RUN_HEARTBEAT_MS = 30 * 1e3;
|
|
var TOOL_GATEWAY_SUMMARY_LIMIT = 4096;
|
|
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 summarizeText(value, limit = TOOL_GATEWAY_SUMMARY_LIMIT) {
|
|
const text = String(value ?? "");
|
|
if (text.length <= limit) return text;
|
|
return text.slice(text.length - limit);
|
|
}
|
|
function normalizeExpectedFileCheck(value) {
|
|
if (typeof value === "string") {
|
|
const expectedPath2 = value.trim();
|
|
return expectedPath2 ? { path: expectedPath2 } : null;
|
|
}
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const expectedPath = String(value.path ?? value.file ?? value.relativePath ?? "").trim();
|
|
if (!expectedPath) return null;
|
|
const contains = value.contains ?? value.expectedContent ?? value.contentIncludes;
|
|
return {
|
|
path: expectedPath,
|
|
...contains == null ? {} : { contains: String(contains) }
|
|
};
|
|
}
|
|
function normalizeToolGatewayValidation(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const checks = [];
|
|
const single = normalizeExpectedFileCheck(value.expectedFile ?? value.expectedPath);
|
|
if (single) checks.push(single);
|
|
if (Array.isArray(value.expectedFiles)) {
|
|
for (const item of value.expectedFiles) {
|
|
const check = normalizeExpectedFileCheck(item);
|
|
if (check) checks.push(check);
|
|
}
|
|
}
|
|
return checks.length > 0 ? { expectedFiles: checks } : null;
|
|
}
|
|
function resolveValidationPath(cwd, expectedPath) {
|
|
if (!String(cwd ?? "").trim()) {
|
|
const err = new Error("Tool Gateway validation failed: missing working directory");
|
|
err.code = "TOOL_GATEWAY_VALIDATION_FAILED";
|
|
err.retryable = false;
|
|
throw err;
|
|
}
|
|
const root2 = path.resolve(String(cwd ?? ""));
|
|
const target = path.resolve(root2, String(expectedPath ?? ""));
|
|
if (target !== root2 && !target.startsWith(`${root2}${path.sep}`)) {
|
|
const err = new Error(`Tool Gateway validation path escapes working directory: ${expectedPath}`);
|
|
err.code = "TOOL_GATEWAY_VALIDATION_FAILED";
|
|
err.retryable = false;
|
|
throw err;
|
|
}
|
|
return { root: root2, target };
|
|
}
|
|
async function validateToolGatewayResult({ result, validation, cwd }) {
|
|
if (!validation?.expectedFiles?.length || result?.dryRun) {
|
|
return null;
|
|
}
|
|
const checks = [];
|
|
for (const expected of validation.expectedFiles) {
|
|
const { root: root2, target } = resolveValidationPath(cwd ?? result?.cwd, expected.path);
|
|
let content = "";
|
|
let stat = null;
|
|
try {
|
|
stat = await fs.stat(target);
|
|
content = await fs.readFile(target, "utf8");
|
|
} catch (cause) {
|
|
const err = new Error(`Tool Gateway validation failed: expected file not found: ${expected.path}`);
|
|
err.code = "TOOL_GATEWAY_VALIDATION_FAILED";
|
|
err.retryable = false;
|
|
err.validation = {
|
|
expectedFile: expected.path,
|
|
cwd: root2,
|
|
reason: "missing_file"
|
|
};
|
|
err.cause = cause;
|
|
throw err;
|
|
}
|
|
if (expected.contains != null && !content.includes(expected.contains)) {
|
|
const err = new Error(`Tool Gateway validation failed: expected content not found in ${expected.path}`);
|
|
err.code = "TOOL_GATEWAY_VALIDATION_FAILED";
|
|
err.retryable = false;
|
|
err.validation = {
|
|
expectedFile: expected.path,
|
|
cwd: root2,
|
|
reason: "missing_content"
|
|
};
|
|
throw err;
|
|
}
|
|
checks.push({
|
|
path: expected.path,
|
|
sizeBytes: Number(stat?.size ?? 0),
|
|
contains: expected.contains == null ? null : true
|
|
});
|
|
}
|
|
return { expectedFiles: checks };
|
|
}
|
|
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),
|
|
validation: normalizeToolGatewayValidation(runMetadata?.validation ?? metadata?.toolGatewayValidation)
|
|
};
|
|
}
|
|
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,
|
|
toolGateway: toolGateway2 = null,
|
|
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
|
|
),
|
|
heartbeatMs = positiveInteger(
|
|
process.env.MEMIND_AGENT_RUN_HEARTBEAT_MS,
|
|
DEFAULT_RUN_HEARTBEAT_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);
|
|
}
|
|
function startRunHeartbeat(runId, { attempt }) {
|
|
let stopped = false;
|
|
const writeHeartbeat = async () => {
|
|
if (stopped) return;
|
|
try {
|
|
await appendEvent(runId, "worker_heartbeat", {
|
|
attempt,
|
|
pid: process.pid,
|
|
heartbeatMs
|
|
});
|
|
} catch (err) {
|
|
console.error("[AgentRun] worker heartbeat failed:", err instanceof Error ? err.message : err);
|
|
}
|
|
};
|
|
void writeHeartbeat();
|
|
const timer = setInterval(() => {
|
|
void writeHeartbeat();
|
|
}, heartbeatMs);
|
|
timer.unref?.();
|
|
return () => {
|
|
stopped = true;
|
|
clearInterval(timer);
|
|
};
|
|
}
|
|
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);
|
|
const toolGatewayStatus = toolGateway2?.getStatus ? toolGateway2.getStatus() : null;
|
|
if (runOptions.toolMode === "code" && toolGatewayStatus?.enabled) {
|
|
const workingDir = userAuth2?.resolveWorkingDir ? await userAuth2.resolveWorkingDir(row.user_id) : void 0;
|
|
await appendEvent(runId, "tool_gateway_dispatch", {
|
|
protocol: toolGatewayStatus.protocol ?? "agent-run-v1",
|
|
taskType: runOptions.taskType,
|
|
workingDir: workingDir ?? null
|
|
});
|
|
const result = await toolGateway2.executeJob({
|
|
runId,
|
|
userId: row.user_id,
|
|
requestId: row.request_id,
|
|
userMessage,
|
|
taskType: runOptions.taskType,
|
|
cwd: workingDir,
|
|
timeoutMs: runTimeoutMs
|
|
});
|
|
await appendEvent(runId, "tool_gateway_result", {
|
|
executor: result.executor ?? null,
|
|
dryRun: Boolean(result.dryRun),
|
|
exitCode: result.exitCode ?? null,
|
|
stdoutTail: summarizeText(result.stdout),
|
|
stderrTail: summarizeText(result.stderr)
|
|
});
|
|
try {
|
|
const validation = await validateToolGatewayResult({
|
|
result,
|
|
validation: runOptions.validation,
|
|
cwd: workingDir
|
|
});
|
|
if (validation) {
|
|
await appendEvent(runId, "tool_gateway_validation", validation);
|
|
}
|
|
} catch (err) {
|
|
await appendEvent(runId, "tool_gateway_validation_failed", {
|
|
code: err?.code ?? null,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
validation: err?.validation ?? null
|
|
});
|
|
throw err;
|
|
}
|
|
return { sessionId: row.agent_session_id ?? null };
|
|
}
|
|
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 });
|
|
const stopHeartbeat = startRunHeartbeat(runId, { 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 && err?.retryable !== false && nextAttempt < retryDelaysMs.length;
|
|
await markRun(runId, retryable ? "retryable" : "failed", {
|
|
error_message: message,
|
|
completed_at: retryable ? null : nowMs()
|
|
});
|
|
if (retryable && autoDispatch) {
|
|
setTimeout(() => dispatchRun(runId), retryDelaysMs[nextAttempt - 1]);
|
|
}
|
|
} finally {
|
|
stopHeartbeat();
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
const [oldestRunningRows] = await pool2.query(
|
|
`SELECT
|
|
r.id,
|
|
r.request_id,
|
|
r.started_at,
|
|
r.updated_at,
|
|
r.attempts,
|
|
h.latest_heartbeat_at
|
|
FROM h5_agent_runs r
|
|
LEFT JOIN (
|
|
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
|
|
FROM h5_agent_run_events
|
|
WHERE event_type = 'worker_heartbeat'
|
|
GROUP BY run_id
|
|
) h ON h.run_id = r.id
|
|
WHERE r.status = 'running'
|
|
ORDER BY COALESCE(h.latest_heartbeat_at, r.started_at) ASC
|
|
LIMIT 1`
|
|
);
|
|
const oldestRunningStartedAt = oldestRunningRows[0]?.started_at == null ? null : Number(oldestRunningRows[0].started_at);
|
|
const oldestRunningAgeMs = oldestRunningStartedAt == null ? 0 : Math.max(0, nowMs() - oldestRunningStartedAt);
|
|
const oldestRunningHeartbeatAt = oldestRunningRows[0]?.latest_heartbeat_at == null ? null : Number(oldestRunningRows[0].latest_heartbeat_at);
|
|
const oldestRunningHeartbeatAgeMs = oldestRunningRows[0] ? Math.max(0, nowMs() - Number(oldestRunningRows[0].latest_heartbeat_at ?? oldestRunningRows[0].started_at ?? nowMs())) : 0;
|
|
const [runningWithoutHeartbeatRows] = await pool2.query(
|
|
`SELECT COUNT(*) AS count
|
|
FROM h5_agent_runs r
|
|
LEFT JOIN (
|
|
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
|
|
FROM h5_agent_run_events
|
|
WHERE event_type = 'worker_heartbeat'
|
|
GROUP BY run_id
|
|
) h ON h.run_id = r.id
|
|
WHERE r.status = 'running' AND h.latest_heartbeat_at IS NULL`
|
|
);
|
|
return {
|
|
autoDispatch,
|
|
maxConcurrentRuns,
|
|
runTimeoutMs,
|
|
heartbeatMs,
|
|
inFlight: inFlight.size,
|
|
pendingDispatches: queuedDispatches.length,
|
|
statusCounts,
|
|
oldestRunningStartedAt,
|
|
oldestRunningAgeMs,
|
|
oldestRunningHeartbeatAt,
|
|
oldestRunningHeartbeatAgeMs,
|
|
runningWithoutHeartbeatCount: Number(runningWithoutHeartbeatRows[0]?.count ?? 0),
|
|
latestRunningRun: oldestRunningRows[0] ? {
|
|
id: oldestRunningRows[0].id,
|
|
requestId: oldestRunningRows[0].request_id,
|
|
startedAt: oldestRunningStartedAt,
|
|
updatedAt: oldestRunningRows[0].updated_at == null ? null : Number(oldestRunningRows[0].updated_at),
|
|
attempts: Number(oldestRunningRows[0].attempts ?? 0),
|
|
heartbeatAt: oldestRunningHeartbeatAt,
|
|
heartbeatAgeMs: oldestRunningHeartbeatAgeMs
|
|
} : null,
|
|
terminalStatuses: [...TERMINAL_STATUSES],
|
|
toolGateway: toolGateway2?.getStatus ? toolGateway2.getStatus() : null
|
|
};
|
|
}
|
|
async function recoverStaleRunningRuns({
|
|
staleMs = runTimeoutMs,
|
|
limit = maxConcurrentRuns,
|
|
dryRun = true,
|
|
reason = "stale_running_timeout"
|
|
} = {}) {
|
|
const normalizedStaleMs = positiveInteger(staleMs, runTimeoutMs);
|
|
const normalizedLimit = positiveInteger(limit, maxConcurrentRuns);
|
|
const cutoff = nowMs() - normalizedStaleMs;
|
|
const [rows] = await pool2.query(
|
|
`SELECT
|
|
r.id,
|
|
r.request_id,
|
|
r.started_at,
|
|
r.updated_at,
|
|
r.attempts,
|
|
h.latest_heartbeat_at
|
|
FROM h5_agent_runs r
|
|
LEFT JOIN (
|
|
SELECT run_id, MAX(created_at) AS latest_heartbeat_at
|
|
FROM h5_agent_run_events
|
|
WHERE event_type = 'worker_heartbeat'
|
|
GROUP BY run_id
|
|
) h ON h.run_id = r.id
|
|
WHERE r.status = 'running'
|
|
AND r.started_at IS NOT NULL
|
|
AND COALESCE(h.latest_heartbeat_at, r.started_at) <= ?
|
|
ORDER BY COALESCE(h.latest_heartbeat_at, r.started_at) ASC
|
|
LIMIT ?`,
|
|
[cutoff, normalizedLimit]
|
|
);
|
|
const recovered = [];
|
|
for (const row of rows) {
|
|
const ageMs = Math.max(0, nowMs() - Number(row.started_at ?? 0));
|
|
const heartbeatAt = row.latest_heartbeat_at == null ? null : Number(row.latest_heartbeat_at);
|
|
const heartbeatAgeMs = Math.max(0, nowMs() - Number(row.latest_heartbeat_at ?? row.started_at ?? 0));
|
|
const item = {
|
|
id: row.id,
|
|
requestId: row.request_id,
|
|
startedAt: Number(row.started_at ?? 0),
|
|
updatedAt: Number(row.updated_at ?? 0),
|
|
attempts: Number(row.attempts ?? 0),
|
|
heartbeatAt,
|
|
heartbeatAgeMs,
|
|
ageMs,
|
|
reason
|
|
};
|
|
if (!dryRun) {
|
|
const message = heartbeatAt == null ? `agent run recovered from stale running state after ${ageMs}ms without heartbeat` : `agent run recovered from stale running state after heartbeat was stale for ${heartbeatAgeMs}ms`;
|
|
const completedAt = nowMs();
|
|
const [update] = await pool2.query(
|
|
`UPDATE h5_agent_runs
|
|
SET status = 'failed', error_message = ?, updated_at = ?, completed_at = ?
|
|
WHERE id = ?
|
|
AND status = 'running'
|
|
AND started_at IS NOT NULL
|
|
AND COALESCE(
|
|
(SELECT MAX(created_at)
|
|
FROM h5_agent_run_events
|
|
WHERE run_id = h5_agent_runs.id AND event_type = 'worker_heartbeat'),
|
|
started_at
|
|
) <= ?`,
|
|
[message, completedAt, completedAt, row.id, cutoff]
|
|
);
|
|
if (Number(update?.affectedRows ?? 0) === 0) continue;
|
|
await appendEvent(row.id, "stale_recovered", {
|
|
reason,
|
|
staleMs: normalizedStaleMs,
|
|
ageMs,
|
|
heartbeatAt,
|
|
heartbeatAgeMs,
|
|
status: "failed",
|
|
error: message
|
|
});
|
|
}
|
|
recovered.push(item);
|
|
}
|
|
return {
|
|
dryRun,
|
|
staleMs: normalizedStaleMs,
|
|
cutoff,
|
|
considered: rows.length,
|
|
recovered: dryRun ? 0 : recovered.length,
|
|
runs: recovered
|
|
};
|
|
}
|
|
async function dispatchQueuedRuns({ limit = maxConcurrentRuns } = {}) {
|
|
const staleRecovery = await recoverStaleRunningRuns({
|
|
staleMs: runTimeoutMs,
|
|
limit: Math.max(maxConcurrentRuns, Number(limit) || maxConcurrentRuns),
|
|
dryRun: false,
|
|
reason: "worker_dispatch_stale_recovery"
|
|
});
|
|
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,
|
|
staleRecovery,
|
|
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,
|
|
staleRecovery,
|
|
queue: await getQueueStatus()
|
|
};
|
|
}
|
|
return {
|
|
createRun,
|
|
getRunForUser,
|
|
dispatchRun,
|
|
getQueueStatus,
|
|
dispatchQueuedRuns,
|
|
recoverStaleRunningRuns
|
|
};
|
|
}
|
|
|
|
// db.mjs
|
|
import mysql from "mysql2/promise";
|
|
import path2 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 = path2.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
|
|
});
|
|
}
|
|
|
|
// llm-providers.mjs
|
|
import crypto3 from "node:crypto";
|
|
import fs2 from "node:fs";
|
|
import os from "node:os";
|
|
import path3 from "node:path";
|
|
import { spawn, spawnSync } from "node:child_process";
|
|
import { Agent, fetch as undiciFetch } from "undici";
|
|
var CUSTOM_PROVIDER_ID = "__custom__";
|
|
var LLM_PROVIDER_CATALOG = [
|
|
{
|
|
id: CUSTOM_PROVIDER_ID,
|
|
label: "\u81EA\u5B9A\u4E49 OpenAI \u517C\u5BB9",
|
|
kind: "custom",
|
|
apiKeyEnv: null,
|
|
defaultModel: "",
|
|
models: []
|
|
},
|
|
{
|
|
id: "custom_deepseek",
|
|
label: "DeepSeek",
|
|
kind: "builtin",
|
|
apiKeyEnv: "DEEPSEEK_API_KEY",
|
|
defaultModel: "deepseek-chat",
|
|
models: ["deepseek-chat", "deepseek-reasoner"]
|
|
},
|
|
{
|
|
id: "openai",
|
|
label: "OpenAI",
|
|
kind: "builtin",
|
|
apiKeyEnv: "OPENAI_API_KEY",
|
|
defaultModel: "gpt-4o",
|
|
models: ["gpt-4o", "gpt-4o-mini"]
|
|
},
|
|
{
|
|
id: "openrouter",
|
|
label: "OpenRouter",
|
|
kind: "builtin",
|
|
apiKeyEnv: "OPENROUTER_API_KEY",
|
|
defaultModel: "anthropic/claude-sonnet-4",
|
|
models: ["anthropic/claude-sonnet-4", "openai/gpt-4o"]
|
|
},
|
|
{
|
|
id: "anthropic",
|
|
label: "Anthropic",
|
|
kind: "builtin",
|
|
apiKeyEnv: "ANTHROPIC_API_KEY",
|
|
defaultModel: "claude-sonnet-4-20250514",
|
|
models: ["claude-sonnet-4-20250514", "claude-3-5-haiku-20241022"]
|
|
},
|
|
{
|
|
id: "custom_qwen",
|
|
label: "Qwen VL (\u901A\u4E49\u5343\u95EE)",
|
|
kind: "builtin",
|
|
apiKeyEnv: null,
|
|
// stored as custom provider; apiUrl is injected at createKey time
|
|
apiUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
defaultModel: "qwen-vl-max",
|
|
models: ["qwen-vl-max", "qwen-vl-max-latest", "qwen-vl-plus", "qwen-plus", "qwen-turbo"]
|
|
}
|
|
];
|
|
var LLM_EXECUTOR_CATALOG = [
|
|
{
|
|
id: "goose",
|
|
label: "Goose",
|
|
description: "\u7B56\u7565\u5224\u65AD\u3001\u5206\u6790\u3001\u603B\u7ED3\u548C\u8F7B\u91CF\u4EFB\u52A1\u7F16\u6392",
|
|
purposes: ["default"]
|
|
},
|
|
{
|
|
id: "aider",
|
|
label: "Aider",
|
|
description: "\u5C0F\u8303\u56F4\u4EE3\u7801\u4FEE\u6539\u548C\u8865\u4E01\u5F0F\u4FEE\u590D",
|
|
purposes: ["default"]
|
|
},
|
|
{
|
|
id: "openhands",
|
|
label: "OpenHands",
|
|
description: "\u590D\u6742\u4ED3\u5E93\u4EFB\u52A1\u3001\u591A\u6587\u4EF6\u6539\u9020\u548C\u547D\u4EE4\u6267\u884C",
|
|
purposes: ["default"]
|
|
}
|
|
];
|
|
var catalogById = Object.fromEntries(LLM_PROVIDER_CATALOG.map((item) => [item.id, item]));
|
|
var executorById = Object.fromEntries(LLM_EXECUTOR_CATALOG.map((item) => [item.id, item]));
|
|
var BUILTIN_PROVIDER_TEST_URLS = {
|
|
custom_deepseek: process.env.DEEPSEEK_API_BASE_URL ?? "https://api.deepseek.com/v1",
|
|
openai: process.env.OPENAI_API_BASE_URL ?? "https://api.openai.com/v1",
|
|
openrouter: process.env.OPENROUTER_API_BASE_URL ?? "https://openrouter.ai/api/v1"
|
|
};
|
|
var insecureDispatcher = new Agent({
|
|
connect: { rejectUnauthorized: false }
|
|
});
|
|
function resolveEncryptionKey(explicitKey) {
|
|
const raw = explicitKey ?? process.env.H5_SETTINGS_ENCRYPTION_KEY ?? process.env.TKMIND_SERVER__SECRET_KEY ?? "local-dev-secret";
|
|
return crypto3.createHash("sha256").update(raw).digest();
|
|
}
|
|
function encryptSecret(plaintext, encryptionKey) {
|
|
const key = resolveEncryptionKey(encryptionKey);
|
|
const iv = crypto3.randomBytes(12);
|
|
const cipher = crypto3.createCipheriv("aes-256-gcm", key, iv);
|
|
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
return {
|
|
ciphertext: encrypted.toString("base64"),
|
|
iv: iv.toString("base64"),
|
|
tag: cipher.getAuthTag().toString("base64")
|
|
};
|
|
}
|
|
function decryptSecret({ ciphertext, iv, tag }, encryptionKey) {
|
|
const key = resolveEncryptionKey(encryptionKey);
|
|
const decipher = crypto3.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "base64"));
|
|
decipher.setAuthTag(Buffer.from(tag, "base64"));
|
|
const plain = Buffer.concat([
|
|
decipher.update(Buffer.from(ciphertext, "base64")),
|
|
decipher.final()
|
|
]);
|
|
return plain.toString("utf8");
|
|
}
|
|
function maskApiKey(apiKey) {
|
|
if (!apiKey) return "";
|
|
if (apiKey.length <= 8) return "*".repeat(apiKey.length);
|
|
const head = apiKey.slice(0, 4);
|
|
const tail = apiKey.slice(-4);
|
|
return `${head}${"*".repeat(Math.max(apiKey.length - 8, 4))}${tail}`;
|
|
}
|
|
function parseModelList(raw) {
|
|
if (Array.isArray(raw)) {
|
|
return [...new Set(raw.map((item) => String(item).trim()).filter(Boolean))];
|
|
}
|
|
return [
|
|
...new Set(
|
|
String(raw ?? "").split(/[\n,]/).map((item) => item.trim()).filter(Boolean)
|
|
)
|
|
];
|
|
}
|
|
function normalizeApiUrl(raw) {
|
|
const trimmed = String(raw ?? "").trim();
|
|
if (!trimmed) return "";
|
|
if (/^https?:\/\//i.test(trimmed)) return trimmed.replace(/\/+$/, "");
|
|
return `http://${trimmed.replace(/\/+$/, "")}`;
|
|
}
|
|
function resolveApiBaseUrl(apiUrl) {
|
|
const normalized = normalizeApiUrl(apiUrl);
|
|
if (!normalized) return "";
|
|
if (normalized.endsWith("/chat/completions")) {
|
|
return normalized.slice(0, -"/chat/completions".length).replace(/\/+$/, "");
|
|
}
|
|
return normalized.replace(/\/+$/, "");
|
|
}
|
|
function resolveChatCompletionsUrl(apiUrl) {
|
|
const normalized = normalizeApiUrl(apiUrl);
|
|
if (!normalized) return "";
|
|
if (normalized.endsWith("/chat/completions")) return normalized;
|
|
if (normalized.endsWith("/v1")) return `${normalized}/chat/completions`;
|
|
return `${normalized}/chat/completions`;
|
|
}
|
|
var RELAY_BOOTSTRAP = {
|
|
name: process.env.H5_RELAY_BOOTSTRAP_NAME ?? "Relay Buyer Ollama",
|
|
apiUrl: process.env.H5_RELAY_BOOTSTRAP_URL ?? "http://127.0.0.1:18300/relay/buyer/v1/chat/completions",
|
|
apiKey: process.env.H5_RELAY_BOOTSTRAP_API_KEY ?? "UqyHPKSSEZq0-oPnl8sru-7hZcJ2anPUL1yAVk866Vo",
|
|
models: parseModelList(process.env.H5_RELAY_BOOTSTRAP_MODELS ?? "qwen2.5:3b"),
|
|
defaultModel: process.env.H5_RELAY_BOOTSTRAP_MODEL ?? "qwen2.5:3b",
|
|
relayProvider: process.env.H5_RELAY_BOOTSTRAP_PROVIDER ?? "ollama"
|
|
};
|
|
var LOCAL_LLM_FALLBACK = {
|
|
enabled: process.env.H5_LOCAL_LLM_FALLBACK !== "0",
|
|
providerId: process.env.H5_LOCAL_LLM_PROVIDER_ID ?? "custom_local_ollama_7b",
|
|
displayName: process.env.H5_LOCAL_LLM_NAME ?? "Local Ollama 7B",
|
|
apiUrl: process.env.H5_LOCAL_LLM_URL ?? "http://127.0.0.1:11434/v1/chat/completions",
|
|
apiKey: process.env.H5_LOCAL_LLM_API_KEY ?? "ollama",
|
|
model: process.env.H5_LOCAL_LLM_MODEL ?? "qwen2.5:3b"
|
|
};
|
|
var cachedLocalFallbackProviderId = null;
|
|
async function testRelayConnection({ apiUrl, apiKey, model, relayProvider }, fetchImpl = undiciFetch) {
|
|
const url = resolveChatCompletionsUrl(apiUrl);
|
|
if (!url) return { ok: false, message: "API \u5730\u5740\u65E0\u6548" };
|
|
if (!apiKey) return { ok: false, message: "\u7F3A\u5C11 API Key" };
|
|
if (!model) return { ok: false, message: "\u7F3A\u5C11\u6A21\u578B" };
|
|
const started = Date.now();
|
|
const body = {
|
|
model,
|
|
messages: [{ role: "user", content: "Hello" }],
|
|
stream: false,
|
|
...relayProvider ? { provider: relayProvider } : {}
|
|
};
|
|
const upstream = await fetchImpl(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${apiKey}`
|
|
},
|
|
body: JSON.stringify(body),
|
|
dispatcher: url.startsWith("https://") ? insecureDispatcher : void 0
|
|
});
|
|
const latencyMs = Date.now() - started;
|
|
const text = await upstream.text().catch(() => "");
|
|
if (!upstream.ok) {
|
|
let detail = text.slice(0, 500) || "(\u7A7A\u54CD\u5E94)";
|
|
if (detail === "{}") {
|
|
detail = "\u7A7A JSON \u54CD\u5E94";
|
|
}
|
|
const statusHint = upstream.status === 401 ? "Bearer Token \u65E0\u6548\u6216\u5DF2\u8FC7\u671F" : upstream.status === 404 ? "\u5730\u5740\u4E0D\u5B58\u5728\uFF0C\u8BF7\u68C0\u67E5 API URL" : upstream.status === 400 ? "\u8BF7\u6C42\u53C2\u6570\u88AB\u62D2\u7EDD\uFF0C\u8BF7\u68C0\u67E5 model / provider" : `HTTP ${upstream.status}`;
|
|
return {
|
|
ok: false,
|
|
latencyMs,
|
|
message: `Relay ${upstream.status} ${statusHint}\uFF1A${detail}`
|
|
};
|
|
}
|
|
let data;
|
|
try {
|
|
data = JSON.parse(text);
|
|
} catch {
|
|
return { ok: false, latencyMs, message: "\u54CD\u5E94\u4E0D\u662F JSON" };
|
|
}
|
|
const reply = data?.choices?.[0]?.message?.content ?? data?.message?.content ?? data?.output ?? null;
|
|
return {
|
|
ok: true,
|
|
latencyMs,
|
|
model,
|
|
reply: reply ? String(reply).slice(0, 300) : "(\u8054\u901A\u6210\u529F\uFF0C\u65E0\u6587\u672C\u5185\u5BB9)"
|
|
};
|
|
}
|
|
async function testLocalLlmConnection(fetchImpl = undiciFetch) {
|
|
if (!LOCAL_LLM_FALLBACK.enabled) {
|
|
return { ok: false, message: "\u672C\u5730 LLM fallback \u5DF2\u7981\u7528" };
|
|
}
|
|
return testRelayConnection(
|
|
{
|
|
apiUrl: LOCAL_LLM_FALLBACK.apiUrl,
|
|
apiKey: LOCAL_LLM_FALLBACK.apiKey,
|
|
model: LOCAL_LLM_FALLBACK.model,
|
|
relayProvider: null
|
|
},
|
|
fetchImpl
|
|
);
|
|
}
|
|
function parseModelsJson(raw) {
|
|
if (!raw) return [];
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
return Array.isArray(parsed) ? parseModelList(parsed) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
function rowToPublic(row, catalogItem, apiKeyForMask) {
|
|
const providerKind = row.provider_kind ?? "builtin";
|
|
const models = providerKind === "custom" ? parseModelsJson(row.models_json) : catalogItem?.models ?? [];
|
|
return {
|
|
id: row.id,
|
|
providerId: row.provider_id,
|
|
providerKind,
|
|
providerLabel: providerKind === "custom" ? row.name : catalogItem?.label ?? row.provider_id,
|
|
name: row.name,
|
|
defaultModel: row.default_model,
|
|
models,
|
|
apiUrl: row.api_url ?? null,
|
|
basePath: row.base_path ?? null,
|
|
engine: row.engine ?? "openai",
|
|
relayProvider: row.relay_provider ?? null,
|
|
goosedProviderId: row.goosed_provider_id ?? null,
|
|
status: row.status,
|
|
isSelected: Boolean(row.is_selected),
|
|
isVisionSelected: Boolean(row.is_vision_selected),
|
|
apiKeyMasked: maskApiKey(apiKeyForMask),
|
|
createdAt: Number(row.created_at),
|
|
updatedAt: Number(row.updated_at)
|
|
};
|
|
}
|
|
function profileFromRow(row, decryptRow) {
|
|
const providerKind = row.provider_kind ?? "builtin";
|
|
const models = providerKind === "custom" ? parseModelsJson(row.models_json) : [row.default_model];
|
|
return {
|
|
providerKind,
|
|
providerId: row.provider_id,
|
|
goosedProviderId: row.goosed_provider_id ?? null,
|
|
name: row.name,
|
|
defaultModel: row.default_model,
|
|
apiKey: decryptRow(row),
|
|
apiUrl: row.api_url ?? null,
|
|
basePath: row.base_path ?? null,
|
|
engine: row.engine ?? "openai",
|
|
relayProvider: row.relay_provider ?? null,
|
|
models
|
|
};
|
|
}
|
|
function normalizeExecutor(raw) {
|
|
const executor = String(raw ?? "").trim().toLowerCase();
|
|
return executorById[executor] ? executor : null;
|
|
}
|
|
function normalizePurpose(raw) {
|
|
return String(raw ?? "default").trim() || "default";
|
|
}
|
|
function rowToExecutorBinding(row, keyPublic = null) {
|
|
const executor = normalizeExecutor(row.executor);
|
|
const meta = executorById[executor] ?? null;
|
|
return {
|
|
id: row.id,
|
|
executor,
|
|
executorLabel: meta?.label ?? row.executor,
|
|
executorDescription: meta?.description ?? "",
|
|
purpose: row.purpose ?? "default",
|
|
providerKeyId: row.provider_key_id ?? null,
|
|
providerName: keyPublic?.name ?? null,
|
|
providerLabel: keyPublic?.providerLabel ?? null,
|
|
providerId: keyPublic?.providerId ?? null,
|
|
model: row.model ?? "",
|
|
enabled: Boolean(row.enabled),
|
|
availableModels: keyPublic?.models ?? [],
|
|
createdAt: row.created_at ? Number(row.created_at) : null,
|
|
updatedAt: row.updated_at ? Number(row.updated_at) : null
|
|
};
|
|
}
|
|
function executorEnvForProfile(executor, profile, model, { includeSecret = false } = {}) {
|
|
const apiKeyValue = includeSecret ? profile.apiKey : "[hidden]";
|
|
const providerId = profile.providerId;
|
|
const resolvedApiUrl = profile.apiUrl || BUILTIN_PROVIDER_TEST_URLS[providerId] || "";
|
|
const baseUrl = resolveApiBaseUrl(resolvedApiUrl);
|
|
const common = {
|
|
TKMIND_EXECUTOR: executor,
|
|
TKMIND_EXECUTOR_PROVIDER: providerId,
|
|
TKMIND_EXECUTOR_MODEL: model
|
|
};
|
|
if (baseUrl) common.TKMIND_EXECUTOR_API_BASE = baseUrl;
|
|
if (includeSecret) common.TKMIND_EXECUTOR_API_KEY = profile.apiKey;
|
|
if (executor === "aider") {
|
|
const aiderModel = aiderModelNameForProvider(providerId, model);
|
|
const env = {
|
|
...common,
|
|
AIDER_MODEL: aiderModel
|
|
};
|
|
if (providerId === "anthropic") {
|
|
env.AIDER_ANTHROPIC_API_KEY = apiKeyValue;
|
|
env.ANTHROPIC_API_KEY = apiKeyValue;
|
|
} else {
|
|
env.AIDER_OPENAI_API_KEY = apiKeyValue;
|
|
env.OPENAI_API_KEY = apiKeyValue;
|
|
if (providerId === "openrouter") env.OPENROUTER_API_KEY = apiKeyValue;
|
|
if (providerId === "custom_deepseek") env.DEEPSEEK_API_KEY = apiKeyValue;
|
|
}
|
|
if (baseUrl) {
|
|
env.AIDER_OPENAI_API_BASE = baseUrl;
|
|
env.OPENAI_API_BASE = baseUrl;
|
|
}
|
|
return env;
|
|
}
|
|
if (executor === "openhands") {
|
|
return {
|
|
...common,
|
|
LLM_MODEL: model,
|
|
LLM_API_KEY: apiKeyValue,
|
|
...baseUrl ? { LLM_BASE_URL: baseUrl } : {}
|
|
};
|
|
}
|
|
return {
|
|
...common,
|
|
GOOSE_PROVIDER: profile.providerKind === "custom" ? profile.goosedProviderId ?? profile.providerId : profile.providerId,
|
|
GOOSE_MODEL: model,
|
|
TKMIND_PROVIDER: profile.providerKind === "custom" ? profile.goosedProviderId ?? profile.providerId : profile.providerId,
|
|
TKMIND_MODEL: model
|
|
};
|
|
}
|
|
function aiderModelNameForProvider(providerId, model) {
|
|
const normalizedModel = String(model ?? "").trim();
|
|
if (!normalizedModel) return normalizedModel;
|
|
if (String(providerId ?? "").trim() === "custom_deepseek") {
|
|
return normalizedModel.startsWith("deepseek/") ? normalizedModel : `deepseek/${normalizedModel}`;
|
|
}
|
|
return normalizedModel;
|
|
}
|
|
function launchLogDir() {
|
|
return path3.join(os.tmpdir(), "memindadm-launches");
|
|
}
|
|
function normalizeLaunchMode(mode) {
|
|
const normalized = String(mode ?? "headless").trim().toLowerCase();
|
|
return normalized === "serve" ? "serve" : "headless";
|
|
}
|
|
function resolveExecutorCommand(executor) {
|
|
const candidates = executor === "aider" ? [process.env.AIDER_BIN, process.env.GOOSE_AIDER_BIN, "aider"] : executor === "openhands" ? [process.env.OPENHANDS_BIN, process.env.GOOSE_OPENHANDS_BIN, "openhands"] : [executor];
|
|
return candidates.map((item) => String(item ?? "").trim()).find(Boolean) ?? executor;
|
|
}
|
|
function commandExists(command) {
|
|
if (path3.isAbsolute(command)) {
|
|
try {
|
|
fs2.accessSync(command, fs2.constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
const probe = spawnSync("sh", ["-lc", `command -v ${JSON.stringify(command).slice(1, -1)}`], {
|
|
stdio: "ignore"
|
|
});
|
|
return probe.status === 0;
|
|
}
|
|
function spawnDetachedExecutor(plan, { logDir = launchLogDir() } = {}) {
|
|
if (!plan?.ok) {
|
|
return plan;
|
|
}
|
|
if (!plan.command) {
|
|
return {
|
|
ok: false,
|
|
executor: plan.executor ?? null,
|
|
message: "\u542F\u52A8\u547D\u4EE4\u7F3A\u5931"
|
|
};
|
|
}
|
|
if (!commandExists(plan.command)) {
|
|
return {
|
|
ok: false,
|
|
executor: plan.executor ?? null,
|
|
message: `${plan.command} \u672A\u5B89\u88C5\u6216\u4E0D\u5728 PATH \u4E2D`
|
|
};
|
|
}
|
|
fs2.mkdirSync(logDir, { recursive: true });
|
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
const logFile = path3.join(
|
|
logDir,
|
|
`${plan.executor ?? "executor"}-${stamp}-${crypto3.randomUUID().slice(0, 8)}.log`
|
|
);
|
|
const fd = fs2.openSync(logFile, "a");
|
|
try {
|
|
const child = spawn(plan.command, plan.args ?? [], {
|
|
cwd: plan.cwd ?? process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
...plan.env ?? {}
|
|
},
|
|
detached: true,
|
|
stdio: ["ignore", fd, fd]
|
|
});
|
|
child.unref();
|
|
return {
|
|
ok: true,
|
|
executor: plan.executor ?? null,
|
|
pid: child.pid ?? null,
|
|
command: plan.command,
|
|
args: plan.args ?? [],
|
|
cwd: plan.cwd ?? process.cwd(),
|
|
logFile,
|
|
notes: plan.notes ?? []
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
executor: plan.executor ?? null,
|
|
message: err instanceof Error ? err.message : "\u542F\u52A8\u5931\u8D25",
|
|
logFile
|
|
};
|
|
} finally {
|
|
try {
|
|
fs2.closeSync(fd);
|
|
} catch {
|
|
}
|
|
}
|
|
}
|
|
function isProcessAlive(pid) {
|
|
if (!pid || Number.isNaN(Number(pid))) return false;
|
|
try {
|
|
process.kill(Number(pid), 0);
|
|
return true;
|
|
} catch (err) {
|
|
if (err && typeof err === "object" && "code" in err && err.code === "EPERM") return true;
|
|
return false;
|
|
}
|
|
}
|
|
function launchStateKey(executor, purpose = "default") {
|
|
return `${normalizeExecutor(executor)}:${normalizePurpose(purpose)}`;
|
|
}
|
|
function buildExecutorLaunchPlan(runtime, options = {}) {
|
|
if (!runtime?.ok) {
|
|
return {
|
|
ok: false,
|
|
executor: runtime?.executor ?? null,
|
|
message: runtime?.message ?? "\u8FD0\u884C\u914D\u7F6E\u672A\u5C31\u7EEA"
|
|
};
|
|
}
|
|
const mode = normalizeLaunchMode(options.mode ?? "headless");
|
|
const cwd = String(options.cwd ?? process.cwd());
|
|
const instruction = String(options.instruction ?? "").trim();
|
|
const env = runtime.env ?? {};
|
|
if (runtime.executor === "goose") {
|
|
return {
|
|
ok: false,
|
|
executor: "goose",
|
|
message: "Goose \u4F5C\u4E3A\u73B0\u6709\u670D\u52A1\u5165\u53E3\uFF0C\u4E0D\u901A\u8FC7\u672C\u65B9\u6CD5\u542F\u52A8"
|
|
};
|
|
}
|
|
if (runtime.executor === "aider") {
|
|
const hasInstruction = Boolean(instruction);
|
|
const command = resolveExecutorCommand("aider");
|
|
const model = aiderModelNameForProvider(env.TKMIND_EXECUTOR_PROVIDER, runtime.model);
|
|
return {
|
|
ok: true,
|
|
executor: "aider",
|
|
cwd,
|
|
command,
|
|
args: [
|
|
"--model",
|
|
model,
|
|
...hasInstruction ? ["--message", instruction] : [],
|
|
"--yes-always"
|
|
],
|
|
env,
|
|
notes: [
|
|
"Aider \u901A\u8FC7\u7EDF\u4E00\u6A21\u578B\u4E2D\u5FC3\u63D0\u4F9B\u7684 API Key / Base URL \u8FD0\u884C\u3002",
|
|
"\u5B9E\u9645\u542F\u52A8\u65F6\u5EFA\u8BAE\u5728\u4ED3\u5E93\u76EE\u5F55\u4E2D\u6267\u884C\u3002"
|
|
]
|
|
};
|
|
}
|
|
if (runtime.executor === "openhands") {
|
|
if (mode === "headless" && !instruction) {
|
|
return {
|
|
ok: false,
|
|
executor: "openhands",
|
|
message: "OpenHands headless \u6A21\u5F0F\u9700\u8981 instruction"
|
|
};
|
|
}
|
|
const command = resolveExecutorCommand("openhands");
|
|
const args2 = mode === "serve" ? ["serve", "--mount-cwd"] : ["--headless", "--json", "--override-with-envs", "--task", instruction];
|
|
return {
|
|
ok: true,
|
|
executor: "openhands",
|
|
cwd,
|
|
command,
|
|
args: args2,
|
|
env,
|
|
notes: [
|
|
mode === "serve" ? "OpenHands GUI Server \u4F9D\u8D56 Docker\uFF0C\u9002\u5408\u4EBA\u5DE5\u4EA4\u4E92\u8C03\u8BD5\u3002" : "Headless \u6A21\u5F0F\u9002\u5408\u540E\u7EED\u548C\u540E\u53F0\u8C03\u5EA6\u5BF9\u63A5\u3002"
|
|
]
|
|
};
|
|
}
|
|
return {
|
|
ok: false,
|
|
executor: runtime.executor,
|
|
message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668"
|
|
};
|
|
}
|
|
async function goosedApiFetch(apiTarget, apiSecret, pathname, init = {}, fetchImpl = undiciFetch) {
|
|
const url = new URL(pathname, apiTarget);
|
|
const headers = {
|
|
...init.headers ?? {},
|
|
"X-Secret-Key": apiSecret
|
|
};
|
|
if (init.body && !headers["Content-Type"]) {
|
|
headers["Content-Type"] = "application/json";
|
|
}
|
|
const dispatcher = apiTarget.startsWith("https://") ? insecureDispatcher : void 0;
|
|
return fetchImpl(url, { ...init, headers, dispatcher });
|
|
}
|
|
async function writeGoosedConfig(apiTarget, apiSecret, key, value, isSecret, fetchImpl) {
|
|
const upstream = await goosedApiFetch(
|
|
apiTarget,
|
|
apiSecret,
|
|
"/config/upsert",
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({ key, value, is_secret: isSecret })
|
|
},
|
|
fetchImpl
|
|
);
|
|
if (upstream.ok) return { ok: true, skipped: false };
|
|
if (upstream.status === 404) return { ok: false, skipped: true };
|
|
const text = await upstream.text().catch(() => "");
|
|
throw new Error(`\u540C\u6B65 TKMind Agent \u914D\u7F6E ${key} \u5931\u8D25: ${text || upstream.status}`);
|
|
}
|
|
async function ensureLocalFallbackProviderOnGoosed(apiTarget, apiSecret, fetchImpl = undiciFetch) {
|
|
if (!LOCAL_LLM_FALLBACK.enabled) {
|
|
throw new Error("\u672C\u5730 LLM fallback \u5DF2\u7981\u7528");
|
|
}
|
|
const profile = {
|
|
providerKind: "custom",
|
|
name: LOCAL_LLM_FALLBACK.displayName,
|
|
apiUrl: LOCAL_LLM_FALLBACK.apiUrl,
|
|
apiKey: LOCAL_LLM_FALLBACK.apiKey,
|
|
models: [LOCAL_LLM_FALLBACK.model],
|
|
defaultModel: LOCAL_LLM_FALLBACK.model,
|
|
goosedProviderId: cachedLocalFallbackProviderId,
|
|
engine: "openai"
|
|
};
|
|
const goosedProviderId = await upsertCustomProviderOnGoosed(
|
|
apiTarget,
|
|
apiSecret,
|
|
profile,
|
|
fetchImpl
|
|
);
|
|
cachedLocalFallbackProviderId = goosedProviderId;
|
|
return goosedProviderId;
|
|
}
|
|
async function updateSessionProvider(apiFetchImpl, sessionId, provider, model) {
|
|
const upstream = await apiFetchImpl("/agent/update_provider", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
session_id: sessionId,
|
|
provider,
|
|
model
|
|
})
|
|
});
|
|
if (!upstream.ok) {
|
|
const text = await upstream.text().catch(() => "");
|
|
throw new Error(text || `\u5207\u6362\u4F1A\u8BDD Provider \u5931\u8D25: ${upstream.status}`);
|
|
}
|
|
}
|
|
async function upsertCustomProviderOnGoosed(apiTarget, apiSecret, profile, fetchImpl) {
|
|
const headers = profile.relayProvider ? { "X-Provider": profile.relayProvider } : void 0;
|
|
const body = {
|
|
engine: profile.engine || "openai",
|
|
display_name: profile.name,
|
|
api_url: profile.apiUrl,
|
|
api_key: profile.apiKey,
|
|
models: profile.models,
|
|
supports_streaming: true,
|
|
requires_auth: true,
|
|
...profile.basePath ? { base_path: profile.basePath } : {},
|
|
...headers ? { headers } : {}
|
|
};
|
|
if (profile.goosedProviderId) {
|
|
const upstream2 = await goosedApiFetch(
|
|
apiTarget,
|
|
apiSecret,
|
|
`/config/custom-providers/${encodeURIComponent(profile.goosedProviderId)}`,
|
|
{ method: "PUT", body: JSON.stringify(body) },
|
|
fetchImpl
|
|
);
|
|
if (!upstream2.ok) {
|
|
const text = await upstream2.text().catch(() => "");
|
|
throw new Error(`\u66F4\u65B0 TKMind Agent \u81EA\u5B9A\u4E49 provider \u5931\u8D25: ${text || upstream2.status}`);
|
|
}
|
|
return profile.goosedProviderId;
|
|
}
|
|
const upstream = await goosedApiFetch(
|
|
apiTarget,
|
|
apiSecret,
|
|
"/config/custom-providers",
|
|
{ method: "POST", body: JSON.stringify(body) },
|
|
fetchImpl
|
|
);
|
|
if (!upstream.ok) {
|
|
const text = await upstream.text().catch(() => "");
|
|
throw new Error(`\u521B\u5EFA TKMind Agent \u81EA\u5B9A\u4E49 provider \u5931\u8D25: ${text || upstream.status}`);
|
|
}
|
|
const data = await upstream.json();
|
|
return data.provider_name;
|
|
}
|
|
async function removeCustomProviderOnGoosed(apiTarget, apiSecret, goosedProviderId, fetchImpl) {
|
|
if (!goosedProviderId) return;
|
|
const upstream = await goosedApiFetch(
|
|
apiTarget,
|
|
apiSecret,
|
|
`/config/custom-providers/${encodeURIComponent(goosedProviderId)}`,
|
|
{ method: "DELETE" },
|
|
fetchImpl
|
|
);
|
|
if (!upstream.ok && upstream.status !== 404) {
|
|
const text = await upstream.text().catch(() => "");
|
|
throw new Error(`\u5220\u9664 TKMind Agent \u81EA\u5B9A\u4E49 provider \u5931\u8D25: ${text || upstream.status}`);
|
|
}
|
|
}
|
|
async function syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl) {
|
|
const catalogItem = catalogById[profile.providerId];
|
|
if (!catalogItem?.apiKeyEnv) {
|
|
throw new Error(`\u672A\u77E5\u5185\u7F6E provider: ${profile.providerId}`);
|
|
}
|
|
await writeGoosedConfig(
|
|
apiTarget,
|
|
apiSecret,
|
|
catalogItem.apiKeyEnv,
|
|
profile.apiKey,
|
|
true,
|
|
fetchImpl
|
|
);
|
|
await writeGoosedConfig(
|
|
apiTarget,
|
|
apiSecret,
|
|
"GOOSE_PROVIDER",
|
|
profile.providerId,
|
|
false,
|
|
fetchImpl
|
|
);
|
|
await writeGoosedConfig(
|
|
apiTarget,
|
|
apiSecret,
|
|
"GOOSE_MODEL",
|
|
profile.defaultModel,
|
|
false,
|
|
fetchImpl
|
|
);
|
|
await writeGoosedConfig(
|
|
apiTarget,
|
|
apiSecret,
|
|
"TKMIND_PROVIDER",
|
|
profile.providerId,
|
|
false,
|
|
fetchImpl
|
|
);
|
|
await writeGoosedConfig(
|
|
apiTarget,
|
|
apiSecret,
|
|
"TKMIND_MODEL",
|
|
profile.defaultModel,
|
|
false,
|
|
fetchImpl
|
|
);
|
|
}
|
|
async function syncProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl = undiciFetch) {
|
|
if (profile.providerKind === "custom") {
|
|
const goosedProviderId = await upsertCustomProviderOnGoosed(
|
|
apiTarget,
|
|
apiSecret,
|
|
profile,
|
|
fetchImpl
|
|
);
|
|
await writeGoosedConfig(
|
|
apiTarget,
|
|
apiSecret,
|
|
"GOOSE_PROVIDER",
|
|
goosedProviderId,
|
|
false,
|
|
fetchImpl
|
|
);
|
|
await writeGoosedConfig(
|
|
apiTarget,
|
|
apiSecret,
|
|
"GOOSE_MODEL",
|
|
profile.defaultModel,
|
|
false,
|
|
fetchImpl
|
|
);
|
|
await writeGoosedConfig(
|
|
apiTarget,
|
|
apiSecret,
|
|
"TKMIND_PROVIDER",
|
|
goosedProviderId,
|
|
false,
|
|
fetchImpl
|
|
);
|
|
await writeGoosedConfig(
|
|
apiTarget,
|
|
apiSecret,
|
|
"TKMIND_MODEL",
|
|
profile.defaultModel,
|
|
false,
|
|
fetchImpl
|
|
);
|
|
return goosedProviderId;
|
|
}
|
|
await syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl);
|
|
return profile.providerId;
|
|
}
|
|
function isCustomPayload(payload) {
|
|
return payload?.providerId === CUSTOM_PROVIDER_ID || payload?.providerKind === "custom";
|
|
}
|
|
function validateCustomPayload(payload) {
|
|
const name = String(payload?.name ?? "").trim();
|
|
const apiKey = String(payload?.apiKey ?? "").trim();
|
|
const apiUrl = normalizeApiUrl(payload?.apiUrl);
|
|
const models = parseModelList(payload?.models);
|
|
const defaultModel = String(payload?.defaultModel ?? "").trim() || models[0] || "";
|
|
if (!name) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" };
|
|
if (!apiUrl) return { ok: false, message: "\u8BF7\u586B\u5199 API \u5730\u5740" };
|
|
if (!apiKey) return { ok: false, message: "\u8BF7\u586B\u5199 API Key / Bearer Token" };
|
|
if (models.length === 0) return { ok: false, message: "\u8BF7\u81F3\u5C11\u586B\u5199\u4E00\u4E2A\u6A21\u578B" };
|
|
if (!models.includes(defaultModel)) {
|
|
return { ok: false, message: "\u9ED8\u8BA4\u6A21\u578B\u5FC5\u987B\u5728\u6A21\u578B\u5217\u8868\u4E2D" };
|
|
}
|
|
return {
|
|
ok: true,
|
|
value: {
|
|
name,
|
|
apiKey,
|
|
apiUrl,
|
|
models,
|
|
defaultModel,
|
|
basePath: String(payload?.basePath ?? "").trim() || null,
|
|
engine: String(payload?.engine ?? "openai").trim() || "openai",
|
|
relayProvider: String(payload?.relayProvider ?? "").trim() || null
|
|
}
|
|
};
|
|
}
|
|
function createLlmProviderService(pool2, { apiTarget, apiTargets: apiTargets2 = [], apiSecret, encryptionKey, apiFetchImpl = undiciFetch } = {}) {
|
|
const launchStates = /* @__PURE__ */ new Map();
|
|
const syncTargets = [
|
|
...new Set([...Array.isArray(apiTargets2) ? apiTargets2 : [], apiTarget].filter(Boolean))
|
|
];
|
|
function catalogItem(providerId) {
|
|
return catalogById[providerId] ?? null;
|
|
}
|
|
function decryptRow(row) {
|
|
return decryptSecret(
|
|
{
|
|
ciphertext: row.api_key_ciphertext,
|
|
iv: row.api_key_iv,
|
|
tag: row.api_key_tag
|
|
},
|
|
encryptionKey
|
|
);
|
|
}
|
|
async function getRowById(id) {
|
|
const [rows] = await pool2.query("SELECT * FROM h5_llm_provider_keys WHERE id = ? LIMIT 1", [
|
|
id
|
|
]);
|
|
return rows[0] ?? null;
|
|
}
|
|
async function getSelectedRow() {
|
|
const [rows] = await pool2.query(
|
|
"SELECT * FROM h5_llm_provider_keys WHERE is_selected = 1 AND status = ? LIMIT 1",
|
|
["active"]
|
|
);
|
|
return rows[0] ?? null;
|
|
}
|
|
async function getExecutorBindingRow(executor, purpose = "default") {
|
|
const normalizedExecutor = normalizeExecutor(executor);
|
|
if (!normalizedExecutor) return null;
|
|
const [rows] = await pool2.query(
|
|
"SELECT * FROM h5_llm_executor_bindings WHERE executor = ? AND purpose = ? LIMIT 1",
|
|
[normalizedExecutor, normalizePurpose(purpose)]
|
|
);
|
|
return rows[0] ?? null;
|
|
}
|
|
async function clearSelected() {
|
|
await pool2.query("UPDATE h5_llm_provider_keys SET is_selected = 0, updated_at = ?", [
|
|
Date.now()
|
|
]);
|
|
}
|
|
async function getVisionRow() {
|
|
const [rows] = await pool2.query(
|
|
"SELECT * FROM h5_llm_provider_keys WHERE is_vision_selected = 1 AND status = ? LIMIT 1",
|
|
["active"]
|
|
);
|
|
return rows[0] ?? null;
|
|
}
|
|
async function clearVisionSelected() {
|
|
await pool2.query("UPDATE h5_llm_provider_keys SET is_vision_selected = 0, updated_at = ?", [
|
|
Date.now()
|
|
]);
|
|
}
|
|
let visionKeyConfiguredCache = null;
|
|
const syncRowCache = /* @__PURE__ */ new Map();
|
|
const SYNC_ROW_CACHE_TTL_MS = 3e4;
|
|
async function syncRow(row, fetchImpl = apiFetchImpl) {
|
|
const cacheKey = `${row.id}:${row.updated_at}`;
|
|
const cached = syncRowCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.syncedAt < SYNC_ROW_CACHE_TTL_MS && fetchImpl === apiFetchImpl) {
|
|
return cached.goosedProviderId;
|
|
}
|
|
const profile = profileFromRow(row, decryptRow);
|
|
const goosedProviderId = await syncProfileToGoosed(
|
|
apiTarget,
|
|
apiSecret,
|
|
profile,
|
|
fetchImpl
|
|
);
|
|
if (profile.providerKind === "custom" && goosedProviderId !== row.goosed_provider_id) {
|
|
await pool2.query(
|
|
"UPDATE h5_llm_provider_keys SET goosed_provider_id = ?, provider_id = ?, updated_at = ? WHERE id = ?",
|
|
[goosedProviderId, goosedProviderId, Date.now(), row.id]
|
|
);
|
|
}
|
|
if (fetchImpl === apiFetchImpl) {
|
|
syncRowCache.set(cacheKey, { goosedProviderId, syncedAt: Date.now() });
|
|
}
|
|
return goosedProviderId;
|
|
}
|
|
async function syncRowWithModel(row, model, fetchImpl = apiFetchImpl) {
|
|
const originalModel = row.default_model;
|
|
row.default_model = model || row.default_model;
|
|
try {
|
|
return await syncRow(row, fetchImpl);
|
|
} finally {
|
|
row.default_model = originalModel;
|
|
}
|
|
}
|
|
async function resolveExecutorProvider(executor, purpose = "default", fetchImpl = apiFetchImpl) {
|
|
const binding = await getExecutorBindingRow(executor, purpose);
|
|
if (!binding?.enabled) {
|
|
return { ok: false, message: `${executorById[executor]?.label ?? executor} \u672A\u542F\u7528\u6A21\u578B\u7ED1\u5B9A` };
|
|
}
|
|
if (!binding.provider_key_id) {
|
|
return { ok: false, message: `${executorById[executor]?.label ?? executor} \u672A\u7ED1\u5B9A Provider` };
|
|
}
|
|
const row = await getRowById(binding.provider_key_id);
|
|
if (!row || row.status !== "active") {
|
|
return { ok: false, message: "\u7ED1\u5B9A\u7684 Provider \u4E0D\u5B58\u5728\u6216\u5DF2\u7981\u7528" };
|
|
}
|
|
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
|
|
if (!publicRow.models.includes(binding.model)) {
|
|
return { ok: false, message: "\u7ED1\u5B9A\u6A21\u578B\u4E0D\u5728\u5F53\u524D Provider \u652F\u6301\u5217\u8868\u4E2D" };
|
|
}
|
|
try {
|
|
const goosedProviderId = await syncRowWithModel(row, binding.model, fetchImpl);
|
|
const profile = profileFromRow(row, decryptRow);
|
|
const providerId = profile.providerKind === "custom" ? goosedProviderId ?? profile.goosedProviderId ?? profile.providerId : profile.providerId;
|
|
return {
|
|
ok: true,
|
|
providerId,
|
|
model: binding.model,
|
|
source: "executor_binding",
|
|
executor,
|
|
purpose: binding.purpose
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : "\u540C\u6B65\u6267\u884C\u5668\u6A21\u578B\u914D\u7F6E\u5931\u8D25"
|
|
};
|
|
}
|
|
}
|
|
async function resolveExecutorRuntimeConfig(executor, { purpose = "default", includeSecret = false } = {}) {
|
|
const normalizedExecutor = normalizeExecutor(executor);
|
|
if (!normalizedExecutor) return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" };
|
|
const binding = await getExecutorBindingRow(normalizedExecutor, purpose);
|
|
if (!binding?.enabled) {
|
|
return {
|
|
ok: false,
|
|
executor: normalizedExecutor,
|
|
purpose,
|
|
message: `${executorById[normalizedExecutor]?.label ?? normalizedExecutor} \u672A\u542F\u7528\u6A21\u578B\u7ED1\u5B9A`
|
|
};
|
|
}
|
|
if (!binding.provider_key_id) {
|
|
return {
|
|
ok: false,
|
|
executor: normalizedExecutor,
|
|
purpose,
|
|
message: `${executorById[normalizedExecutor]?.label ?? normalizedExecutor} \u672A\u7ED1\u5B9A Provider`
|
|
};
|
|
}
|
|
const row = await getRowById(binding.provider_key_id);
|
|
if (!row || row.status !== "active") {
|
|
return {
|
|
ok: false,
|
|
executor: normalizedExecutor,
|
|
purpose,
|
|
message: "\u7ED1\u5B9A\u7684 Provider \u4E0D\u5B58\u5728\u6216\u5DF2\u7981\u7528"
|
|
};
|
|
}
|
|
const keyPublic = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
|
|
if (!keyPublic.models.includes(binding.model)) {
|
|
return {
|
|
ok: false,
|
|
executor: normalizedExecutor,
|
|
purpose,
|
|
message: "\u7ED1\u5B9A\u6A21\u578B\u4E0D\u5728\u5F53\u524D Provider \u652F\u6301\u5217\u8868\u4E2D"
|
|
};
|
|
}
|
|
const profile = profileFromRow(row, decryptRow);
|
|
const runtimeProfile = includeSecret ? profile : { ...profile, apiKey: "" };
|
|
return {
|
|
ok: true,
|
|
executor: normalizedExecutor,
|
|
executorLabel: executorById[normalizedExecutor]?.label ?? normalizedExecutor,
|
|
purpose: binding.purpose,
|
|
providerKeyId: row.id,
|
|
providerName: row.name,
|
|
providerKind: profile.providerKind,
|
|
providerId: profile.providerId,
|
|
providerLabel: keyPublic.providerLabel,
|
|
goosedProviderId: profile.goosedProviderId,
|
|
model: binding.model,
|
|
apiUrl: profile.apiUrl,
|
|
apiBaseUrl: resolveApiBaseUrl(profile.apiUrl),
|
|
apiKeyMasked: keyPublic.apiKeyMasked,
|
|
env: executorEnvForProfile(normalizedExecutor, runtimeProfile, binding.model, {
|
|
includeSecret
|
|
})
|
|
};
|
|
}
|
|
function getLaunchState(executor, purpose = "default") {
|
|
const key = launchStateKey(executor, purpose);
|
|
const current = launchStates.get(key);
|
|
if (!current) return null;
|
|
if (current.pid && !isProcessAlive(current.pid)) {
|
|
current.running = false;
|
|
current.stoppedAt = current.stoppedAt ?? Date.now();
|
|
current.exitReason = current.exitReason ?? "not_running";
|
|
launchStates.set(key, current);
|
|
}
|
|
return current;
|
|
}
|
|
function setLaunchState(executor, purpose, state) {
|
|
const key = launchStateKey(executor, purpose);
|
|
launchStates.set(key, {
|
|
...state,
|
|
executor: normalizeExecutor(executor),
|
|
purpose: normalizePurpose(purpose)
|
|
});
|
|
return launchStates.get(key);
|
|
}
|
|
function stateToPublic(state) {
|
|
if (!state) return null;
|
|
return {
|
|
executor: state.executor,
|
|
purpose: state.purpose,
|
|
pid: state.pid ?? null,
|
|
running: Boolean(state.running),
|
|
command: state.command ?? null,
|
|
args: state.args ?? [],
|
|
cwd: state.cwd ?? null,
|
|
logFile: state.logFile ?? null,
|
|
startedAt: state.startedAt ?? null,
|
|
stoppedAt: state.stoppedAt ?? null,
|
|
exitReason: state.exitReason ?? null,
|
|
mode: state.mode ?? null,
|
|
instruction: state.instruction ?? null
|
|
};
|
|
}
|
|
async function stopLaunchState(state, { force = false } = {}) {
|
|
if (!state?.pid) {
|
|
return { ok: false, message: "\u672A\u627E\u5230\u8FD0\u884C\u4E2D\u7684\u6267\u884C\u5668\u8FDB\u7A0B" };
|
|
}
|
|
if (!isProcessAlive(state.pid)) {
|
|
state.running = false;
|
|
state.stoppedAt = state.stoppedAt ?? Date.now();
|
|
state.exitReason = state.exitReason ?? "not_running";
|
|
return { ok: true, stopped: true, state: stateToPublic(state) };
|
|
}
|
|
try {
|
|
process.kill(state.pid, "SIGTERM");
|
|
if (!force) {
|
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
}
|
|
if (force || isProcessAlive(state.pid)) {
|
|
try {
|
|
process.kill(state.pid, "SIGKILL");
|
|
} catch {
|
|
}
|
|
}
|
|
state.running = false;
|
|
state.stoppedAt = Date.now();
|
|
state.exitReason = force ? "force_kill" : "sigterm";
|
|
return { ok: true, stopped: true, state: stateToPublic(state) };
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : "\u505C\u6B62\u5931\u8D25"
|
|
};
|
|
}
|
|
}
|
|
async function testSelectedRow(row) {
|
|
const profile = profileFromRow(row, decryptRow);
|
|
if (profile.providerKind === "custom") {
|
|
try {
|
|
return await testRelayConnection(
|
|
{
|
|
apiUrl: profile.apiUrl,
|
|
apiKey: profile.apiKey,
|
|
model: profile.defaultModel,
|
|
relayProvider: profile.relayProvider
|
|
},
|
|
apiFetchImpl
|
|
);
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : String(err)
|
|
};
|
|
}
|
|
}
|
|
return { ok: true, model: profile.defaultModel };
|
|
}
|
|
async function resolveSelectedProvider(fetchImpl = apiFetchImpl) {
|
|
const row = await getSelectedRow();
|
|
if (!row) {
|
|
return { ok: false, message: "\u8BF7\u5148\u542F\u7528 LLM \u914D\u7F6E" };
|
|
}
|
|
const profile = profileFromRow(row, decryptRow);
|
|
const selectedTest = await testSelectedRow(row);
|
|
if (!selectedTest.ok) {
|
|
return {
|
|
ok: false,
|
|
message: selectedTest.message ?? "\u5F53\u524D\u9009\u4E2D\u7684 LLM \u4E0D\u53EF\u7528"
|
|
};
|
|
}
|
|
try {
|
|
const goosedProviderId = await syncRow(row, fetchImpl);
|
|
const providerId = profile.providerKind === "custom" ? goosedProviderId ?? profile.goosedProviderId ?? profile.providerId : profile.providerId;
|
|
return {
|
|
ok: true,
|
|
providerId,
|
|
model: profile.defaultModel,
|
|
source: "selected"
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : "\u540C\u6B65 LLM \u914D\u7F6E\u5931\u8D25"
|
|
};
|
|
}
|
|
}
|
|
async function resolveLocalFallbackProvider(fetchImpl = apiFetchImpl) {
|
|
let localTest;
|
|
try {
|
|
localTest = await testLocalLlmConnection(apiFetchImpl);
|
|
} catch (err) {
|
|
localTest = {
|
|
ok: false,
|
|
message: err instanceof Error ? err.message : String(err)
|
|
};
|
|
}
|
|
if (!localTest.ok) {
|
|
return {
|
|
ok: false,
|
|
message: localTest.message ?? "\u672C\u5730 LLM \u4E0D\u53EF\u7528"
|
|
};
|
|
}
|
|
const providerId = await ensureLocalFallbackProviderOnGoosed(
|
|
apiTarget,
|
|
apiSecret,
|
|
fetchImpl
|
|
);
|
|
return {
|
|
ok: true,
|
|
providerId,
|
|
model: LOCAL_LLM_FALLBACK.model,
|
|
source: "local_fallback"
|
|
};
|
|
}
|
|
const goosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, apiFetchImpl);
|
|
return {
|
|
catalog: LLM_PROVIDER_CATALOG,
|
|
executorCatalog: LLM_EXECUTOR_CATALOG,
|
|
async listKeys() {
|
|
const [rows] = await pool2.query(
|
|
"SELECT * FROM h5_llm_provider_keys ORDER BY is_selected DESC, updated_at DESC"
|
|
);
|
|
return rows.map(
|
|
(row) => rowToPublic(row, catalogItem(row.provider_id), decryptRow(row))
|
|
);
|
|
},
|
|
async listExecutorBindings() {
|
|
const keys = await this.listKeys();
|
|
const keyMap = new Map(keys.map((key) => [key.id, key]));
|
|
const [rows] = await pool2.query(
|
|
'SELECT * FROM h5_llm_executor_bindings ORDER BY FIELD(executor, "goose", "aider", "openhands"), purpose'
|
|
);
|
|
const rowMap = new Map(
|
|
rows.map((row) => [`${row.executor}:${row.purpose}`, row])
|
|
);
|
|
return LLM_EXECUTOR_CATALOG.map((executor) => {
|
|
const row = rowMap.get(`${executor.id}:default`);
|
|
if (!row) {
|
|
return {
|
|
id: null,
|
|
executor: executor.id,
|
|
executorLabel: executor.label,
|
|
executorDescription: executor.description,
|
|
purpose: "default",
|
|
providerKeyId: null,
|
|
providerName: null,
|
|
providerLabel: null,
|
|
providerId: null,
|
|
model: "",
|
|
enabled: false,
|
|
availableModels: [],
|
|
createdAt: null,
|
|
updatedAt: null
|
|
};
|
|
}
|
|
return rowToExecutorBinding(row, keyMap.get(row.provider_key_id) ?? null);
|
|
});
|
|
},
|
|
async setExecutorBinding(executor, payload = {}) {
|
|
const normalizedExecutor = normalizeExecutor(executor);
|
|
if (!normalizedExecutor) return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" };
|
|
const purpose = normalizePurpose(payload.purpose);
|
|
const enabled = payload.enabled !== false;
|
|
const keyId = String(payload.keyId ?? payload.providerKeyId ?? "").trim() || null;
|
|
const model = String(payload.model ?? "").trim();
|
|
if (enabled && !keyId) return { ok: false, message: "\u8BF7\u9009\u62E9 Provider" };
|
|
if (enabled && !model) return { ok: false, message: "\u8BF7\u9009\u62E9\u6A21\u578B" };
|
|
let keyPublic = null;
|
|
if (keyId) {
|
|
const row2 = await getRowById(keyId);
|
|
if (!row2) return { ok: false, message: "Provider \u4E0D\u5B58\u5728" };
|
|
if (row2.status !== "active") return { ok: false, message: "Provider \u5DF2\u7981\u7528" };
|
|
keyPublic = rowToPublic(row2, catalogItem(row2.provider_id), decryptRow(row2));
|
|
if (model && !keyPublic.models.includes(model)) {
|
|
return { ok: false, message: "\u6A21\u578B\u4E0D\u5728\u8BE5 Provider \u652F\u6301\u5217\u8868\u4E2D" };
|
|
}
|
|
}
|
|
const existing = await getExecutorBindingRow(normalizedExecutor, purpose);
|
|
const now = Date.now();
|
|
if (existing) {
|
|
await pool2.query(
|
|
`UPDATE h5_llm_executor_bindings
|
|
SET provider_key_id = ?, model = ?, enabled = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
[keyId, model, enabled ? 1 : 0, now, existing.id]
|
|
);
|
|
} else {
|
|
await pool2.query(
|
|
`INSERT INTO h5_llm_executor_bindings
|
|
(id, executor, purpose, provider_key_id, model, enabled, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[crypto3.randomUUID(), normalizedExecutor, purpose, keyId, model, enabled ? 1 : 0, now, now]
|
|
);
|
|
}
|
|
const row = await getExecutorBindingRow(normalizedExecutor, purpose);
|
|
return { ok: true, binding: rowToExecutorBinding(row, keyPublic) };
|
|
},
|
|
async getExecutorRuntimeConfig(executor, options = {}) {
|
|
return resolveExecutorRuntimeConfig(executor, options);
|
|
},
|
|
async listExecutorRuntimeConfigs(options = {}) {
|
|
const configs = await Promise.all(
|
|
LLM_EXECUTOR_CATALOG.map(
|
|
(executor) => resolveExecutorRuntimeConfig(executor.id, options)
|
|
)
|
|
);
|
|
return configs;
|
|
},
|
|
async getExecutorLaunchPlan(executor, options = {}) {
|
|
const runtime = await resolveExecutorRuntimeConfig(executor, {
|
|
purpose: options.purpose ?? "default",
|
|
includeSecret: options.includeSecret ?? false
|
|
});
|
|
return buildExecutorLaunchPlan(runtime, options);
|
|
},
|
|
async listExecutorLaunchPlans(options = {}) {
|
|
const runtimes = await this.listExecutorRuntimeConfigs({
|
|
purpose: options.purpose ?? "default",
|
|
includeSecret: options.includeSecret ?? false
|
|
});
|
|
return runtimes.map((runtime) => buildExecutorLaunchPlan(runtime, options));
|
|
},
|
|
async listExecutorLaunchStates(options = {}) {
|
|
const purpose = normalizePurpose(options.purpose ?? "default");
|
|
return LLM_EXECUTOR_CATALOG.map((item) => stateToPublic(getLaunchState(item.id, purpose)));
|
|
},
|
|
async getExecutorLaunchState(executor, options = {}) {
|
|
const purpose = normalizePurpose(options.purpose ?? "default");
|
|
return stateToPublic(getLaunchState(executor, purpose));
|
|
},
|
|
async launchExecutor(executor, options = {}) {
|
|
const normalizedExecutor = normalizeExecutor(executor);
|
|
if (!normalizedExecutor) {
|
|
return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" };
|
|
}
|
|
const purpose = normalizePurpose(options.purpose ?? "default");
|
|
const mode = normalizeLaunchMode(options.mode ?? (normalizedExecutor === "openhands" ? "serve" : "headless"));
|
|
const instruction = String(options.instruction ?? "").trim();
|
|
const currentState = getLaunchState(normalizedExecutor, purpose);
|
|
if (currentState?.running) {
|
|
return {
|
|
ok: true,
|
|
executor: normalizedExecutor,
|
|
purpose,
|
|
running: true,
|
|
reused: true,
|
|
launch: stateToPublic(currentState)
|
|
};
|
|
}
|
|
if (normalizedExecutor === "goose") {
|
|
return {
|
|
ok: false,
|
|
executor: "goose",
|
|
message: "Goose \u4F5C\u4E3A\u73B0\u6709\u670D\u52A1\u5165\u53E3\uFF0C\u4E0D\u901A\u8FC7\u540E\u53F0\u76F4\u63A5\u542F\u52A8"
|
|
};
|
|
}
|
|
if (normalizedExecutor === "aider" && !instruction) {
|
|
return {
|
|
ok: false,
|
|
executor: "aider",
|
|
message: "Aider \u542F\u52A8\u9700\u8981 instruction"
|
|
};
|
|
}
|
|
if (normalizedExecutor === "openhands" && mode === "headless" && !instruction) {
|
|
return {
|
|
ok: false,
|
|
executor: "openhands",
|
|
message: "OpenHands headless \u542F\u52A8\u9700\u8981 instruction"
|
|
};
|
|
}
|
|
const runtime = await resolveExecutorRuntimeConfig(normalizedExecutor, {
|
|
purpose,
|
|
includeSecret: true
|
|
});
|
|
const plan = buildExecutorLaunchPlan(runtime, {
|
|
purpose,
|
|
mode,
|
|
cwd: options.cwd,
|
|
instruction,
|
|
includeSecret: true
|
|
});
|
|
if (!plan.ok) {
|
|
return plan;
|
|
}
|
|
const launch = spawnDetachedExecutor(plan, { logDir: options.logDir });
|
|
if (!launch.ok) return launch;
|
|
const state = setLaunchState(normalizedExecutor, purpose, {
|
|
pid: launch.pid ?? null,
|
|
running: true,
|
|
command: launch.command,
|
|
args: launch.args ?? [],
|
|
cwd: launch.cwd ?? plan.cwd,
|
|
logFile: launch.logFile,
|
|
startedAt: Date.now(),
|
|
mode,
|
|
instruction: instruction || null
|
|
});
|
|
return {
|
|
...launch,
|
|
purpose,
|
|
running: true,
|
|
launch: stateToPublic(state)
|
|
};
|
|
},
|
|
async stopExecutor(executor, options = {}) {
|
|
const normalizedExecutor = normalizeExecutor(executor);
|
|
if (!normalizedExecutor) {
|
|
return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" };
|
|
}
|
|
const purpose = normalizePurpose(options.purpose ?? "default");
|
|
const state = getLaunchState(normalizedExecutor, purpose);
|
|
if (!state) {
|
|
return { ok: false, executor: normalizedExecutor, purpose, message: "\u672A\u627E\u5230\u6267\u884C\u8BB0\u5F55" };
|
|
}
|
|
const result = await stopLaunchState(state, { force: Boolean(options.force) });
|
|
if (!result.ok) return { ok: false, executor: normalizedExecutor, purpose, message: result.message };
|
|
setLaunchState(normalizedExecutor, purpose, state);
|
|
return {
|
|
ok: true,
|
|
executor: normalizedExecutor,
|
|
purpose,
|
|
launch: result.state
|
|
};
|
|
},
|
|
async restartExecutor(executor, options = {}) {
|
|
const normalizedExecutor = normalizeExecutor(executor);
|
|
if (!normalizedExecutor) {
|
|
return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6267\u884C\u5668" };
|
|
}
|
|
const purpose = normalizePurpose(options.purpose ?? "default");
|
|
const state = getLaunchState(normalizedExecutor, purpose);
|
|
if (!state) {
|
|
return { ok: false, executor: normalizedExecutor, purpose, message: "\u672A\u627E\u5230\u53EF\u91CD\u542F\u7684\u6267\u884C\u8BB0\u5F55" };
|
|
}
|
|
await stopLaunchState(state, { force: true });
|
|
const launchOptions = {
|
|
purpose,
|
|
mode: options.mode ?? state.mode ?? (normalizedExecutor === "openhands" ? "serve" : "headless"),
|
|
cwd: options.cwd ?? state.cwd ?? process.cwd(),
|
|
instruction: options.instruction ?? state.instruction ?? "",
|
|
logDir: options.logDir
|
|
};
|
|
return this.launchExecutor(normalizedExecutor, launchOptions);
|
|
},
|
|
async createKey(payload) {
|
|
const custom = isCustomPayload(payload);
|
|
let providerKind = "builtin";
|
|
let providerId = String(payload?.providerId ?? "").trim();
|
|
let insertName = String(payload?.name ?? "").trim();
|
|
let apiKey = String(payload?.apiKey ?? "").trim();
|
|
let defaultModel = String(payload?.defaultModel ?? "").trim();
|
|
let apiUrl = null;
|
|
let basePath = null;
|
|
let engine = "openai";
|
|
let relayProvider = null;
|
|
let modelsJson = null;
|
|
let meta = catalogItem(providerId);
|
|
if (custom) {
|
|
const validated = validateCustomPayload(payload);
|
|
if (!validated.ok) return validated;
|
|
providerKind = "custom";
|
|
providerId = CUSTOM_PROVIDER_ID;
|
|
meta = catalogById[CUSTOM_PROVIDER_ID];
|
|
insertName = validated.value.name;
|
|
apiKey = validated.value.apiKey;
|
|
apiUrl = validated.value.apiUrl;
|
|
basePath = validated.value.basePath;
|
|
engine = validated.value.engine;
|
|
relayProvider = validated.value.relayProvider;
|
|
defaultModel = validated.value.defaultModel;
|
|
modelsJson = JSON.stringify(validated.value.models);
|
|
} else if (providerId === "custom_qwen") {
|
|
if (!meta) return { ok: false, message: "\u4E0D\u652F\u6301\u7684 provider" };
|
|
if (!insertName) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" };
|
|
if (!apiKey) return { ok: false, message: "\u8BF7\u586B\u5199 API Key" };
|
|
providerKind = "custom";
|
|
apiUrl = meta.apiUrl ?? "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
|
const qwenModels = meta.models;
|
|
defaultModel = defaultModel || meta.defaultModel || qwenModels[0];
|
|
if (!qwenModels.includes(defaultModel)) defaultModel = qwenModels[0];
|
|
modelsJson = JSON.stringify(qwenModels);
|
|
} else {
|
|
if (!meta) return { ok: false, message: "\u4E0D\u652F\u6301\u7684 provider" };
|
|
if (!insertName) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" };
|
|
if (!apiKey) return { ok: false, message: "\u8BF7\u586B\u5199 API Key" };
|
|
defaultModel = defaultModel || meta.defaultModel;
|
|
if (!meta.models.includes(defaultModel)) {
|
|
return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6A21\u578B" };
|
|
}
|
|
}
|
|
const [existing] = await pool2.query(
|
|
"SELECT id FROM h5_llm_provider_keys WHERE name = ? LIMIT 1",
|
|
[insertName]
|
|
);
|
|
if (existing.length > 0) return { ok: false, message: "\u914D\u7F6E\u540D\u79F0\u5DF2\u5B58\u5728" };
|
|
const [countRows] = await pool2.query("SELECT COUNT(*) AS total FROM h5_llm_provider_keys");
|
|
const shouldSelect = Number(countRows[0]?.total ?? 0) === 0;
|
|
const encrypted = encryptSecret(apiKey, encryptionKey);
|
|
const now = Date.now();
|
|
const id = crypto3.randomUUID();
|
|
if (shouldSelect) await clearSelected();
|
|
await pool2.query(
|
|
`INSERT INTO h5_llm_provider_keys
|
|
(id, provider_id, provider_kind, api_url, base_path, models_json, goosed_provider_id, engine, relay_provider,
|
|
name, api_key_ciphertext, api_key_iv, api_key_tag, default_model, status, is_selected, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)`,
|
|
[
|
|
id,
|
|
providerId,
|
|
providerKind,
|
|
apiUrl,
|
|
basePath,
|
|
modelsJson,
|
|
engine,
|
|
relayProvider,
|
|
insertName,
|
|
encrypted.ciphertext,
|
|
encrypted.iv,
|
|
encrypted.tag,
|
|
defaultModel,
|
|
shouldSelect ? 1 : 0,
|
|
now,
|
|
now
|
|
]
|
|
);
|
|
let row = await getRowById(id);
|
|
if (shouldSelect) {
|
|
await syncRow(row);
|
|
row = await getRowById(id);
|
|
}
|
|
return { ok: true, key: rowToPublic(row, meta, apiKey) };
|
|
},
|
|
async updateKey(id, payload) {
|
|
const row = await getRowById(id);
|
|
if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" };
|
|
const providerKind = row.provider_kind ?? "builtin";
|
|
const nextName = payload?.name !== void 0 ? String(payload.name).trim() : row.name;
|
|
const nextApiKey = payload?.apiKey !== void 0 ? String(payload.apiKey).trim() : null;
|
|
const nextStatus = payload?.status === "disabled" || payload?.status === "active" ? payload.status : row.status;
|
|
if (!nextName) return { ok: false, message: "\u8BF7\u586B\u5199\u914D\u7F6E\u540D\u79F0" };
|
|
const [existing] = await pool2.query(
|
|
"SELECT id FROM h5_llm_provider_keys WHERE name = ? AND id <> ? LIMIT 1",
|
|
[nextName, id]
|
|
);
|
|
if (existing.length > 0) return { ok: false, message: "\u914D\u7F6E\u540D\u79F0\u5DF2\u5B58\u5728" };
|
|
if (row.is_selected && nextStatus === "disabled") {
|
|
return { ok: false, message: "\u5F53\u524D\u542F\u7528\u7684\u914D\u7F6E\u4E0D\u80FD\u7981\u7528\uFF0C\u8BF7\u5148\u5207\u6362\u5230\u5176\u4ED6\u914D\u7F6E" };
|
|
}
|
|
let nextModel = row.default_model;
|
|
let nextApiUrl = row.api_url;
|
|
let nextBasePath = row.base_path;
|
|
let nextEngine = row.engine ?? "openai";
|
|
let nextRelayProvider = row.relay_provider;
|
|
let nextModelsJson = row.models_json;
|
|
if (providerKind === "custom") {
|
|
const models = payload?.models !== void 0 ? parseModelList(payload.models) : parseModelsJson(row.models_json);
|
|
nextModel = payload?.defaultModel !== void 0 ? String(payload.defaultModel).trim() : row.default_model;
|
|
nextApiUrl = payload?.apiUrl !== void 0 ? normalizeApiUrl(payload.apiUrl) : row.api_url;
|
|
nextBasePath = payload?.basePath !== void 0 ? String(payload.basePath).trim() || null : row.base_path;
|
|
nextEngine = payload?.engine !== void 0 ? String(payload.engine).trim() || "openai" : row.engine;
|
|
nextRelayProvider = payload?.relayProvider !== void 0 ? String(payload.relayProvider).trim() || null : row.relay_provider;
|
|
if (models.length === 0) return { ok: false, message: "\u8BF7\u81F3\u5C11\u4FDD\u7559\u4E00\u4E2A\u6A21\u578B" };
|
|
if (!models.includes(nextModel)) {
|
|
return { ok: false, message: "\u9ED8\u8BA4\u6A21\u578B\u5FC5\u987B\u5728\u6A21\u578B\u5217\u8868\u4E2D" };
|
|
}
|
|
if (!nextApiUrl) return { ok: false, message: "\u8BF7\u586B\u5199 API \u5730\u5740" };
|
|
nextModelsJson = JSON.stringify(models);
|
|
} else {
|
|
const meta = catalogItem(row.provider_id);
|
|
if (!meta) return { ok: false, message: "\u4E0D\u652F\u6301\u7684 provider" };
|
|
nextModel = payload?.defaultModel !== void 0 ? String(payload.defaultModel).trim() : row.default_model;
|
|
if (!meta.models.includes(nextModel)) {
|
|
return { ok: false, message: "\u4E0D\u652F\u6301\u7684\u6A21\u578B" };
|
|
}
|
|
}
|
|
const encrypted = nextApiKey ? encryptSecret(nextApiKey, encryptionKey) : {
|
|
ciphertext: row.api_key_ciphertext,
|
|
iv: row.api_key_iv,
|
|
tag: row.api_key_tag
|
|
};
|
|
const now = Date.now();
|
|
await pool2.query(
|
|
`UPDATE h5_llm_provider_keys
|
|
SET name = ?, api_url = ?, base_path = ?, models_json = ?, engine = ?, relay_provider = ?,
|
|
api_key_ciphertext = ?, api_key_iv = ?, api_key_tag = ?,
|
|
default_model = ?, status = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
[
|
|
nextName,
|
|
nextApiUrl,
|
|
nextBasePath,
|
|
nextModelsJson,
|
|
nextEngine,
|
|
nextRelayProvider,
|
|
encrypted.ciphertext,
|
|
encrypted.iv,
|
|
encrypted.tag,
|
|
nextModel,
|
|
nextStatus,
|
|
now,
|
|
id
|
|
]
|
|
);
|
|
if (row.is_selected) {
|
|
await syncRow(await getRowById(id));
|
|
}
|
|
const updated = await getRowById(id);
|
|
return {
|
|
ok: true,
|
|
key: rowToPublic(updated, catalogItem(updated.provider_id), nextApiKey || decryptRow(row))
|
|
};
|
|
},
|
|
async selectKey(id) {
|
|
const row = await getRowById(id);
|
|
if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" };
|
|
if (row.status !== "active") {
|
|
return { ok: false, message: "\u5DF2\u7981\u7528\u7684\u914D\u7F6E\u4E0D\u80FD\u542F\u7528" };
|
|
}
|
|
await clearSelected();
|
|
const now = Date.now();
|
|
await pool2.query(
|
|
"UPDATE h5_llm_provider_keys SET is_selected = 1, updated_at = ? WHERE id = ?",
|
|
[now, id]
|
|
);
|
|
await syncRow(row);
|
|
const updated = await getRowById(id);
|
|
return {
|
|
ok: true,
|
|
key: rowToPublic(updated, catalogItem(updated.provider_id), decryptRow(updated))
|
|
};
|
|
},
|
|
async deleteKey(id) {
|
|
const row = await getRowById(id);
|
|
if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" };
|
|
if (row.is_selected) {
|
|
return { ok: false, message: "\u5F53\u524D\u542F\u7528\u7684\u914D\u7F6E\u4E0D\u80FD\u5220\u9664\uFF0C\u8BF7\u5148\u5207\u6362\u5230\u5176\u4ED6\u914D\u7F6E" };
|
|
}
|
|
if ((row.provider_kind ?? "builtin") === "custom" && row.goosed_provider_id) {
|
|
await removeCustomProviderOnGoosed(
|
|
apiTarget,
|
|
apiSecret,
|
|
row.goosed_provider_id,
|
|
apiFetchImpl
|
|
);
|
|
}
|
|
await pool2.query("DELETE FROM h5_llm_provider_keys WHERE id = ?", [id]);
|
|
return { ok: true };
|
|
},
|
|
async syncSelectedToGoosed() {
|
|
const targets = syncTargets.length ? syncTargets : [apiTarget].filter(Boolean);
|
|
let lastResolved = null;
|
|
for (const target of targets) {
|
|
const targetFetch = (url, init) => {
|
|
const pathname = `${url.pathname}${url.search}`;
|
|
return goosedApiFetch(target, apiSecret, pathname, init, apiFetchImpl);
|
|
};
|
|
let resolved = await resolveExecutorProvider("goose", "default", targetFetch);
|
|
if (!resolved.ok) {
|
|
resolved = await resolveSelectedProvider(targetFetch);
|
|
}
|
|
if (!resolved.ok) {
|
|
return { ok: false, synced: false, target, message: resolved.message };
|
|
}
|
|
lastResolved = resolved;
|
|
}
|
|
return {
|
|
ok: true,
|
|
synced: true,
|
|
targets,
|
|
source: lastResolved.source,
|
|
providerId: lastResolved.providerId,
|
|
model: lastResolved.model
|
|
};
|
|
},
|
|
async applyBestProviderForSession(sessionId, fetchImpl = apiFetchImpl) {
|
|
let resolved = await resolveExecutorProvider("goose", "default", fetchImpl);
|
|
if (!resolved.ok) {
|
|
resolved = await resolveSelectedProvider(fetchImpl);
|
|
}
|
|
if (!resolved.ok) {
|
|
return resolved;
|
|
}
|
|
const sessionGoosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl);
|
|
await updateSessionProvider(sessionGoosedApi, sessionId, resolved.providerId, resolved.model);
|
|
return resolved;
|
|
},
|
|
/** Switch session to local Ollama (credits exhausted or relay 500 payload limit). */
|
|
async applyLocalFallbackForSession(sessionId, fetchImpl = apiFetchImpl) {
|
|
const resolved = await resolveLocalFallbackProvider(fetchImpl);
|
|
if (!resolved.ok) {
|
|
return resolved;
|
|
}
|
|
const sessionGoosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl);
|
|
await updateSessionProvider(sessionGoosedApi, sessionId, resolved.providerId, resolved.model);
|
|
return resolved;
|
|
},
|
|
async hasVisionKey() {
|
|
if (visionKeyConfiguredCache !== null) return visionKeyConfiguredCache;
|
|
const row = await getVisionRow();
|
|
visionKeyConfiguredCache = row !== null;
|
|
return visionKeyConfiguredCache;
|
|
},
|
|
async getVisionSettings() {
|
|
const row = await getVisionRow();
|
|
if (!row) {
|
|
return { keyId: null, keyName: null, providerLabel: null, visionModel: null, availableModels: [] };
|
|
}
|
|
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
|
|
return {
|
|
keyId: publicRow.id,
|
|
keyName: publicRow.name,
|
|
providerLabel: publicRow.providerLabel,
|
|
visionModel: publicRow.defaultModel,
|
|
availableModels: publicRow.models
|
|
};
|
|
},
|
|
async setVisionKey(keyId, model) {
|
|
const nextModel = String(model ?? "").trim();
|
|
if (!nextModel) return { ok: false, message: "\u8BF7\u9009\u62E9\u89C6\u89C9\u6A21\u578B" };
|
|
const row = await getRowById(keyId);
|
|
if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" };
|
|
if (row.status !== "active") return { ok: false, message: "\u8BE5\u914D\u7F6E\u5DF2\u7981\u7528" };
|
|
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
|
|
if (!publicRow.models.includes(nextModel)) {
|
|
return { ok: false, message: "\u6A21\u578B\u4E0D\u5728\u8BE5\u914D\u7F6E\u652F\u6301\u5217\u8868\u4E2D" };
|
|
}
|
|
await clearVisionSelected();
|
|
const now = Date.now();
|
|
await pool2.query(
|
|
"UPDATE h5_llm_provider_keys SET is_vision_selected = 1, default_model = ?, updated_at = ? WHERE id = ?",
|
|
[nextModel, now, keyId]
|
|
);
|
|
visionKeyConfiguredCache = true;
|
|
return { ok: true, vision: await this.getVisionSettings() };
|
|
},
|
|
async clearVisionKey() {
|
|
await clearVisionSelected();
|
|
visionKeyConfiguredCache = false;
|
|
return { ok: true };
|
|
},
|
|
async applyVisionProviderForSession(sessionId, fetchImpl = apiFetchImpl) {
|
|
const row = await getVisionRow();
|
|
if (!row) return { ok: false, message: "\u672A\u914D\u7F6E\u56FE\u7247\u4EFB\u52A1\u6A21\u578B" };
|
|
const sessionGoosedApi = (pathname, init) => goosedApiFetch(apiTarget, apiSecret, pathname, init, fetchImpl);
|
|
let goosedProviderId;
|
|
try {
|
|
goosedProviderId = await syncRow(row, fetchImpl);
|
|
if (!goosedProviderId) {
|
|
goosedProviderId = profileFromRow(row, decryptRow).goosedProviderId ?? row.provider_id;
|
|
}
|
|
} catch (err) {
|
|
return { ok: false, message: err instanceof Error ? err.message : "\u540C\u6B65\u89C6\u89C9\u6A21\u578B\u5931\u8D25" };
|
|
}
|
|
await updateSessionProvider(sessionGoosedApi, sessionId, goosedProviderId, row.default_model);
|
|
return { ok: true, providerId: goosedProviderId, model: row.default_model, source: "vision" };
|
|
},
|
|
// Calls the vision provider directly (not through Goose) to analyze images.
|
|
// Returns the model's text description, or null on failure.
|
|
async analyzeImagesWithVision(imageItems, userText) {
|
|
const row = await getVisionRow();
|
|
if (!row) return null;
|
|
const apiUrl = String(row.api_url ?? "").trim();
|
|
if (!apiUrl) return null;
|
|
const apiKey = decryptRow(row);
|
|
if (!apiKey) return null;
|
|
const model = String(row.default_model ?? "qwen-vl-max").trim();
|
|
const content = [
|
|
...imageItems.map((item) => ({
|
|
type: "image_url",
|
|
image_url: { url: `data:${item.mimeType};base64,${item.data}` }
|
|
})),
|
|
{ type: "text", text: String(userText || "\u8BF7\u63CF\u8FF0\u8FD9\u5F20\u56FE\u7247\u7684\u5185\u5BB9").trim() }
|
|
];
|
|
try {
|
|
const resp = await undiciFetch(`${apiUrl}/chat/completions`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${apiKey}`
|
|
},
|
|
body: JSON.stringify({
|
|
model,
|
|
messages: [{ role: "user", content }],
|
|
max_tokens: 1200
|
|
}),
|
|
signal: AbortSignal.timeout(3e4)
|
|
});
|
|
if (!resp.ok) return null;
|
|
const json = await resp.json();
|
|
return json?.choices?.[0]?.message?.content ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
async getGlobalSettings() {
|
|
const row = await getSelectedRow();
|
|
if (!row) {
|
|
return {
|
|
keyId: null,
|
|
keyName: null,
|
|
providerLabel: null,
|
|
globalModel: null,
|
|
availableModels: []
|
|
};
|
|
}
|
|
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
|
|
return {
|
|
keyId: publicRow.id,
|
|
keyName: publicRow.name,
|
|
providerLabel: publicRow.providerLabel,
|
|
globalModel: publicRow.defaultModel,
|
|
availableModels: publicRow.models
|
|
};
|
|
},
|
|
async setGlobalModel(model) {
|
|
const nextModel = String(model ?? "").trim();
|
|
if (!nextModel) return { ok: false, message: "\u8BF7\u9009\u62E9\u5168\u5C40\u6A21\u578B" };
|
|
const row = await getSelectedRow();
|
|
if (!row) return { ok: false, message: "\u8BF7\u5148\u542F\u7528\u4E00\u4E2A LLM \u914D\u7F6E" };
|
|
const publicRow = rowToPublic(row, catalogItem(row.provider_id), decryptRow(row));
|
|
if (!publicRow.models.includes(nextModel)) {
|
|
return { ok: false, message: "\u6A21\u578B\u4E0D\u5728\u5F53\u524D Provider \u652F\u6301\u5217\u8868\u4E2D" };
|
|
}
|
|
await pool2.query(
|
|
"UPDATE h5_llm_provider_keys SET default_model = ?, updated_at = ? WHERE id = ?",
|
|
[nextModel, Date.now(), row.id]
|
|
);
|
|
await syncRow(await getRowById(row.id));
|
|
return { ok: true, global: await this.getGlobalSettings() };
|
|
},
|
|
async testDraft(payload) {
|
|
const custom = isCustomPayload(payload);
|
|
if (!custom) {
|
|
return { ok: false, message: "\u8054\u901A\u6D4B\u8BD5\u76EE\u524D\u652F\u6301\u81EA\u5B9A\u4E49 OpenAI \u517C\u5BB9\u914D\u7F6E" };
|
|
}
|
|
const validated = validateCustomPayload(payload);
|
|
if (!validated.ok) return validated;
|
|
const model = String(payload?.testModel ?? payload?.defaultModel ?? validated.value.defaultModel).trim();
|
|
if (!validated.value.models.includes(model)) {
|
|
return { ok: false, message: "\u6D4B\u8BD5\u6A21\u578B\u4E0D\u5728\u6A21\u578B\u5217\u8868\u4E2D" };
|
|
}
|
|
return testRelayConnection(
|
|
{
|
|
apiUrl: validated.value.apiUrl,
|
|
apiKey: validated.value.apiKey,
|
|
model,
|
|
relayProvider: validated.value.relayProvider
|
|
},
|
|
apiFetchImpl
|
|
);
|
|
},
|
|
async testKey(id, testModel) {
|
|
const row = await getRowById(id);
|
|
if (!row) return { ok: false, message: "\u914D\u7F6E\u4E0D\u5B58\u5728" };
|
|
const model = String(testModel ?? row.default_model).trim();
|
|
const providerKind = row.provider_kind ?? "builtin";
|
|
const meta = catalogItem(row.provider_id);
|
|
const models = providerKind === "custom" ? parseModelsJson(row.models_json) : meta?.models ?? [];
|
|
if (!models.includes(model)) {
|
|
return { ok: false, message: "\u6D4B\u8BD5\u6A21\u578B\u4E0D\u5728\u914D\u7F6E\u5217\u8868\u4E2D" };
|
|
}
|
|
if (providerKind !== "custom") {
|
|
const apiUrl = BUILTIN_PROVIDER_TEST_URLS[row.provider_id];
|
|
if (!apiUrl) {
|
|
return { ok: false, message: "\u8BE5\u5185\u7F6E Provider \u6682\u4E0D\u652F\u6301\u8054\u901A\u6D4B\u8BD5" };
|
|
}
|
|
return testRelayConnection(
|
|
{
|
|
apiUrl,
|
|
apiKey: decryptRow(row),
|
|
model,
|
|
relayProvider: null
|
|
},
|
|
apiFetchImpl
|
|
);
|
|
}
|
|
return testRelayConnection(
|
|
{
|
|
apiUrl: row.api_url,
|
|
apiKey: decryptRow(row),
|
|
model,
|
|
relayProvider: row.relay_provider
|
|
},
|
|
apiFetchImpl
|
|
);
|
|
},
|
|
async ensureBootstrapRelay() {
|
|
const [existing] = await pool2.query(
|
|
"SELECT id FROM h5_llm_provider_keys WHERE name = ? LIMIT 1",
|
|
[RELAY_BOOTSTRAP.name]
|
|
);
|
|
if (existing.length > 0) {
|
|
return { ok: true, created: false, keyId: existing[0].id };
|
|
}
|
|
const result = await this.createKey({
|
|
providerId: CUSTOM_PROVIDER_ID,
|
|
name: RELAY_BOOTSTRAP.name,
|
|
apiKey: RELAY_BOOTSTRAP.apiKey,
|
|
apiUrl: RELAY_BOOTSTRAP.apiUrl,
|
|
models: RELAY_BOOTSTRAP.models,
|
|
defaultModel: RELAY_BOOTSTRAP.defaultModel,
|
|
relayProvider: RELAY_BOOTSTRAP.relayProvider
|
|
});
|
|
if (!result.ok) return result;
|
|
if (result.key && !result.key.isSelected) {
|
|
await this.selectKey(result.key.id);
|
|
}
|
|
return { ok: true, created: true, key: result.key };
|
|
}
|
|
};
|
|
}
|
|
|
|
// tkmind-proxy.mjs
|
|
import fs5 from "node:fs";
|
|
import path8 from "node:path";
|
|
import { Readable, Transform as Transform2, Writable } from "node:stream";
|
|
import { pipeline } from "node:stream/promises";
|
|
import { Agent as Agent2, fetch as undiciFetch2 } 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 path4 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 path13 = String(pathname ?? "");
|
|
return path13.startsWith("/mindspace/") || path13.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 path4.join(path4.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", "generate_long_image");
|
|
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 fs3 from "node:fs";
|
|
import path5 from "node:path";
|
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
|
|
// mindspace-canonical-url.mjs
|
|
var MINDSPACE_PUBLIC_ROOT = "MindSpace";
|
|
var MINDSPACE_PUBLIC_ZONE = "public";
|
|
function urlError(message, code, details = {}) {
|
|
return Object.assign(new Error(message), { code, ...details });
|
|
}
|
|
function requiredSegment(value, field) {
|
|
const normalized = String(value ?? "").trim();
|
|
if (!normalized) {
|
|
throw urlError(`${field} is required`, "invalid_mindspace_url_segment", { field });
|
|
}
|
|
if (normalized.includes("/") || normalized.includes("\\") || normalized.includes("\0")) {
|
|
throw urlError(`${field} must be a single URL segment`, "invalid_mindspace_url_segment", {
|
|
field
|
|
});
|
|
}
|
|
return normalized;
|
|
}
|
|
function normalizeMindSpacePublicBaseUrl(publicBaseUrl) {
|
|
const normalized = String(publicBaseUrl ?? "").trim().replace(/\/+$/, "");
|
|
if (!normalized) {
|
|
throw urlError("publicBaseUrl is required", "invalid_mindspace_public_base_url");
|
|
}
|
|
return normalized;
|
|
}
|
|
function normalizeMindSpacePublicRelativePath(relativePath) {
|
|
const raw = String(relativePath ?? "").trim();
|
|
if (!raw) {
|
|
throw urlError("relativePath is required", "invalid_mindspace_public_relative_path");
|
|
}
|
|
if (raw.includes("\0") || raw.includes("\\")) {
|
|
throw urlError("relativePath must use URL-style forward slashes", "invalid_mindspace_public_relative_path");
|
|
}
|
|
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(raw) || raw.startsWith("//")) {
|
|
throw urlError("relativePath must not be an absolute URL", "invalid_mindspace_public_relative_path");
|
|
}
|
|
const clean = raw.replace(/^\/+/, "");
|
|
const parts = clean.split("/").filter(Boolean);
|
|
if (parts.length === 0 || parts.includes("..")) {
|
|
throw urlError("relativePath must not traverse parent directories", "invalid_mindspace_public_relative_path");
|
|
}
|
|
return parts.join("/");
|
|
}
|
|
function createMindSpacePublicUrl({ publicBaseUrl, ownerKey, relativePath }) {
|
|
const base = normalizeMindSpacePublicBaseUrl(publicBaseUrl);
|
|
const owner = requiredSegment(ownerKey, "ownerKey");
|
|
const relative = normalizeMindSpacePublicRelativePath(relativePath);
|
|
const encodedRelative = relative.split("/").map(encodeURIComponent).join("/");
|
|
return `${base}/${MINDSPACE_PUBLIC_ROOT}/${encodeURIComponent(owner)}/${encodedRelative}`;
|
|
}
|
|
function createMindSpaceUserRootUrl({ publicBaseUrl, ownerKey }) {
|
|
const base = normalizeMindSpacePublicBaseUrl(publicBaseUrl);
|
|
const owner = requiredSegment(ownerKey, "ownerKey");
|
|
return `${base}/${MINDSPACE_PUBLIC_ROOT}/${encodeURIComponent(owner)}/`;
|
|
}
|
|
function createMindSpacePublicPageUrl({ publicBaseUrl, ownerKey, filename }) {
|
|
const clean = normalizeMindSpacePublicRelativePath(filename);
|
|
const relative = clean.startsWith(`${MINDSPACE_PUBLIC_ZONE}/`) ? clean : `${MINDSPACE_PUBLIC_ZONE}/${clean}`;
|
|
return createMindSpacePublicUrl({ publicBaseUrl, ownerKey, relativePath: relative });
|
|
}
|
|
|
|
// user-publish.mjs
|
|
var __dirname2 = path5.dirname(fileURLToPath3(import.meta.url));
|
|
var PUBLISH_SKILL_NAME = "static-page-publish";
|
|
var DOCX_SKILL_NAME = "docx-generate";
|
|
var LONG_IMAGE_SKILL_NAME = "long-image-download";
|
|
var PUBLISH_ROOT_DIR = "MindSpace";
|
|
var PUBLIC_ZONE_DIR = "public";
|
|
var PUBLISH_SKILL_DIR = path5.join(__dirname2, "skills", PUBLISH_SKILL_NAME);
|
|
var DOCX_SKILL_DIR = path5.join(__dirname2, "skills", DOCX_SKILL_NAME);
|
|
var LONG_IMAGE_SKILL_DIR = path5.join(__dirname2, "skills", LONG_IMAGE_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 path5.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 path5.join(path5.resolve(canonicalRoot), resolvePublishKey(user));
|
|
}
|
|
return resolvePublishDir(h5Root, user);
|
|
}
|
|
function resolveLegacyPublishDir(h5Root, user) {
|
|
if (!user?.username) return null;
|
|
return path5.join(h5Root, PUBLISH_ROOT_DIR, resolveUsernameSlug(user));
|
|
}
|
|
function mergePublishTrees(sourceDir, targetDir) {
|
|
if (!fs3.existsSync(sourceDir)) return;
|
|
for (const entry of fs3.readdirSync(sourceDir, { withFileTypes: true })) {
|
|
const from = path5.join(sourceDir, entry.name);
|
|
const to = path5.join(targetDir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
fs3.mkdirSync(to, { recursive: true });
|
|
mergePublishTrees(from, to);
|
|
} else if (!fs3.existsSync(to)) {
|
|
fs3.copyFileSync(from, to);
|
|
}
|
|
}
|
|
}
|
|
function migrateUserPublishDir(h5Root, user, legacyUsersRoot = null) {
|
|
const target = resolvePublishDir(h5Root, user);
|
|
fs3.mkdirSync(target, { recursive: true });
|
|
const legacyDirs = [
|
|
resolveLegacyPublishDir(h5Root, user),
|
|
legacyUsersRoot ? path5.join(legacyUsersRoot, resolveUsernameSlug(user)) : null
|
|
].filter(Boolean);
|
|
for (const legacyDir of legacyDirs) {
|
|
if (path5.resolve(legacyDir) === path5.resolve(target)) continue;
|
|
mergePublishTrees(legacyDir, target);
|
|
}
|
|
return target;
|
|
}
|
|
function buildPublicUrl(publicBaseUrl, publishKey, filename = "") {
|
|
if (!filename) {
|
|
return createMindSpaceUserRootUrl({ publicBaseUrl, ownerKey: publishKey });
|
|
}
|
|
return createMindSpacePublicUrl({ publicBaseUrl, ownerKey: publishKey, relativePath: filename });
|
|
}
|
|
function buildPublicZonePageUrl(publicBaseUrl, publishKey, filename) {
|
|
return createMindSpacePublicPageUrl({ publicBaseUrl, ownerKey: publishKey, filename });
|
|
}
|
|
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\`\u3001\`generate_long_image\`\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 \`<head>\` \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. \u9ED8\u8BA4\u53EA\u751F\u6210 HTML\uFF1B\u4E0D\u8981\u5728\u6CA1\u6709\u660E\u786E\u9700\u6C42\u65F6\u5F3A\u5236\u751F\u6210 Word\u3001PDF\u3001\u957F\u56FE\u7B49\u4F34\u751F\u6587\u4EF6
|
|
7. \u82E5\u7528\u6237\u660E\u786E\u8981\u6C42 Word / docx \u4E0B\u8F7D\uFF0C\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
|
|
8. \u82E5\u7528\u6237\u660E\u786E\u8981\u6C42\u957F\u56FE\u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`long-image-download\`\uFF0C\u8C03\u7528 \`generate_long_image\` \u751F\u6210\u5E76\u786E\u8BA4 \`public/<\u9875\u9762\u540D>.long.png\` \u5DF2\u5B58\u5728
|
|
9. \u5B8C\u6210\u540E\u6309\u300C\u56DE\u590D\u683C\u5F0F\u300D\u8FD4\u56DE\u53EF\u70B9\u51FB\u94FE\u63A5
|
|
|
|
## \u6309\u9700\u4F34\u751F\u4E0B\u8F7D\u6587\u4EF6
|
|
|
|
- \u9ED8\u8BA4\u4E0D\u751F\u6210\u4F34\u751F\u6587\u4EF6\uFF1B\u53EA\u6709\u7528\u6237\u660E\u786E\u8981\u6C42\u4E0B\u8F7D\u9644\u4EF6\u65F6\u624D\u751F\u6210
|
|
- \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\u6309\u9700\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 \`<a href="report.docx" download>\u4E0B\u8F7D Word \u6587\u6863</a>\`
|
|
- **\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**
|
|
- \u82E5\u751F\u6210\u4E86\u957F\u56FE\uFF0C\u540C\u65F6\u7ED9 \`[\u957F\u56FE\u9884\u89C8](.../public/pilates-618.long.png)\` \u548C \`[\u4E0B\u8F7D\u957F\u56FE](.../public/pilates-618.html?download=long-image)\`
|
|
|
|
## \u4FE1\u606F\u6D41\u9884\u89C8\u56FE\uFF08\u5FC5\u987B\uFF09
|
|
|
|
\u6BCF\u4E2A HTML \u9875\u9762\u90FD\u5FC5\u987B\u5728 \`<head>\` \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
|
|
<meta name="description" content="\u4E00\u53E5\u8BDD\u6458\u8981\uFF0C\u663E\u793A\u5728\u9884\u89C8\u56FE\u526F\u6807\u9898">
|
|
<meta name="mindspace-cover" content='{"tag":"\u8FD0\u52A8","emoji":"\u{1F9D8}","accent":"#eeb04e","accent2":"#1a0a2e","subtitle":"618 \u9650\u65F6 5 \u8282\u8BFE","cover":"assets/hero.jpg"}'>
|
|
\`\`\`
|
|
|
|
| \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 \`<!DOCTYPE html>\`\uFF0C\`lang="zh-CN"\`
|
|
- \u79FB\u52A8\u7AEF\u53CB\u597D\uFF1A\`<meta name="viewport" content="width=device-width, initial-scale=1">\`
|
|
- \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\u54C1\u724C\u884C**\u5FC5\u987B**\u4F7F\u7528 \`data-mindspace-page-tag="platform-brand"\` \u6807\u8BB0\uFF0C\u663E\u793A\u4E3A **TKMind \xB7 \u667A\u8DA3**\uFF0C**\u7981\u6B62**\u4F7F\u7528\u90AE\u7BB1\u6216 \`tkmind.ai\`
|
|
|
|
\`\`\`html
|
|
<p data-mindspace-page-tag="platform-brand">TKMind \xB7 \u667A\u8DA3</p>
|
|
\`\`\`
|
|
|
|
- \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
|
|
- \u7528\u6237\u8981\u6C42\u957F\u56FE\u4E0B\u8F7D\u65F6\uFF0C\u7981\u6B62\u8DF3\u8FC7 \`long-image-download\` \u6216\u628A \`.thumbnail.svg\` \u5F53\u6210\u957F\u56FE
|
|
|
|
## \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
|
|
|
|
## \u957F\u56FE\u4E0B\u8F7D
|
|
|
|
- \u82E5\u7528\u6237\u660E\u786E\u8981\u6C42\u957F\u56FE\u4E0B\u8F7D\uFF1A\u5FC5\u987B\u5148 \`load_skill\` \u2192 \`long-image-download\`
|
|
- \u8C03\u7528 \`generate_long_image\` \u751F\u6210 \`public/<\u9875\u9762\u540D>.long.png\`
|
|
- \u5148\u786E\u8BA4 \`public/*.long.png\` \u5DF2\u5B58\u5728\uFF0C\u518D\u56DE\u590D \`[\u957F\u56FE\u9884\u89C8](...long.png)\` \u548C \`[\u4E0B\u8F7D\u957F\u56FE](...html?download=long-image)\`
|
|
- \`.thumbnail.svg\` \u53EA\u662F\u4FE1\u606F\u6D41\u5C01\u9762\uFF0C\u4E0D\u80FD\u5F53\u6210\u957F\u56FE
|
|
`;
|
|
}
|
|
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",
|
|
"- \u9ED8\u8BA4\u53EA\u751F\u6210 HTML\uFF1B\u4E0D\u8981\u5728\u6CA1\u6709\u660E\u786E\u9700\u6C42\u65F6\u5F3A\u5236\u751F\u6210 Word\u3001PDF\u3001\u957F\u56FE\u7B49\u4F34\u751F\u6587\u4EF6",
|
|
"- \u53EA\u6709\u7528\u6237\u660E\u786E\u8981\u6C42 Word / docx \u4E0B\u8F7D\u65F6\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",
|
|
"- \u53EA\u6709\u7528\u6237\u660E\u786E\u8981\u6C42\u957F\u56FE\u4E0B\u8F7D\u65F6\uFF1A\u5148 `load_skill` \u2192 `long-image-download`\uFF0C\u8C03\u7528 `generate_long_image` \u751F\u6210\u5E76\u786E\u8BA4 `public/*.long.png` \u5DF2\u5B58\u5728\uFF0C\u518D\u56DE\u590D\u957F\u56FE\u9884\u89C8\u4E0E\u4E0B\u8F7D\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",
|
|
"- \u6309\u9700\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",
|
|
"- \u6309\u9700\u957F\u56FE\u9644\u4EF6\uFF1A\u7528 `generate_long_image` \u5199\u5165 `public/*.long.png`\uFF0C\u4E0D\u8981\u628A `.thumbnail.svg` \u5F53\u6210\u957F\u56FE"
|
|
);
|
|
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`",
|
|
"- **\u9ED8\u8BA4\u53EA\u751F\u6210 HTML**\uFF1A\u4E0D\u8981\u5728\u6CA1\u6709\u660E\u786E\u9700\u6C42\u65F6\u5F3A\u5236\u751F\u6210 Word\u3001PDF\u3001\u957F\u56FE\u7B49\u4F34\u751F\u6587\u4EF6",
|
|
"- **Word \u4E0B\u8F7D\u9875\uFF08\u6309\u9700\u4EB2\u81EA\u5B8C\u6210\uFF09**\uFF1A\u82E5\u7528\u6237\u660E\u786E\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",
|
|
"- **\u957F\u56FE\u4E0B\u8F7D\uFF08\u6309\u9700\u4EB2\u81EA\u5B8C\u6210\uFF09**\uFF1A\u82E5\u7528\u6237\u660E\u786E\u8981\u6C42\u957F\u56FE\u4E0B\u8F7D\uFF0C\u5FC5\u987B\u5148 `load_skill` \u2192 `long-image-download`\uFF0C\u8C03\u7528 `generate_long_image` \u751F\u6210\u5E76\u786E\u8BA4 `public/*.long.png` \u5DF2\u5B58\u5728",
|
|
"- **\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",
|
|
"- \u6309\u9700\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",
|
|
"- \u6309\u9700\u957F\u56FE\u94FE\u63A5\uFF1A\u751F\u6210 `report.long.png` \u540E\u7ED9 `[\u957F\u56FE\u9884\u89C8](.../report.long.png)`\uFF0C\u4E0B\u8F7D\u7528 `[\u4E0B\u8F7D\u957F\u56FE](.../report.html?download=long-image)`",
|
|
`- \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 = path5.join(publishDir, WORKSPACE_HINTS_FILENAME);
|
|
const legacyPath = path5.join(publishDir, LEGACY_WORKSPACE_HINTS_FILENAME);
|
|
const content = renderWorkspaceHints(context);
|
|
fs3.writeFileSync(hintsPath, content, "utf8");
|
|
if (fs3.existsSync(legacyPath)) {
|
|
fs3.unlinkSync(legacyPath);
|
|
}
|
|
return hintsPath;
|
|
}
|
|
function ensureDocxSkillInstalled(publishDir) {
|
|
if (!fs3.existsSync(DOCX_SKILL_DIR)) return null;
|
|
const skillRoot = path5.join(publishDir, ".agents", "skills", DOCX_SKILL_NAME);
|
|
fs3.rmSync(skillRoot, { recursive: true, force: true });
|
|
fs3.mkdirSync(path5.dirname(skillRoot), { recursive: true });
|
|
fs3.cpSync(DOCX_SKILL_DIR, skillRoot, { recursive: true });
|
|
return path5.join(skillRoot, "SKILL.md");
|
|
}
|
|
function ensureLongImageSkillInstalled(publishDir) {
|
|
if (!fs3.existsSync(LONG_IMAGE_SKILL_DIR)) return null;
|
|
const skillRoot = path5.join(publishDir, ".agents", "skills", LONG_IMAGE_SKILL_NAME);
|
|
fs3.rmSync(skillRoot, { recursive: true, force: true });
|
|
fs3.mkdirSync(path5.dirname(skillRoot), { recursive: true });
|
|
fs3.cpSync(LONG_IMAGE_SKILL_DIR, skillRoot, { recursive: true });
|
|
return path5.join(skillRoot, "SKILL.md");
|
|
}
|
|
function ensurePublishSkillInstalled(publishDir, context) {
|
|
ensureDocxSkillInstalled(publishDir);
|
|
ensureLongImageSkillInstalled(publishDir);
|
|
const skillRoot = path5.join(publishDir, ".agents", "skills", PUBLISH_SKILL_NAME);
|
|
fs3.mkdirSync(skillRoot, { recursive: true });
|
|
const skillPath = path5.join(skillRoot, "SKILL.md");
|
|
const content = renderPublishSkill(context);
|
|
const existing = fs3.existsSync(skillPath) ? fs3.readFileSync(skillPath, "utf8") : "";
|
|
if (existing !== content) {
|
|
fs3.writeFileSync(skillPath, content, "utf8");
|
|
}
|
|
return skillPath;
|
|
}
|
|
function ensureUserPublishLayout({
|
|
h5Root,
|
|
publicBaseUrl,
|
|
user,
|
|
legacyUsersRoot = null,
|
|
installWorkspaceHints = false
|
|
}) {
|
|
migrateUserPublishDir(h5Root, user, legacyUsersRoot);
|
|
const slug = resolvePublishKey(user);
|
|
const username = user.username ? resolveUsernameSlug(user) : slug;
|
|
const displayName = user.displayName ?? user.display_name ?? null;
|
|
const publishDir = resolvePublishDir(h5Root, user);
|
|
fs3.mkdirSync(path5.join(publishDir, "assets"), { recursive: true });
|
|
const context = { slug, username, displayName, publicBaseUrl, publishDir };
|
|
if (installWorkspaceHints) {
|
|
ensureWorkspaceHintsInstalled(publishDir, context);
|
|
ensurePublishSkillInstalled(publishDir, context);
|
|
}
|
|
return {
|
|
slug,
|
|
username,
|
|
displayName,
|
|
publishDir,
|
|
publicBaseUrl,
|
|
publicUrl: buildPublicUrl(publicBaseUrl, slug),
|
|
skillName: PUBLISH_SKILL_NAME,
|
|
constraints: buildPublishConstraints(context)
|
|
};
|
|
}
|
|
|
|
// user-memory-profile.mjs
|
|
import fs4 from "node:fs";
|
|
import path6 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 path6.join(workspaceRoot, USER_MEMORY_PROFILE_FILENAME);
|
|
}
|
|
function loadUserMemoryProfile(workspaceRoot) {
|
|
const profilePath = resolveUserMemoryProfilePath(workspaceRoot);
|
|
if (!fs4.existsSync(profilePath)) return null;
|
|
try {
|
|
const raw = fs4.readFileSync(profilePath, "utf8");
|
|
const parsed = JSON.parse(raw);
|
|
if (!parsed || typeof parsed !== "object") return null;
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
function ensureUserMemoryProfile(workspaceRoot, context) {
|
|
const profilePath = resolveUserMemoryProfilePath(workspaceRoot);
|
|
const existing = loadUserMemoryProfile(workspaceRoot);
|
|
if (existing) {
|
|
const addressName = resolveUserAddressName(context);
|
|
const nextDisplayName = addressName || existing.displayName;
|
|
if (nextDisplayName && nextDisplayName !== existing.displayName) {
|
|
const updated = {
|
|
...existing,
|
|
displayName: nextDisplayName,
|
|
updatedAt: Date.now()
|
|
};
|
|
fs4.writeFileSync(profilePath, `${JSON.stringify(updated, null, 2)}
|
|
`, "utf8");
|
|
return updated;
|
|
}
|
|
return existing;
|
|
}
|
|
const profile = buildInitialUserMemoryProfile({
|
|
userId: context.userId,
|
|
displayName: context.displayName,
|
|
username: context.username,
|
|
slug: context.slug
|
|
});
|
|
fs4.writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}
|
|
`, "utf8");
|
|
return profile;
|
|
}
|
|
function renderMemoryStoreGuidance({ addressName }) {
|
|
const name = addressName || "\u7528\u6237";
|
|
return [
|
|
"## TKMind \u957F\u671F\u8BB0\u5FC6\uFF08L3 / memory \u6269\u5C55\uFF09",
|
|
"",
|
|
`- \u5F53\u524D\u670D\u52A1\u5BF9\u8C61\uFF1A**${name}**\uFF08\u4EC5\u6B64\u7528\u6237\uFF0C\u4E0D\u5F97\u4E0E\u5176\u4ED6\u7528\u6237\u6DF7\u6DC6\uFF09`,
|
|
"- \u4F7F\u7528 memory \u5DE5\u5177\u8BB0\u5F55\u7528\u6237**\u660E\u786E\u8868\u8FBE**\u7684\u504F\u597D\u3001\u4E60\u60EF\u4E0E\u7A33\u5B9A\u4E8B\u5B9E",
|
|
"- \u8BB0\u5FC6\u5206\u7C7B\u5EFA\u8BAE\uFF1A",
|
|
" - `preference`\uFF1A\u56DE\u590D\u98CE\u683C\u3001\u683C\u5F0F\u3001\u8BED\u8A00\u3001\u79F0\u547C\u4E60\u60EF",
|
|
" - `project`\uFF1A\u5F53\u524D\u9879\u76EE\u76EE\u6807\u3001\u7EA6\u5B9A\u3001\u6280\u672F\u9009\u578B\uFF08\u4E0E L2 \u9879\u76EE\u8BB0\u5FC6\u4E92\u8865\uFF09",
|
|
" - `fact`\uFF1A\u957F\u671F\u7A33\u5B9A\u3001\u53EF\u590D\u7528\u7684\u4E8B\u5B9E\uFF08\u5982\u90E8\u95E8\u3001\u5E38\u7528\u5DE5\u5177\uFF09",
|
|
"- \u7528\u6237\u8BF4\u300C\u5FD8\u8BB0\u2026\u300D\u300C\u4E0D\u8981\u518D\u2026\u300D\u300C\u53D6\u6D88\u504F\u597D\u2026\u300D\u65F6\uFF0C\u5FC5\u987B\u66F4\u65B0\u6216\u5220\u9664\u5BF9\u5E94\u8BB0\u5FC6",
|
|
"- **\u7981\u6B62**\u8BB0\u5F55\u5176\u4ED6\u7528\u6237\u7684\u4FE1\u606F\uFF1B**\u7981\u6B62**\u731C\u6D4B\u672A\u660E\u786E\u8868\u8FBE\u7684\u504F\u597D",
|
|
"- \u4E0E L2 \u9879\u76EE\u8BB0\u5FC6\uFF08harness\uFF09\u5206\u5DE5\uFF1AL2 \u8BB0\u8FD1\u671F\u5DE5\u4F5C\u4E0E\u51B3\u7B56\u6458\u8981\uFF1BL3 \u8BB0\u53EF\u8DE8\u4F1A\u8BDD\u590D\u7528\u7684\u4E2A\u4EBA\u504F\u597D"
|
|
].join("\n");
|
|
}
|
|
function renderPreferenceLines(profile) {
|
|
const items = Array.isArray(profile?.preferences) ? profile.preferences : [];
|
|
if (items.length === 0) {
|
|
return ["- \uFF08\u6682\u65E0\u7ED3\u6784\u5316\u504F\u597D \u2014 \u53EF\u5728\u5BF9\u8BDD\u4E2D\u7528 memory \u5DE5\u5177\u8865\u5145\uFF09"];
|
|
}
|
|
return items.map((item) => {
|
|
if (typeof item === "string") return `- ${item}`;
|
|
const category = item?.category ? `[${item.category}] ` : "";
|
|
const label = item?.label ?? item?.key ?? "preference";
|
|
const value = item?.value ?? item?.content ?? "";
|
|
return `- ${category}${label}\uFF1A${value}`;
|
|
});
|
|
}
|
|
function renderUserMemoryProfileForHarness(profile, context = {}) {
|
|
const addressName = resolveUserAddressName({
|
|
displayName: profile?.displayName ?? context.displayName,
|
|
username: context.username,
|
|
slug: context.slug
|
|
});
|
|
const language = profile?.language === "zh-CN" ? "\u7B80\u4F53\u4E2D\u6587" : profile?.language ?? "\u7B80\u4F53\u4E2D\u6587";
|
|
const styleMap = {
|
|
concise: "\u7B80\u6D01\u76F4\u63A5\uFF0C\u5C11\u5E9F\u8BDD",
|
|
detailed: "\u8BE6\u7EC6\u5B8C\u6574\uFF0C\u5FC5\u8981\u65F6\u5C55\u5F00",
|
|
balanced: "\u7B80\u6D01\u52A1\u5B9E\uFF0C\u5FC5\u8981\u65F6\u8865\u5145\u7EC6\u8282"
|
|
};
|
|
const responseStyle = styleMap[profile?.responseStyle] ?? styleMap.balanced;
|
|
return [
|
|
"## TKMind \u7528\u6237\u504F\u597D\u753B\u50CF\uFF08L3\uFF09",
|
|
"",
|
|
`- \u7528\u6237\u79F0\u547C\uFF1A**${addressName}**`,
|
|
`- \u754C\u9762\u8BED\u8A00\uFF1A${language}`,
|
|
`- \u9ED8\u8BA4\u56DE\u590D\u98CE\u683C\uFF1A${responseStyle}`,
|
|
"",
|
|
"### \u5DF2\u8BB0\u5F55\u7684\u7ED3\u6784\u5316\u504F\u597D",
|
|
...renderPreferenceLines(profile)
|
|
].join("\n");
|
|
}
|
|
function 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 path7 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 path7.resolve(left) === path7.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 crypto4 from "node:crypto";
|
|
function createImgproxySigner(key, salt) {
|
|
const keyBuf = Buffer.from(key, "hex");
|
|
const saltBuf = Buffer.from(salt, "hex");
|
|
function signPath(path13) {
|
|
const signaturePayload = Buffer.concat([saltBuf, Buffer.from(path13)]);
|
|
const signature = crypto4.createHmac("sha256", keyBuf).update(signaturePayload).digest();
|
|
const signatureB64 = signature.toString("base64url");
|
|
return `/${signatureB64}${path13}`;
|
|
}
|
|
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 insecureDispatcher2 = new Agent2({
|
|
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 undiciFetch2(url, {
|
|
...init,
|
|
headers,
|
|
dispatcher: isHttpsTarget(target) ? insecureDispatcher2 : 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 parseFirstTokenSample = (value) => {
|
|
const parts = String(value ?? "").split(":");
|
|
return readNumber(parts[1]);
|
|
};
|
|
const percentile = (values, pct) => {
|
|
const sorted = values.map((value) => readNumber(value)).filter((value) => value > 0).sort((a, b) => a - b);
|
|
if (sorted.length === 0) return 0;
|
|
const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(pct / 100 * sorted.length) - 1));
|
|
return sorted[idx];
|
|
};
|
|
const firstTokenWindowStats = async (client, workerId, windowMs) => {
|
|
if (!client) return { count: 0, p50Ms: 0, p95Ms: 0 };
|
|
const now = Date.now();
|
|
const samples = await client.sendCommand([
|
|
"ZRANGEBYSCORE",
|
|
key("worker", workerId, "first_token_samples"),
|
|
String(now - windowMs),
|
|
String(now)
|
|
]).catch(() => []);
|
|
const values = samples.map(parseFirstTokenSample).filter((value) => value > 0);
|
|
return {
|
|
count: values.length,
|
|
p50Ms: percentile(values, 50),
|
|
p95Ms: percentile(values, 95)
|
|
};
|
|
};
|
|
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 samplesKey = key("worker", workerId, "first_token_samples");
|
|
const prev = readNumber(await client.get(ewmaKey).catch(() => null));
|
|
const next = prev > 0 ? Math.round(prev * 0.8 + sample * 0.2) : sample;
|
|
const nowMs2 = Date.now();
|
|
const now = String(nowMs2);
|
|
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);
|
|
await client.sendCommand([
|
|
"ZADD",
|
|
samplesKey,
|
|
String(nowMs2),
|
|
`${now}:${sample}:${Math.random().toString(36).slice(2)}`
|
|
]).catch(() => null);
|
|
await client.sendCommand(["ZREMRANGEBYSCORE", samplesKey, "0", String(nowMs2 - 60 * 60 * 1e3)]).catch(() => null);
|
|
await client.sendCommand(["EXPIRE", samplesKey, String(2 * 60 * 60)]).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"),
|
|
key("worker", workerId, "heartbeat_source"),
|
|
key("worker", workerId, "heartbeat_ok"),
|
|
key("worker", workerId, "heartbeat_status_code"),
|
|
key("worker", workerId, "heartbeat_latency_ms"),
|
|
key("worker", workerId, "heartbeat_error")
|
|
]).catch(() => []) : [];
|
|
const firstToken5m = await firstTokenWindowStats(client, workerId, 5 * 60 * 1e3);
|
|
const firstToken1h = await firstTokenWindowStats(client, workerId, 60 * 60 * 1e3);
|
|
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]),
|
|
heartbeatSource: values?.[21] ?? null,
|
|
heartbeatOk: values?.[22] == null ? null : /^(1|true|yes)$/i.test(String(values[22])),
|
|
heartbeatStatusCode: readNumber(values?.[23]),
|
|
heartbeatLatencyMs: readNumber(values?.[24]),
|
|
heartbeatError: values?.[25] || null,
|
|
firstToken5m,
|
|
firstToken1h,
|
|
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 = path8.resolve(process.cwd(), PUBLISH_ROOT_DIR, normalizedOwner);
|
|
const target = path8.resolve(root2, normalizedRelativePath);
|
|
if (target !== root2 && !target.startsWith(`${root2}${path8.sep}`)) return false;
|
|
return fs5.existsSync(target) && fs5.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(path8.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: llmProviderService2,
|
|
imgproxySigner = null
|
|
}) {
|
|
void imgproxySigner;
|
|
if (!llmProviderService2 || !userId) return null;
|
|
const rawImageUrls = extractImageUrlsFromMessage(userMessage);
|
|
if (rawImageUrls.length === 0) return null;
|
|
const originalDisplayText = typeof userMessage?.metadata?.displayText === "string" && userMessage.metadata.displayText.trim() ? userMessage.metadata.displayText : Array.isArray(userMessage?.content) ? userMessage.content.filter((item) => item?.type === "text" && typeof item.text === "string").map((item) => item.text).join("\n").trim() : "";
|
|
const imageItems = [];
|
|
for (const rawUrl of rawImageUrls) {
|
|
try {
|
|
const match = rawUrl.match(/\/mindspace\/v1\/assets\/([^/?#]+)(?:\/download)?/);
|
|
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 undiciFetch2(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 llmProviderService2.analyzeImagesWithVision(imageItems, "\u8BF7\u8BE6\u7EC6\u63CF\u8FF0\u56FE\u7247\u7684\u89C6\u89C9\u5185\u5BB9\uFF1A\u4E3B\u4F53\u3001\u989C\u8272\u3001\u98CE\u683C\u3001\u6784\u56FE\uFF0C\u4E0D\u8981\u751F\u6210\u4EE3\u7801\u6216\u9875\u9762\u65B9\u6848").catch(() => null);
|
|
const pathList = imageItems.map((item, i) => `\u56FE\u7247${i + 1}: <img src="${item.embedUrl}" alt="\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 <img> \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: llmProviderService2,
|
|
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 (!llmProviderService2 || !sessionId) return null;
|
|
try {
|
|
const target = await resolveTarget(sessionId);
|
|
return await llmProviderService2.applyBestProviderForSession(
|
|
sessionId,
|
|
(url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init)
|
|
);
|
|
} catch (err) {
|
|
console.warn(
|
|
"LLM provider apply skipped:",
|
|
err instanceof Error ? err.message : err
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
async function applyLocalFallbackForSession(sessionId) {
|
|
if (!llmProviderService2 || !sessionId) return null;
|
|
const target = await resolveTarget(sessionId);
|
|
return llmProviderService2.applyLocalFallbackForSession(
|
|
sessionId,
|
|
(url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init)
|
|
);
|
|
}
|
|
const visionActiveSessions = /* @__PURE__ */ new Set();
|
|
async function applyVisionProviderForSession(sessionId) {
|
|
if (!llmProviderService2 || !sessionId) return null;
|
|
const target = await resolveTarget(sessionId);
|
|
return llmProviderService2.applyVisionProviderForSession(
|
|
sessionId,
|
|
(url, init) => apiFetch(target, apiSecret, `${url.pathname}${url.search}`, init)
|
|
);
|
|
}
|
|
async function buildVisionBody(userMessage, userId, publishLayout) {
|
|
return buildVisionPayload({
|
|
userMessage,
|
|
userId,
|
|
publishLayout,
|
|
localFetchAsset,
|
|
llmProviderService: llmProviderService2,
|
|
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 (llmProviderService2 && messageHasImages(userMessage) && await llmProviderService2.hasVisionKey()) {
|
|
const publishLayout = await userAuth2.getUserPublishLayout(userId).catch(() => null);
|
|
const visionResult = await buildVisionBody(userMessage, userId, publishLayout).catch(() => null);
|
|
if (visionResult?.userMessage) {
|
|
finalUserMessage = visionResult.userMessage;
|
|
}
|
|
if (visionResult?.billableImageCount > 0 && 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 && llmProviderService2) {
|
|
const sessionId = sessionMatch?.[1];
|
|
if (sessionId) {
|
|
const hasImages = messageHasImages(req.body?.user_message);
|
|
if (hasImages && await llmProviderService2.hasVisionKey()) {
|
|
const publishLayout = await userAuth2.getUserPublishLayout(req.currentUser?.id).catch(() => null);
|
|
const visionResult = await buildVisionBody(
|
|
baseBody?.user_message ?? req.body?.user_message,
|
|
req.currentUser?.id,
|
|
publishLayout
|
|
).catch(() => null);
|
|
if (visionResult?.userMessage) {
|
|
baseBody = { ...baseBody ?? req.body, user_message: visionResult.userMessage };
|
|
}
|
|
if (visionResult?.billableImageCount > 0 && 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)
|
|
};
|
|
}
|
|
|
|
// tool-gateway.mjs
|
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
var CODE_EXECUTORS = /* @__PURE__ */ new Set(["aider", "openhands"]);
|
|
var DEFAULT_STDIO_LIMIT = 64 * 1024;
|
|
function envFlag2(value, fallback = false) {
|
|
const raw = String(value ?? "").trim().toLowerCase();
|
|
if (!raw) return fallback;
|
|
return ["1", "true", "yes", "on"].includes(raw);
|
|
}
|
|
function positiveInteger2(value, fallback) {
|
|
const n = Number(value);
|
|
if (!Number.isFinite(n) || n <= 0) return fallback;
|
|
return Math.floor(n);
|
|
}
|
|
function normalizeExecutor2(value, fallback = "aider") {
|
|
const normalized = String(value ?? fallback).trim().toLowerCase();
|
|
if (CODE_EXECUTORS.has(normalized)) return normalized;
|
|
return fallback;
|
|
}
|
|
function csvSet(value) {
|
|
return new Set(
|
|
String(value ?? "").split(",").map((item) => item.trim().toLowerCase()).filter(Boolean)
|
|
);
|
|
}
|
|
function appendLimited(current, chunk, limit) {
|
|
const next = `${current}${Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk ?? "")}`;
|
|
if (next.length <= limit) return next;
|
|
return next.slice(next.length - limit);
|
|
}
|
|
function extractToolInstruction(userMessage) {
|
|
const content = userMessage?.content;
|
|
if (typeof content === "string") return content.trim();
|
|
if (!Array.isArray(content)) {
|
|
return String(userMessage?.text ?? userMessage?.value ?? "").trim();
|
|
}
|
|
return content.map((item) => {
|
|
if (typeof item === "string") return item;
|
|
if (item?.type === "text") return item.text ?? "";
|
|
return "";
|
|
}).map((item) => String(item ?? "").trim()).filter(Boolean).join("\n").trim();
|
|
}
|
|
function createToolGateway({
|
|
llmProviderService: llmProviderService2,
|
|
env = process.env,
|
|
spawnImpl = nodeSpawn
|
|
} = {}) {
|
|
const enabled = envFlag2(env.MEMIND_TOOL_GATEWAY_ENABLED, false);
|
|
const dryRun = envFlag2(env.MEMIND_TOOL_GATEWAY_DRY_RUN, false);
|
|
const defaultExecutor = normalizeExecutor2(env.MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR, "aider");
|
|
const openhandsTaskTypes = csvSet(
|
|
env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES ?? "repo_refactor,multi_file,complex_repo"
|
|
);
|
|
const stdioLimit = positiveInteger2(env.MEMIND_TOOL_GATEWAY_STDIO_LIMIT, DEFAULT_STDIO_LIMIT);
|
|
function getStatus() {
|
|
return {
|
|
enabled,
|
|
dryRun,
|
|
protocol: "agent-run-v1",
|
|
executors: ["aider", "openhands"],
|
|
defaultExecutor,
|
|
openhandsTaskTypes: [...openhandsTaskTypes]
|
|
};
|
|
}
|
|
function selectExecutor({ userMessage, taskType } = {}) {
|
|
const metadata = userMessage?.metadata ?? {};
|
|
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
|
|
const requested = normalizeExecutor2(runMetadata.executor, "");
|
|
if (requested) return requested;
|
|
const normalizedTaskType = String(taskType ?? runMetadata.taskType ?? "").trim().toLowerCase();
|
|
if (openhandsTaskTypes.has(normalizedTaskType)) return "openhands";
|
|
return defaultExecutor;
|
|
}
|
|
async function executeJob({
|
|
runId,
|
|
requestId,
|
|
userId,
|
|
userMessage,
|
|
taskType,
|
|
cwd,
|
|
timeoutMs = 15 * 60 * 1e3
|
|
} = {}) {
|
|
if (!enabled) {
|
|
throw new Error("Tool Gateway is disabled");
|
|
}
|
|
if (!llmProviderService2?.getExecutorLaunchPlan) {
|
|
throw new Error("Tool Gateway missing llm provider service");
|
|
}
|
|
const instruction = extractToolInstruction(userMessage);
|
|
if (!instruction) {
|
|
throw new Error("Tool Gateway job missing instruction");
|
|
}
|
|
const executor = selectExecutor({ userMessage, taskType });
|
|
const plan = await llmProviderService2.getExecutorLaunchPlan(executor, {
|
|
cwd,
|
|
mode: "headless",
|
|
instruction,
|
|
purpose: "default",
|
|
includeSecret: true
|
|
});
|
|
if (!plan?.ok) {
|
|
throw new Error(plan?.message ?? `Tool Gateway launch plan unavailable for ${executor}`);
|
|
}
|
|
if (dryRun) {
|
|
return {
|
|
ok: true,
|
|
dryRun: true,
|
|
executor,
|
|
protocol: "agent-run-v1",
|
|
cwd: plan.cwd,
|
|
command: plan.command,
|
|
args: plan.args ?? [],
|
|
runId,
|
|
requestId,
|
|
userId
|
|
};
|
|
}
|
|
return await new Promise((resolve, reject) => {
|
|
let stdout = "";
|
|
let stderr = "";
|
|
const child = spawnImpl(plan.command, plan.args ?? [], {
|
|
cwd: plan.cwd,
|
|
env: { ...process.env, ...plan.env ?? {} },
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
const cleanup = () => {
|
|
if (timer) clearTimeout(timer);
|
|
};
|
|
const timer = setTimeout(() => {
|
|
child.kill("SIGTERM");
|
|
const err = new Error(`Tool Gateway job timed out after ${timeoutMs}ms`);
|
|
err.code = "TOOL_GATEWAY_TIMEOUT";
|
|
reject(err);
|
|
}, timeoutMs);
|
|
if (child.stdout instanceof EventEmitter) {
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout = appendLimited(stdout, chunk, stdioLimit);
|
|
});
|
|
}
|
|
if (child.stderr instanceof EventEmitter) {
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr = appendLimited(stderr, chunk, stdioLimit);
|
|
});
|
|
}
|
|
child.on("error", (err) => {
|
|
cleanup();
|
|
reject(err);
|
|
});
|
|
child.on("exit", (code, signal) => {
|
|
cleanup();
|
|
if (code === 0) {
|
|
resolve({
|
|
ok: true,
|
|
executor,
|
|
protocol: "agent-run-v1",
|
|
cwd: plan.cwd,
|
|
command: plan.command,
|
|
args: plan.args ?? [],
|
|
exitCode: code,
|
|
signal: signal ?? null,
|
|
stdout,
|
|
stderr
|
|
});
|
|
return;
|
|
}
|
|
const err = new Error(`Tool Gateway executor ${executor} exited with ${signal ?? code}`);
|
|
err.code = "TOOL_GATEWAY_EXECUTOR_FAILED";
|
|
err.executor = executor;
|
|
err.exitCode = code;
|
|
err.signal = signal ?? null;
|
|
err.stdout = stdout;
|
|
err.stderr = stderr;
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
return {
|
|
getStatus,
|
|
selectExecutor,
|
|
executeJob
|
|
};
|
|
}
|
|
|
|
// user-auth.mjs
|
|
import crypto5 from "node:crypto";
|
|
import fs8 from "node:fs";
|
|
import path11 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 fs6 from "node:fs";
|
|
import path9 from "node:path";
|
|
var UPLOAD_ZONE_CODES = ["oa", "public"];
|
|
function resolveMindspaceStorageRoot(h5Root, env = process.env) {
|
|
return path9.resolve(env.MINDSPACE_STORAGE_ROOT ?? path9.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 path9.join(workspaceRoot, categoryCode);
|
|
}
|
|
function resolveZoneFilePath(workspaceRoot, categoryCode, filename) {
|
|
return path9.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) {
|
|
fs6.mkdirSync(resolveZoneDir(workspaceRoot, code), { recursive: true });
|
|
}
|
|
}
|
|
function mirrorAssetToZone({ workspaceRoot, categoryCode, filename, sourcePath }) {
|
|
if (!UPLOAD_ZONE_CODES.includes(categoryCode)) return null;
|
|
if (!sourcePath || !fs6.existsSync(sourcePath)) return null;
|
|
ensureUserZoneDirs(workspaceRoot);
|
|
const dest = resolveZoneFilePath(workspaceRoot, categoryCode, filename);
|
|
fs6.mkdirSync(path9.dirname(dest), { recursive: true });
|
|
fs6.copyFileSync(sourcePath, dest);
|
|
return dest;
|
|
}
|
|
function isPathInsideUserWorkspace(workspaceRoot, requestedPath) {
|
|
const base = path9.resolve(workspaceRoot);
|
|
const resolved = path9.resolve(requestedPath);
|
|
return resolved === base || resolved.startsWith(`${base}${path9.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
|
|
- \u9ED8\u8BA4\u53EA\u751F\u6210 HTML\uFF1B\u4E0D\u8981\u5728\u6CA1\u6709\u660E\u786E\u9700\u6C42\u65F6\u5F3A\u5236\u751F\u6210 Word\u3001PDF\u3001\u957F\u56FE\u7B49\u4F34\u751F\u6587\u4EF6
|
|
- \u53EA\u6709\u7528\u6237\u660E\u786E\u8981\u6C42 Word / docx \u4E0B\u8F7D\u65F6\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
|
|
- \u7528\u6237\u660E\u786E\u8981\u6C42 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 \`<head>\` \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",
|
|
"- **\u9ED8\u8BA4\u53EA\u751F\u6210 HTML**\uFF1A\u4E0D\u8981\u5728\u6CA1\u6709\u660E\u786E\u9700\u6C42\u65F6\u5F3A\u5236\u751F\u6210 Word\u3001PDF\u3001\u957F\u56FE\u7B49\u4F34\u751F\u6587\u4EF6",
|
|
"- **Word \u4E0B\u8F7D\u9875**\uFF1A\u82E5\u7528\u6237\u660E\u786E\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\u660E\u786E\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 = path9.join(workspaceRoot, WORKSPACE_HINTS_FILENAME2);
|
|
const legacyPath = path9.join(workspaceRoot, LEGACY_WORKSPACE_HINTS_FILENAME2);
|
|
const content = renderUserSpaceHints({ ...context, workspaceRoot });
|
|
fs6.writeFileSync(hintsPath, content, "utf8");
|
|
if (fs6.existsSync(legacyPath)) {
|
|
fs6.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 = path9.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 fs7 from "node:fs";
|
|
import path10 from "node:path";
|
|
import { fileURLToPath as fileURLToPath4 } from "node:url";
|
|
var __dirname3 = path10.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,
|
|
"long-image-download": 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 = path10.join(h5Root, "skills");
|
|
if (!fs7.existsSync(skillsRoot)) return [];
|
|
const catalog = [];
|
|
for (const entry of fs7.readdirSync(skillsRoot, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
const skillPath = path10.join(skillsRoot, entry.name, "SKILL.md");
|
|
if (!fs7.existsSync(skillPath)) continue;
|
|
const raw = fs7.readFileSync(skillPath, "utf8");
|
|
const meta = parseSkillFrontmatter(raw);
|
|
const name = meta.name || entry.name;
|
|
catalog.push({
|
|
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) {
|
|
fs7.mkdirSync(destDir, { recursive: true });
|
|
for (const entry of fs7.readdirSync(srcDir, { withFileTypes: true })) {
|
|
const from = path10.join(srcDir, entry.name);
|
|
const to = path10.join(destDir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
copySkillTree(from, to);
|
|
} else {
|
|
fs7.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 = path10.join(publishDir, ".agents", "skills");
|
|
if (fs7.existsSync(agentsSkills)) {
|
|
for (const entry of fs7.readdirSync(agentsSkills, { withFileTypes: true })) {
|
|
if (!entry.isDirectory()) continue;
|
|
const skillName = entry.name;
|
|
const catalogItem = catalog.find((item) => item.name === skillName || item.dirName === skillName);
|
|
const resolvedName = catalogItem?.name ?? skillName;
|
|
if (platformNames.has(resolvedName) && !enabled.has(resolvedName)) {
|
|
fs7.rmSync(path10.join(agentsSkills, entry.name), { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
for (const item of catalog) {
|
|
if (!enabled.has(item.name)) continue;
|
|
const srcDir = path10.join(h5Root, "skills", item.dirName);
|
|
const destDir = path10.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 (fs7.existsSync(srcDir)) {
|
|
if (fs7.existsSync(destDir)) fs7.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 && crypto5.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 crypto5.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 = crypto5.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 crypto5.createHash("sha256").update(token).digest("hex");
|
|
}
|
|
function createUserAuth(pool2, options = {}) {
|
|
const usersRoot = path11.resolve(options.usersRoot ?? "/tmp/tkmind_go_users");
|
|
const h5Root = path11.resolve(options.h5Root ?? path11.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 (?, ?, ?, ?, ?)`,
|
|
[crypto5.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) => {
|
|
fs8.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: buildPublicUrl(publicBaseUrl, 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 && fs8.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 = path11.resolve(user.workspace_root);
|
|
const target = path11.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, ?, ?)`,
|
|
[
|
|
crypto5.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 = crypto5.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 = crypto5.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 = crypto5.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 = path11.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, ?, ?)`,
|
|
[
|
|
crypto5.randomUUID(),
|
|
userId,
|
|
"\u7A7A\u95F4\u6269\u5BB9\u6210\u529F",
|
|
`\u5DF2\u8D2D\u4E70 ${purchaseMb} MB \u7A7A\u95F4\uFF0C\u652F\u4ED8 \xA5${(costCents / 100).toFixed(2)}\u3002`,
|
|
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, ?, ?)`,
|
|
[
|
|
crypto5.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 = crypto5.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 = crypto5.randomUUID();
|
|
await pool2.query(
|
|
`INSERT INTO h5_wechat_agent_routes
|
|
(id, user_id, app_id, openid, agent_session_id, status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
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 = crypto5.randomUUID();
|
|
await pool2.query(
|
|
`INSERT INTO h5_wechat_mp_message_details
|
|
(id, app_id, openid, user_id, msg_id, msg_type, display_text, agent_text,
|
|
media_id, media_url, media_public_url, media_format,
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
crypto5.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 = crypto5.randomBytes(24).toString("base64url");
|
|
await pool2.query(
|
|
`INSERT INTO h5_wechat_pending_binds
|
|
(token, app_id, openid, unionid, nickname, avatar_url, return_to,
|
|
utm_source, utm_medium, utm_campaign, expires_at, created_at)
|
|
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) || crypto5.randomBytes(4).toString("hex");
|
|
let candidate = `wx_${suffix}`.slice(0, 32);
|
|
if (!isValidUsername(candidate)) {
|
|
candidate = `wx_${crypto5.randomBytes(4).toString("hex")}`;
|
|
}
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
const [rows] = await pool2.query(`SELECT id FROM h5_users WHERE username = ? LIMIT 1`, [
|
|
candidate
|
|
]);
|
|
if (!rows[0]) return candidate;
|
|
candidate = `wx_${suffix.slice(0, Math.max(1, 8 - attempt))}${crypto5.randomBytes(2).toString("hex")}`.slice(
|
|
0,
|
|
32
|
|
);
|
|
}
|
|
return `wx_${crypto5.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 = crypto5.randomBytes(24).toString("base64url");
|
|
const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword);
|
|
const userId = crypto5.randomUUID();
|
|
const layout = await publishLayoutFor({ id: userId, username: normalized });
|
|
const workspaceRoot = layout.publishDir;
|
|
const displayName = nickname?.trim() || "\u5FAE\u4FE1\u7528\u6237";
|
|
const conn = await 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[
|
|
crypto5.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 = path12.join(path12.dirname(fileURLToPath5(import.meta.url)), "..");
|
|
function loadEnvFile(filePath) {
|
|
if (!fs9.existsSync(filePath)) return;
|
|
for (const line of fs9.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,
|
|
recoverStale: false,
|
|
applyRecovery: false,
|
|
runId: "",
|
|
pollMs: Number(process.env.MEMIND_AGENT_RUN_WORKER_POLL_MS ?? 1e3),
|
|
limit: Number(process.env.MEMIND_AGENT_RUN_WORKER_BATCH_SIZE ?? 1),
|
|
staleMs: Number(process.env.MEMIND_AGENT_RUN_STALE_RUNNING_MS ?? process.env.MEMIND_AGENT_RUN_TIMEOUT_MS ?? 15 * 60 * 1e3)
|
|
};
|
|
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 === "--recover-stale") args2.recoverStale = true;
|
|
else if (item === "--apply-recovery") {
|
|
args2.recoverStale = true;
|
|
args2.applyRecovery = true;
|
|
} else if (item === "--run-id") args2.runId = String(argv[++i] ?? "").trim();
|
|
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 === "--stale-ms") args2.staleMs = Number(argv[++i] ?? args2.staleMs);
|
|
else if (item === "--help" || item === "-h") args2.help = true;
|
|
}
|
|
return args2;
|
|
}
|
|
function printHelp() {
|
|
console.log([
|
|
"Usage:",
|
|
" node scripts/agent-run-worker.mjs [--once] [--status] [--run-id <id>] [--poll-ms 1000] [--limit 1]",
|
|
" node scripts/agent-run-worker.mjs --recover-stale [--apply-recovery] [--stale-ms 900000]",
|
|
"",
|
|
"Notes:",
|
|
" - Processes existing h5_agent_runs queued/retryable rows through the Tool Gateway queue.",
|
|
" - Regular dispatch automatically fails stale running rows older than MEMIND_AGENT_RUN_TIMEOUT_MS.",
|
|
" - --recover-stale is dry-run by default; --apply-recovery marks stale running rows failed.",
|
|
" - Does not create agent runs by itself.",
|
|
" - --run-id dispatches exactly one known run and avoids scanning the global queue.",
|
|
" - 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 || path12.join(root, ".env"));
|
|
loadEnvFile(path12.join(root, ".env.local"));
|
|
var args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
printHelp();
|
|
process.exit(0);
|
|
}
|
|
var pool = createDbPool();
|
|
var baseUserAuth = createUserAuth(pool, {
|
|
usersRoot: process.env.H5_USERS_ROOT ?? path12.join(root, "users"),
|
|
h5Root: root
|
|
});
|
|
var overrideWorkingDir = String(process.env.MEMIND_AGENT_RUN_WORKDIR_OVERRIDE ?? "").trim();
|
|
var overrideWorkingDirUserId = String(process.env.MEMIND_AGENT_RUN_WORKDIR_USER_ID ?? "").trim();
|
|
var userAuth = overrideWorkingDir ? {
|
|
...baseUserAuth,
|
|
async resolveWorkingDir(userId) {
|
|
if (!overrideWorkingDirUserId || overrideWorkingDirUserId === userId) {
|
|
return path12.resolve(overrideWorkingDir);
|
|
}
|
|
return baseUserAuth.resolveWorkingDir(userId);
|
|
}
|
|
} : baseUserAuth;
|
|
var llmProviderService = createLlmProviderService(pool, {
|
|
apiTarget: process.env.TKMIND_API_TARGET ?? "https://127.0.0.1:18006",
|
|
apiSecret: process.env.TKMIND_SERVER__SECRET_KEY ?? "local-dev-secret"
|
|
});
|
|
var toolGateway = createToolGateway({ llmProviderService });
|
|
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,
|
|
toolGateway,
|
|
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() {
|
|
let result = null;
|
|
if (args.runId) {
|
|
gateway.dispatchRun(args.runId);
|
|
result = {
|
|
considered: 1,
|
|
dispatched: 1,
|
|
runId: args.runId
|
|
};
|
|
} else {
|
|
result = await gateway.dispatchQueuedRuns({ limit: args.limit });
|
|
}
|
|
await waitForIdle();
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
mode: args.status ? "status" : "dispatch",
|
|
runId: args.runId || null,
|
|
dispatched: args.status ? 0 : result.dispatched,
|
|
queue: await gateway.getQueueStatus()
|
|
}));
|
|
}
|
|
async function runStaleRecovery() {
|
|
const result = await gateway.recoverStaleRunningRuns({
|
|
staleMs: args.staleMs,
|
|
limit: args.limit,
|
|
dryRun: !args.applyRecovery,
|
|
reason: args.applyRecovery ? "manual_stale_recovery" : "manual_stale_recovery_dry_run"
|
|
});
|
|
console.log(JSON.stringify({
|
|
ok: true,
|
|
mode: args.applyRecovery ? "recover-stale-apply" : "recover-stale-dry-run",
|
|
recovery: result,
|
|
queue: await gateway.getQueueStatus()
|
|
}, null, 2));
|
|
}
|
|
async function waitForIdle() {
|
|
const startedAt = Date.now();
|
|
const maxWaitMs = Number(process.env.MEMIND_AGENT_RUN_WORKER_DRAIN_WAIT_MS ?? 20 * 60 * 1e3);
|
|
while (!stopping) {
|
|
const status = await gateway.getQueueStatus();
|
|
if (status.inFlight === 0 && status.pendingDispatches === 0) return;
|
|
if (Date.now() - startedAt > maxWaitMs) {
|
|
throw new Error(`agent run worker did not drain within ${maxWaitMs}ms`);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
}
|
|
var exitCode = 0;
|
|
try {
|
|
if (args.status) {
|
|
console.log(JSON.stringify({ ok: true, queue: await gateway.getQueueStatus() }, null, 2));
|
|
} else if (args.recoverStale) {
|
|
await runStaleRecovery();
|
|
} else if (args.once || args.runId) {
|
|
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)));
|
|
}
|
|
}
|
|
} catch (err) {
|
|
exitCode = 1;
|
|
console.error(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
await pool.end().catch(() => {
|
|
});
|
|
if (args.status || args.once) process.exit(exitCode);
|
|
}
|