merge: feature/page-data-dev-aider into main
Bring in Page Data Aider dev loop, adm code-run policy UI, and customer order demo. Keep system disclosure policy and agent code-run admin wiring side by side. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -32,9 +32,12 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
||||
# Code-tool agent run 灰度开关(默认关闭)。
|
||||
# 后端必须先开启 MEMIND_AGENT_CODE_RUNS_ENABLED,H5 构建开关才会生效。
|
||||
# 开启后,页面编辑子聊天会传 tool_mode=code;普通聊天仍需同时开启 AUTODETECT 才会按文本自动识别代码任务。
|
||||
# Phase 1.5:memindadm 运行时策略优先于 VITE_;设为 env 可强制只读 env(紧急 override)。
|
||||
# MEMIND_CODE_RUN_POLICY_SOURCE=env
|
||||
# MEMIND_AGENT_CODE_RUNS_ENABLED=0
|
||||
# VITE_AGENT_CODE_RUNS_ENABLED=0
|
||||
# VITE_AGENT_CODE_RUNS_AUTODETECT=0
|
||||
# VITE_AGENT_PAGE_DATA_DEV_AUTODETECT=0
|
||||
|
||||
# Tool Gateway Queue v0(/agent/runs 后台任务队列,默认保守限流)。
|
||||
# MEMIND_AGENT_RUN_QUEUE_CONCURRENCY=1
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createImageMakeAdminConfigService } from './image-make-admin-config.mjs
|
||||
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
||||
import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs';
|
||||
import { createSystemDisclosurePolicyService } from './system-disclosure-policy.mjs';
|
||||
import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-config.mjs';
|
||||
import { createMindSearchConfigService } from './mindsearch-config.mjs';
|
||||
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
|
||||
import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
|
||||
@@ -116,6 +117,7 @@ export async function createAdminServices(env = {}) {
|
||||
autoRefresh: false,
|
||||
});
|
||||
await systemDisclosurePolicyService.initialize();
|
||||
const agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env });
|
||||
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
||||
const adminSystemTestService = createAdminSystemTestService({
|
||||
pool,
|
||||
@@ -158,6 +160,7 @@ export async function createAdminServices(env = {}) {
|
||||
mindSearchConfigService,
|
||||
skillRuntimeConfigService,
|
||||
systemDisclosurePolicyService,
|
||||
agentCodeRunPolicyService,
|
||||
wechatScheduleLlmConfigService,
|
||||
adminSystemTestService,
|
||||
plazaPosts,
|
||||
|
||||
@@ -33,6 +33,7 @@ function plazaRouteError(res, req, error) {
|
||||
* @param {object|null} deps.memoryV2ConfigService
|
||||
* @param {object|null} deps.skillRuntimeConfigService
|
||||
* @param {object|null} deps.systemDisclosurePolicyService
|
||||
* @param {object|null} deps.agentCodeRunPolicyService
|
||||
* @param {object|null} deps.adminSystemTestService
|
||||
* @param {object|null} deps.plazaPosts
|
||||
* @param {object|null} deps.plazaOps
|
||||
@@ -50,6 +51,7 @@ export function createAdminApi({
|
||||
mindSearchConfigService,
|
||||
skillRuntimeConfigService,
|
||||
systemDisclosurePolicyService,
|
||||
agentCodeRunPolicyService,
|
||||
adminSystemTestService,
|
||||
plazaPosts,
|
||||
plazaOps,
|
||||
@@ -286,6 +288,48 @@ export function createAdminApi({
|
||||
return res.json(await skillRuntimeConfigService.getPublicRuntimeConfig());
|
||||
});
|
||||
|
||||
adminApi.get('/agent-code-run/config', requireAdmin, async (_req, res) => {
|
||||
if (!agentCodeRunPolicyService?.getAdminConfig) {
|
||||
return res.status(503).json({ message: 'Agent Code Run 配置服务未启用' });
|
||||
}
|
||||
return res.json(await agentCodeRunPolicyService.getAdminConfig());
|
||||
});
|
||||
|
||||
const updateAgentCodeRunConfig = async (req, res) => {
|
||||
if (!agentCodeRunPolicyService?.updateAdminConfig) {
|
||||
return res.status(503).json({ message: 'Agent Code Run 配置服务未启用' });
|
||||
}
|
||||
try {
|
||||
const result = await agentCodeRunPolicyService.updateAdminConfig(req.body?.config ?? req.body ?? {}, {
|
||||
updatedBy: req.currentUser.id,
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
if (err?.code === 'CODE_RUN_POLICY_ENV_LOCKED') {
|
||||
return res.status(409).json({ message: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
adminApi.put('/agent-code-run/config', requireAdmin, updateAgentCodeRunConfig);
|
||||
adminApi.patch('/agent-code-run/config', requireAdmin, updateAgentCodeRunConfig);
|
||||
|
||||
adminApi.get('/agent-code-run/runtime', requireAdmin, async (_req, res) => {
|
||||
if (!agentCodeRunPolicyService?.getRuntimeState) {
|
||||
return res.status(503).json({ message: 'Agent Code Run 配置服务未启用' });
|
||||
}
|
||||
return res.json(await agentCodeRunPolicyService.getRuntimeState());
|
||||
});
|
||||
|
||||
adminApi.get('/agent-code-run/runs', requireAdmin, async (req, res) => {
|
||||
if (!agentCodeRunPolicyService?.listRecentPageDataDevRuns) {
|
||||
return res.status(503).json({ message: 'Agent Code Run 配置服务未启用' });
|
||||
}
|
||||
const limit = Number(req.query?.limit ?? 50);
|
||||
return res.json(await agentCodeRunPolicyService.listRecentPageDataDevRuns({ limit }));
|
||||
});
|
||||
|
||||
adminApi.post('/system-tests/skill-validation', requireAdmin, async (req, res) => {
|
||||
if (!adminSystemTestService?.runSkillValidation) {
|
||||
return res.status(503).json({ message: '系统测试服务未启用' });
|
||||
|
||||
@@ -80,7 +80,9 @@ const CONSOLES = {
|
||||
imageMakeAdminConfigService: services.imageMakeAdminConfigService,
|
||||
memoryV2ConfigService: services.memoryV2ConfigService,
|
||||
mindSearchConfigService: services.mindSearchConfigService,
|
||||
skillRuntimeConfigService: services.skillRuntimeConfigService,
|
||||
systemDisclosurePolicyService: services.systemDisclosurePolicyService,
|
||||
agentCodeRunPolicyService: services.agentCodeRunPolicyService,
|
||||
adminSystemTestService: services.adminSystemTestService,
|
||||
plazaPosts: services.plazaPosts,
|
||||
plazaOps: services.plazaOps,
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
const CONFIG_TABLE = 'h5_agent_code_run_config';
|
||||
const CONFIG_SCOPE = 'global';
|
||||
const POLICY_SOURCE_ENV = 'env';
|
||||
const POLICY_SOURCE_ENV_OVERRIDE = 'env-override';
|
||||
const POLICY_SOURCE_ENV_MIGRATION = 'env-migration';
|
||||
const POLICY_SOURCE_ADMIN_DB = 'admin-db';
|
||||
const POLICY_SOURCE_DEFAULT = 'default';
|
||||
|
||||
const DEFAULT_TASK_TYPES = Object.freeze([
|
||||
'page_data_dev',
|
||||
'page_data_dev_complex',
|
||||
'h5_chat_code_task',
|
||||
'page_edit_code_task',
|
||||
]);
|
||||
|
||||
function defaultConfigShape() {
|
||||
return {
|
||||
codeRun: {
|
||||
enabled: false,
|
||||
clientEnabled: false,
|
||||
generalAutodetect: false,
|
||||
requireValidation: true,
|
||||
userAllowlist: [],
|
||||
taskTypeAllowlist: [...DEFAULT_TASK_TYPES],
|
||||
},
|
||||
pageDataDev: {
|
||||
autodetect: false,
|
||||
},
|
||||
meta: {
|
||||
notes: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function envFlag(value, fallback = false) {
|
||||
const raw = String(value ?? '').trim().toLowerCase();
|
||||
if (!raw) return fallback;
|
||||
return ['1', 'true', 'yes', 'on'].includes(raw);
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
if (value == null || value === '') return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeStringList(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
return String(value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return value.map((item) => String(item ?? '').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function cloneConfig(config = null) {
|
||||
return structuredClone?.(config ?? defaultConfigShape())
|
||||
?? JSON.parse(JSON.stringify(config ?? defaultConfigShape()));
|
||||
}
|
||||
|
||||
function parseJsonLike(value, fallback) {
|
||||
if (value == null || value === '') return fallback;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object') return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function policySourceFromEnv(env = process.env) {
|
||||
return String(env.MEMIND_CODE_RUN_POLICY_SOURCE ?? '').trim().toLowerCase() === 'env'
|
||||
? POLICY_SOURCE_ENV_OVERRIDE
|
||||
: null;
|
||||
}
|
||||
|
||||
function buildConfigFromEnv(env = process.env) {
|
||||
const config = defaultConfigShape();
|
||||
config.codeRun.enabled = envFlag(env.MEMIND_AGENT_CODE_RUNS_ENABLED);
|
||||
config.codeRun.clientEnabled = envFlag(env.VITE_AGENT_CODE_RUNS_ENABLED, config.codeRun.enabled);
|
||||
config.codeRun.generalAutodetect = envFlag(env.VITE_AGENT_CODE_RUNS_AUTODETECT);
|
||||
config.codeRun.requireValidation = envFlag(env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION, true);
|
||||
config.codeRun.userAllowlist = normalizeStringList(env.MEMIND_AGENT_CODE_RUNS_USER_IDS);
|
||||
const taskTypes = normalizeStringList(env.MEMIND_AGENT_CODE_RUN_TASK_TYPES);
|
||||
config.codeRun.taskTypeAllowlist = taskTypes.length ? taskTypes : [...DEFAULT_TASK_TYPES];
|
||||
config.pageDataDev.autodetect = envFlag(env.VITE_AGENT_PAGE_DATA_DEV_AUTODETECT);
|
||||
return config;
|
||||
}
|
||||
|
||||
function envPolicyActive(config) {
|
||||
return Boolean(config?.codeRun?.enabled)
|
||||
|| Boolean(config?.codeRun?.clientEnabled)
|
||||
|| Boolean(config?.pageDataDev?.autodetect)
|
||||
|| (config?.codeRun?.userAllowlist?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
function mergePatch(currentConfig, patch = {}) {
|
||||
const next = cloneConfig(currentConfig);
|
||||
const codeRunPatch = patch?.codeRun ?? {};
|
||||
const pageDataDevPatch = patch?.pageDataDev ?? {};
|
||||
|
||||
if ('enabled' in codeRunPatch) next.codeRun.enabled = normalizeBoolean(codeRunPatch.enabled, false);
|
||||
if ('clientEnabled' in codeRunPatch) {
|
||||
next.codeRun.clientEnabled = normalizeBoolean(codeRunPatch.clientEnabled, false);
|
||||
}
|
||||
if ('generalAutodetect' in codeRunPatch) {
|
||||
next.codeRun.generalAutodetect = normalizeBoolean(codeRunPatch.generalAutodetect, false);
|
||||
}
|
||||
if ('requireValidation' in codeRunPatch) {
|
||||
next.codeRun.requireValidation = normalizeBoolean(codeRunPatch.requireValidation, true);
|
||||
}
|
||||
if ('userAllowlist' in codeRunPatch) {
|
||||
next.codeRun.userAllowlist = normalizeStringList(codeRunPatch.userAllowlist);
|
||||
}
|
||||
if ('taskTypeAllowlist' in codeRunPatch) {
|
||||
const taskTypes = normalizeStringList(codeRunPatch.taskTypeAllowlist);
|
||||
next.codeRun.taskTypeAllowlist = taskTypes.length ? taskTypes : [...DEFAULT_TASK_TYPES];
|
||||
}
|
||||
if ('autodetect' in pageDataDevPatch) {
|
||||
next.pageDataDev.autodetect = normalizeBoolean(pageDataDevPatch.autodetect, false);
|
||||
}
|
||||
if (patch?.meta && typeof patch.meta.notes === 'string') {
|
||||
next.meta.notes = patch.meta.notes;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function flattenPolicy(config, source) {
|
||||
const taskTypeAllowlist = normalizeStringList(config.codeRun.taskTypeAllowlist);
|
||||
return {
|
||||
source,
|
||||
enabled: Boolean(config.codeRun.enabled),
|
||||
clientEnabled: Boolean(config.codeRun.clientEnabled),
|
||||
generalAutodetect: Boolean(config.codeRun.generalAutodetect),
|
||||
requireValidation: Boolean(config.codeRun.requireValidation),
|
||||
pageDataDevAutodetect: Boolean(config.pageDataDev.autodetect),
|
||||
userAllowlist: normalizeStringList(config.codeRun.userAllowlist),
|
||||
taskTypeAllowlist: taskTypeAllowlist.length ? taskTypeAllowlist : [...DEFAULT_TASK_TYPES],
|
||||
};
|
||||
}
|
||||
|
||||
export function isUserAllowedByCodeRunPolicy(userId, policy) {
|
||||
if (!policy?.enabled) return false;
|
||||
const allowlist = policy.userAllowlist ?? [];
|
||||
if (allowlist.length === 0) return true;
|
||||
return allowlist.includes(String(userId ?? '').trim());
|
||||
}
|
||||
|
||||
export function isTaskTypeAllowedByCodeRunPolicy(taskType, policy) {
|
||||
const allowlist = (policy?.taskTypeAllowlist ?? []).map((item) => String(item).toLowerCase());
|
||||
if (allowlist.length === 0) return true;
|
||||
const normalized = String(taskType ?? '').trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
return allowlist.includes(normalized);
|
||||
}
|
||||
|
||||
async function ensureConfigTable(pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
|
||||
config_scope VARCHAR(32) PRIMARY KEY,
|
||||
config_json JSON NOT NULL,
|
||||
updated_by CHAR(36) NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
|
||||
async function loadStoredState(pool) {
|
||||
await ensureConfigTable(pool);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT config_json, updated_by, updated_at
|
||||
FROM ${CONFIG_TABLE}
|
||||
WHERE config_scope = ?
|
||||
LIMIT 1`,
|
||||
[CONFIG_SCOPE],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
const parsed = parseJsonLike(row.config_json, {});
|
||||
return {
|
||||
config: mergePatch(defaultConfigShape(), parsed),
|
||||
updatedAt: Number(row.updated_at ?? 0) || null,
|
||||
updatedBy: row.updated_by ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgentCodeRunAdminConfigService(pool, { env = process.env } = {}) {
|
||||
async function loadEffectiveConfig() {
|
||||
const forcedEnvSource = policySourceFromEnv(env);
|
||||
if (forcedEnvSource) {
|
||||
const config = buildConfigFromEnv(env);
|
||||
return {
|
||||
config,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: forcedEnvSource,
|
||||
};
|
||||
}
|
||||
|
||||
const stored = await loadStoredState(pool);
|
||||
if (stored) {
|
||||
return {
|
||||
config: cloneConfig(stored.config),
|
||||
updatedAt: stored.updatedAt,
|
||||
updatedBy: stored.updatedBy,
|
||||
source: POLICY_SOURCE_ADMIN_DB,
|
||||
};
|
||||
}
|
||||
|
||||
const envConfig = buildConfigFromEnv(env);
|
||||
if (envPolicyActive(envConfig)) {
|
||||
return {
|
||||
config: envConfig,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: POLICY_SOURCE_ENV_MIGRATION,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
config: defaultConfigShape(),
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
source: POLICY_SOURCE_DEFAULT,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async getAdminConfig() {
|
||||
const state = await loadEffectiveConfig();
|
||||
return {
|
||||
config: state.config,
|
||||
updatedAt: state.updatedAt,
|
||||
updatedBy: state.updatedBy,
|
||||
source: state.source,
|
||||
envOverrideActive: state.source === POLICY_SOURCE_ENV_OVERRIDE,
|
||||
};
|
||||
},
|
||||
|
||||
async updateAdminConfig(patch = {}, { updatedBy = null } = {}) {
|
||||
if (policySourceFromEnv(env)) {
|
||||
throw Object.assign(new Error('MEMIND_CODE_RUN_POLICY_SOURCE=env 时不允许通过后台修改'), {
|
||||
code: 'CODE_RUN_POLICY_ENV_LOCKED',
|
||||
});
|
||||
}
|
||||
const stored = await loadStoredState(pool);
|
||||
const base = stored?.config ?? defaultConfigShape();
|
||||
const nextConfig = mergePatch(base, patch.config ?? patch);
|
||||
await ensureConfigTable(pool);
|
||||
const now = Date.now();
|
||||
await pool.query(
|
||||
`INSERT INTO ${CONFIG_TABLE}
|
||||
(config_scope, config_json, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
config_json = VALUES(config_json),
|
||||
updated_by = VALUES(updated_by),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
[CONFIG_SCOPE, JSON.stringify(nextConfig), updatedBy, now],
|
||||
);
|
||||
return this.getAdminConfig();
|
||||
},
|
||||
|
||||
async getRuntimeState() {
|
||||
const state = await loadEffectiveConfig();
|
||||
const policy = flattenPolicy(state.config, state.source);
|
||||
return {
|
||||
source: state.source,
|
||||
updatedAt: state.updatedAt,
|
||||
updatedBy: state.updatedBy,
|
||||
config: state.config,
|
||||
policy,
|
||||
envOverrideActive: state.source === POLICY_SOURCE_ENV_OVERRIDE,
|
||||
};
|
||||
},
|
||||
|
||||
async getEffectivePolicy(userId) {
|
||||
const state = await loadEffectiveConfig();
|
||||
const policy = flattenPolicy(state.config, state.source);
|
||||
return {
|
||||
...policy,
|
||||
userAllowed: isUserAllowedByCodeRunPolicy(userId, policy),
|
||||
};
|
||||
},
|
||||
|
||||
async getPublicClientPolicy(userId) {
|
||||
const policy = await this.getEffectivePolicy(userId);
|
||||
const enabled = policy.enabled && policy.clientEnabled && policy.userAllowed;
|
||||
return {
|
||||
codeRun: {
|
||||
enabled,
|
||||
pageDataDevAutodetect: enabled && policy.pageDataDevAutodetect,
|
||||
generalAutodetect: enabled && policy.generalAutodetect,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
async getRuntimeStatusSummary() {
|
||||
const state = await loadEffectiveConfig();
|
||||
const policy = flattenPolicy(state.config, state.source);
|
||||
return {
|
||||
source: policy.source,
|
||||
enabled: policy.enabled,
|
||||
clientEnabled: policy.clientEnabled,
|
||||
userAllowlist: policy.userAllowlist,
|
||||
taskTypeAllowlist: policy.taskTypeAllowlist,
|
||||
requireValidation: policy.requireValidation,
|
||||
pageDataDevAutodetect: policy.pageDataDevAutodetect,
|
||||
generalAutodetect: policy.generalAutodetect,
|
||||
envOverrideActive: policy.source === POLICY_SOURCE_ENV_OVERRIDE,
|
||||
updatedAt: state.updatedAt,
|
||||
updatedBy: state.updatedBy,
|
||||
};
|
||||
},
|
||||
|
||||
async listRecentPageDataDevRuns({ limit = 50 } = {}) {
|
||||
const pageDataTaskTypes = new Set(['page_data_dev', 'page_data_dev_complex']);
|
||||
const capped = Math.min(Math.max(Number(limit) || 50, 1), 200);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, user_id, request_id, status, error_message, created_at, updated_at, user_message_json
|
||||
FROM h5_agent_runs
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?`,
|
||||
[capped * 4],
|
||||
);
|
||||
const runs = [];
|
||||
for (const row of rows) {
|
||||
if (runs.length >= capped) break;
|
||||
const parsed = parseJsonLike(row.user_message_json, {});
|
||||
const metadata = parsed?.metadata ?? {};
|
||||
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
|
||||
const taskType = String(runMetadata.taskType ?? metadata.taskType ?? '').trim().toLowerCase();
|
||||
if (!pageDataTaskTypes.has(taskType)) continue;
|
||||
runs.push({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
requestId: row.request_id,
|
||||
status: row.status,
|
||||
taskType,
|
||||
executor: String(runMetadata.executor ?? metadata.executor ?? '').trim() || null,
|
||||
errorMessage: row.error_message ?? null,
|
||||
createdAt: Number(row.created_at ?? 0) || null,
|
||||
updatedAt: Number(row.updated_at ?? 0) || null,
|
||||
});
|
||||
}
|
||||
return { runs, limit: capped };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const agentCodeRunAdminConfigInternals = {
|
||||
CONFIG_SCOPE,
|
||||
CONFIG_TABLE,
|
||||
DEFAULT_TASK_TYPES,
|
||||
defaultConfigShape,
|
||||
buildConfigFromEnv,
|
||||
mergePatch,
|
||||
flattenPolicy,
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
agentCodeRunAdminConfigInternals,
|
||||
createAgentCodeRunAdminConfigService,
|
||||
isTaskTypeAllowedByCodeRunPolicy,
|
||||
isUserAllowedByCodeRunPolicy,
|
||||
} from './agent-code-run-admin-config.mjs';
|
||||
|
||||
function createMemoryPool(initialRow = null) {
|
||||
const state = { row: initialRow };
|
||||
return {
|
||||
state,
|
||||
async query(sql, params = []) {
|
||||
const normalized = String(sql).replace(/\s+/g, ' ').trim();
|
||||
if (normalized.startsWith('CREATE TABLE')) return [[]];
|
||||
if (normalized.includes('SELECT config_json')) {
|
||||
return [state.row ? [state.row] : []];
|
||||
}
|
||||
if (normalized.includes('INSERT INTO h5_agent_code_run_config')) {
|
||||
state.row = {
|
||||
config_json: params[1],
|
||||
updated_by: params[2],
|
||||
updated_at: params[3],
|
||||
};
|
||||
return [{ affectedRows: 1 }];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('default policy is fail closed', async () => {
|
||||
const service = createAgentCodeRunAdminConfigService(createMemoryPool(), { env: {} });
|
||||
const policy = await service.getEffectivePolicy('user-1');
|
||||
assert.equal(policy.enabled, false);
|
||||
assert.equal(policy.clientEnabled, false);
|
||||
assert.equal(policy.pageDataDevAutodetect, false);
|
||||
assert.equal(policy.source, 'default');
|
||||
});
|
||||
|
||||
test('env migration activates when legacy env flags are set', async () => {
|
||||
const service = createAgentCodeRunAdminConfigService(createMemoryPool(), {
|
||||
env: {
|
||||
MEMIND_AGENT_CODE_RUNS_ENABLED: '1',
|
||||
VITE_AGENT_PAGE_DATA_DEV_AUTODETECT: '1',
|
||||
MEMIND_AGENT_CODE_RUNS_USER_IDS: 'user-a',
|
||||
},
|
||||
});
|
||||
const policy = await service.getEffectivePolicy('user-a');
|
||||
assert.equal(policy.source, 'env-migration');
|
||||
assert.equal(policy.enabled, true);
|
||||
assert.equal(policy.pageDataDevAutodetect, true);
|
||||
assert.equal(policy.userAllowed, true);
|
||||
assert.equal(isUserAllowedByCodeRunPolicy('user-b', policy), false);
|
||||
});
|
||||
|
||||
test('admin-db config overrides env migration defaults', async () => {
|
||||
const pool = createMemoryPool({
|
||||
config_json: JSON.stringify({
|
||||
codeRun: {
|
||||
enabled: true,
|
||||
clientEnabled: true,
|
||||
pageDataDevAutodetect: false,
|
||||
generalAutodetect: false,
|
||||
requireValidation: true,
|
||||
userAllowlist: [],
|
||||
taskTypeAllowlist: ['page_data_dev'],
|
||||
},
|
||||
pageDataDev: { autodetect: true },
|
||||
}),
|
||||
updated_by: 'admin-1',
|
||||
updated_at: 123,
|
||||
});
|
||||
const service = createAgentCodeRunAdminConfigService(pool, {
|
||||
env: { MEMIND_AGENT_CODE_RUNS_ENABLED: '0' },
|
||||
});
|
||||
const policy = await service.getEffectivePolicy('user-1');
|
||||
assert.equal(policy.source, 'admin-db');
|
||||
assert.equal(policy.enabled, true);
|
||||
assert.equal(policy.pageDataDevAutodetect, true);
|
||||
assert.equal(isTaskTypeAllowedByCodeRunPolicy('page_data_dev', policy), true);
|
||||
assert.equal(isTaskTypeAllowedByCodeRunPolicy('h5_chat_code_task', policy), false);
|
||||
});
|
||||
|
||||
test('updateAdminConfig persists patch to database', async () => {
|
||||
const pool = createMemoryPool();
|
||||
const service = createAgentCodeRunAdminConfigService(pool, { env: {} });
|
||||
const saved = await service.updateAdminConfig(
|
||||
{
|
||||
config: {
|
||||
codeRun: { enabled: true, clientEnabled: true },
|
||||
pageDataDev: { autodetect: true },
|
||||
},
|
||||
},
|
||||
{ updatedBy: 'admin-1' },
|
||||
);
|
||||
assert.equal(saved.source, 'admin-db');
|
||||
assert.equal(saved.config.codeRun.enabled, true);
|
||||
assert.equal(saved.config.pageDataDev.autodetect, true);
|
||||
const clientPolicy = await service.getPublicClientPolicy('user-1');
|
||||
assert.equal(clientPolicy.codeRun.enabled, true);
|
||||
assert.equal(clientPolicy.codeRun.pageDataDevAutodetect, true);
|
||||
});
|
||||
|
||||
test('MEMIND_CODE_RUN_POLICY_SOURCE=env locks admin updates', async () => {
|
||||
const service = createAgentCodeRunAdminConfigService(createMemoryPool(), {
|
||||
env: { MEMIND_CODE_RUN_POLICY_SOURCE: 'env', MEMIND_AGENT_CODE_RUNS_ENABLED: '1' },
|
||||
});
|
||||
await assert.rejects(
|
||||
() => service.updateAdminConfig({ config: { codeRun: { enabled: false } } }),
|
||||
/不允许通过后台修改/,
|
||||
);
|
||||
});
|
||||
|
||||
test('getPublicClientPolicy disables client flags when user not allowlisted', async () => {
|
||||
const config = agentCodeRunAdminConfigInternals.defaultConfigShape();
|
||||
config.codeRun.enabled = true;
|
||||
config.codeRun.clientEnabled = true;
|
||||
config.codeRun.userAllowlist = ['allowed-user'];
|
||||
config.pageDataDev.autodetect = true;
|
||||
const pool = createMemoryPool({
|
||||
config_json: JSON.stringify(config),
|
||||
updated_by: null,
|
||||
updated_at: 1,
|
||||
});
|
||||
const service = createAgentCodeRunAdminConfigService(pool, { env: {} });
|
||||
const denied = await service.getPublicClientPolicy('other-user');
|
||||
assert.equal(denied.codeRun.enabled, false);
|
||||
const allowed = await service.getPublicClientPolicy('allowed-user');
|
||||
assert.equal(allowed.codeRun.enabled, true);
|
||||
assert.equal(allowed.codeRun.pageDataDevAutodetect, true);
|
||||
});
|
||||
|
||||
test('listRecentPageDataDevRuns filters page data dev task types', async () => {
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
const normalized = String(sql).replace(/\s+/g, ' ').trim();
|
||||
if (normalized.includes('FROM h5_agent_runs')) {
|
||||
return [[
|
||||
{
|
||||
id: 'run-1',
|
||||
user_id: 'user-1',
|
||||
request_id: 'req-1',
|
||||
status: 'failed',
|
||||
error_message: 'boom',
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
user_message_json: JSON.stringify({
|
||||
metadata: { memindRun: { taskType: 'page_data_dev', executor: 'aider' } },
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'run-2',
|
||||
user_id: 'user-1',
|
||||
request_id: 'req-2',
|
||||
status: 'succeeded',
|
||||
error_message: null,
|
||||
created_at: 3,
|
||||
updated_at: 4,
|
||||
user_message_json: JSON.stringify({
|
||||
metadata: { memindRun: { taskType: 'h5_chat_code_task' } },
|
||||
}),
|
||||
},
|
||||
]];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
};
|
||||
const service = createAgentCodeRunAdminConfigService(pool, { env: {} });
|
||||
const result = await service.listRecentPageDataDevRuns({ limit: 10 });
|
||||
assert.equal(result.runs.length, 1);
|
||||
assert.equal(result.runs[0].taskType, 'page_data_dev');
|
||||
assert.equal(result.runs[0].executor, 'aider');
|
||||
});
|
||||
+42
-23
@@ -56,12 +56,32 @@ export function createPostAgentRunsHandler({
|
||||
sessionAccess = null,
|
||||
agentRunGateway,
|
||||
mindSpaceAssetAgent = null,
|
||||
codeRunPolicyService = null,
|
||||
codeRunsEnabled = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED),
|
||||
codeRunUserIds = parseUserIdSet(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS),
|
||||
codeRunTaskTypes = parseTaskTypeSet(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES),
|
||||
requireCodeRunValidation = envFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION),
|
||||
}) {
|
||||
const sessionStore = sessionAccess ?? createSessionAccess({ userAuth, enabled: false });
|
||||
|
||||
async function resolveCodeRunPolicy(userId) {
|
||||
if (codeRunPolicyService?.getEffectivePolicy) {
|
||||
return codeRunPolicyService.getEffectivePolicy(userId);
|
||||
}
|
||||
return {
|
||||
source: 'env',
|
||||
enabled: codeRunsEnabled,
|
||||
clientEnabled: codeRunsEnabled,
|
||||
generalAutodetect: false,
|
||||
pageDataDevAutodetect: false,
|
||||
requireValidation: requireCodeRunValidation,
|
||||
userAllowlist: [...codeRunUserIds],
|
||||
taskTypeAllowlist: [...codeRunTaskTypes],
|
||||
userAllowed:
|
||||
!codeRunUserIds.size || codeRunUserIds.has(String(userId ?? '').trim()),
|
||||
};
|
||||
}
|
||||
|
||||
return async function postAgentRuns(request, response) {
|
||||
try {
|
||||
const sessionId = String(request.body?.session_id ?? '').trim() || null;
|
||||
@@ -87,29 +107,28 @@ export function createPostAgentRunsHandler({
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (toolMode === 'code' && !codeRunsEnabled) {
|
||||
response.status(403).json({ message: '代码任务灰度未开启' });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
toolMode === 'code' &&
|
||||
codeRunUserIds.size > 0 &&
|
||||
!codeRunUserIds.has(request.currentUser.id)
|
||||
) {
|
||||
response.status(403).json({ message: '当前用户未开启代码任务灰度' });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
toolMode === 'code' &&
|
||||
codeRunTaskTypes.size > 0 &&
|
||||
(!taskType || !codeRunTaskTypes.has(taskType.toLowerCase()))
|
||||
) {
|
||||
response.status(403).json({ message: '当前代码任务类型未开启灰度' });
|
||||
return;
|
||||
}
|
||||
if (toolMode === 'code' && requireCodeRunValidation && !hasExpectedFileValidation(userMessage)) {
|
||||
response.status(400).json({ message: '代码任务必须声明产物校验规则' });
|
||||
return;
|
||||
if (toolMode === 'code') {
|
||||
const codeRunPolicy = await resolveCodeRunPolicy(request.currentUser.id);
|
||||
if (!codeRunPolicy.enabled) {
|
||||
response.status(403).json({ message: '代码任务灰度未开启' });
|
||||
return;
|
||||
}
|
||||
if (!codeRunPolicy.userAllowed) {
|
||||
response.status(403).json({ message: '当前用户未开启代码任务灰度' });
|
||||
return;
|
||||
}
|
||||
const taskTypeAllowlist = codeRunPolicy.taskTypeAllowlist ?? [];
|
||||
if (
|
||||
taskTypeAllowlist.length > 0 &&
|
||||
(!taskType || !taskTypeAllowlist.map((item) => String(item).toLowerCase()).includes(taskType.toLowerCase()))
|
||||
) {
|
||||
response.status(403).json({ message: '当前代码任务类型未开启灰度' });
|
||||
return;
|
||||
}
|
||||
if (codeRunPolicy.requireValidation && !hasExpectedFileValidation(userMessage)) {
|
||||
response.status(400).json({ message: '代码任务必须声明产物校验规则' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (sessionId) {
|
||||
const owns = await sessionStore.validateOwnership(request.currentUser.id, sessionId);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
buildWebNewsSkillPrompt,
|
||||
extractSelectedChatSkillName,
|
||||
hasExplicitChatSkillPrompt,
|
||||
isPageDataDevIntent,
|
||||
isPageDataIntent,
|
||||
isPageGenerationIntent,
|
||||
isProductCampaignIntent,
|
||||
@@ -930,6 +931,15 @@ export function classifyWithRules({
|
||||
reason,
|
||||
}, { source: 'rule' }), decisionContext);
|
||||
}
|
||||
if (includeIntentPatterns && normalized && isPageDataDevIntent(normalized)) {
|
||||
return finalizeRouterClassification(normalizeClassification({
|
||||
route: CHAT_INTENT_ROUTE.AGENT,
|
||||
confidence: 0.94,
|
||||
reason: 'Page Data 开发修复(修 bug / 测试回归)',
|
||||
agent_brief:
|
||||
'这是 Page Data 开发修复任务:优先根据 verify/报错信息修 public HTML 与 policy,不要重建问卷;建表/bind 缺失时说明需 Goose 或 repair 脚本。',
|
||||
}, { source: 'rule' }), decisionContext);
|
||||
}
|
||||
if (
|
||||
includeIntentPatterns &&
|
||||
normalized &&
|
||||
|
||||
@@ -1335,3 +1335,14 @@ test('createChatIntentRouter uses agent fallback for unmatched general questions
|
||||
assert.equal(result.source, 'fallback');
|
||||
assert.equal(llmCalls.length, 0);
|
||||
});
|
||||
|
||||
test('classifyWithRules routes page data dev repair before new page-data collect', () => {
|
||||
const result = classifyWithRules({
|
||||
text: '修复问卷 insert 403,columns_not_allowed',
|
||||
sessionId: null,
|
||||
sessionMessageCount: 0,
|
||||
});
|
||||
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
|
||||
assert.match(result.reason, /Page Data 开发修复/);
|
||||
assert.equal(result.suggested_skill, undefined);
|
||||
});
|
||||
|
||||
@@ -71,9 +71,27 @@ function isInteractivePageDataIntent(text) {
|
||||
return INTERACTIVE_PAGE_DATA_SURFACE_PATTERN.test(text) || featureCount >= 2;
|
||||
}
|
||||
|
||||
const PAGE_DATA_DEV_INTENT_PATTERNS = [
|
||||
/columns_not_allowed/i,
|
||||
/\bpage[\s-]?data[\s-]?dev\b/i,
|
||||
/(?:insert|提交|写入).{0,16}(?:失败|403|报错|error)/iu,
|
||||
/(?:修复|修|排查|debug|fix).{0,24}(?:问卷|page[\s-]?data|数据提交|插入|admin|后台|policy|绑定|bind)/iu,
|
||||
/(?:问卷|page[\s-]?data|数据页|表单页).{0,24}(?:bug|坏了|不通|失败|有问题)/iu,
|
||||
/(?:测试|verify).{0,16}(?:没过|失败|不通过)/iu,
|
||||
/(?:repair|修复).{0,16}(?:bind|绑定|policy|策略)/iu,
|
||||
];
|
||||
|
||||
/** Page Data 开发修复(Aider 兜底):修 bug / 测回归,不是新建问卷。 */
|
||||
export function isPageDataDevIntent(text) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
if (!normalized) return false;
|
||||
return PAGE_DATA_DEV_INTENT_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
export function isPageDataIntent(text) {
|
||||
const normalized = String(text ?? '').trim();
|
||||
if (!normalized) return false;
|
||||
if (isPageDataDevIntent(normalized)) return false;
|
||||
return PAGE_DATA_INTENT_PATTERNS.some((pattern) => pattern.test(normalized))
|
||||
|| isInteractivePageDataIntent(normalized);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
CHAT_SKILL_DEFINITIONS,
|
||||
filterChatSkills,
|
||||
isExcelAnalysisIntent,
|
||||
isPageDataDevIntent,
|
||||
isPageDataIntent,
|
||||
isPageGenerationIntent,
|
||||
} from './chat-skills.mjs';
|
||||
@@ -168,3 +169,11 @@ test('buildAutoChatSkillPrefix enables publish for implicit page requests', () =
|
||||
assert.match(prefix, /static-page-publish/);
|
||||
assert.match(prefix, /禁止询问是否还要发布/);
|
||||
});
|
||||
|
||||
test('isPageDataDevIntent matches repair phrases but not new survey creation', () => {
|
||||
assert.equal(isPageDataDevIntent('修复问卷 insert 403 columns_not_allowed'), true);
|
||||
assert.equal(isPageDataDevIntent('page-data-dev 修 policy 白名单'), true);
|
||||
assert.equal(isPageDataDevIntent('帮我做一个报名问卷'), false);
|
||||
assert.equal(isPageDataIntent('帮我做一个报名问卷'), true);
|
||||
assert.equal(isPageDataIntent('修复问卷 insert 403 columns_not_allowed'), false);
|
||||
});
|
||||
|
||||
@@ -37,6 +37,47 @@ runtime-slo-report.ok=true
|
||||
runtime-slo-report.failures=[]
|
||||
```
|
||||
|
||||
## Admin DB Policy (Phase 1.5)
|
||||
|
||||
Code-run **user/taskType/client toggles** can be managed in memindadm instead of rebuilding H5 for every change.
|
||||
|
||||
| Layer | Controls |
|
||||
|-------|----------|
|
||||
| **memindadm** (`h5_agent_code_run_config`) | `codeRun.enabled`, `clientEnabled`, allowlists, `pageDataDev.autodetect`, `generalAutodetect`, `requireValidation` |
|
||||
| **Portal env** | `MEMIND_TOOL_GATEWAY_ENABLED`, worker queue topology, emergency override via `MEMIND_CODE_RUN_POLICY_SOURCE=env` |
|
||||
| **H5 runtime** | `/auth/status.agentCodeRun` → client autodetect without `VITE_*` rebuild |
|
||||
|
||||
Admin API (memind_adm `:8082`):
|
||||
|
||||
```text
|
||||
GET/PATCH /admin-api/agent-code-run/config
|
||||
GET /admin-api/agent-code-run/runtime
|
||||
```
|
||||
|
||||
Ops UI: super-admin console → **Code Run** (`/ops/admin/agent-code-run`).
|
||||
|
||||
One-time env → DB migration (dev/staging):
|
||||
|
||||
```bash
|
||||
node scripts/migrate-agent-code-run-config-from-env.mjs --dry-run
|
||||
node scripts/migrate-agent-code-run-config-from-env.mjs --updated-by <admin-user-uuid>
|
||||
```
|
||||
|
||||
Verify effective policy after save:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:8081/auth/status -H "Cookie: h5_user=<token>" | jq '.agentCodeRun'
|
||||
curl -fsS http://127.0.0.1:8081/api/runtime/status | jq '.toolRuntime.codeRunPolicy'
|
||||
```
|
||||
|
||||
Page Data dev loop (local only):
|
||||
|
||||
```bash
|
||||
MEMIND_TOOL_GATEWAY_ENABLED=1 npm run dev:page-data-aider-loop -- --dry-run
|
||||
```
|
||||
|
||||
See also: [help-code01.md](./help-code01.md), [help-code02.md](./help-code02.md).
|
||||
|
||||
## One-user Canary Profile
|
||||
|
||||
Use only for a controlled john-user canary.
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
# Help Code01:Aider 补充 Page Data 简单系统开发方案
|
||||
|
||||
> 文档版本:2026-07-23
|
||||
> 状态:方案设计(未实施)
|
||||
> 目标:用 **Aider** 在 Goose 主路径之外,补充 Page Data 简单系统在**开发/调试**阶段的修 bug 与测试回归;**不替代 Goose**,不替代 `page-data-collect` skill。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 现状
|
||||
|
||||
Memind 的 Page Data 主路径由 **Goose + `page-data-collect` skill + sandbox-fs MCP** 完成:
|
||||
|
||||
```text
|
||||
load_skill(page-data-collect)
|
||||
→ private_data_execute(建表)
|
||||
→ private_data_register_dataset(注册 dataset)
|
||||
→ write_file(survey.html + admin.html)
|
||||
→ private_data_bind_workspace_page(发布 + 策略)
|
||||
```
|
||||
|
||||
**Aider / OpenHands** 当前仅在 `toolMode='code'` 的显式代码任务中作为外部执行器,**不参与** Page Data 创建与日常 H5 聊天。
|
||||
|
||||
### 1.2 痛点
|
||||
|
||||
简单 Page Data 系统(问卷、台账、小后台)在 Goose 搭完骨架后,开发阶段常见:
|
||||
|
||||
- HTML 字段名与 dataset 列不一致 → insert 403
|
||||
- `page-data-client.js` 用法错误
|
||||
- policy 白名单未更新(bind 失败或漏列)
|
||||
- admin 页读不到数据
|
||||
- 改完不确定是否回归通过
|
||||
|
||||
重新走聊天让 Goose 修,成本高、周期长。
|
||||
|
||||
### 1.3 目标
|
||||
|
||||
引入 **Aider 作为 L2 开发兜底**:
|
||||
|
||||
| 阶段 | 执行者 |
|
||||
|------|--------|
|
||||
| **创建** | Goose + `page-data-collect`(不变) |
|
||||
| **测试** | 现有 verify / scenario 脚本(不变) |
|
||||
| **修 bug** | Aider(新增,仅 dev/staging) |
|
||||
| **改表 / 重新 bind** | Goose 或运维脚本(Aider 不主责) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计原则
|
||||
|
||||
1. **不替代 Goose**:Page Data 首次建表、注册、bind 仍走 MCP 主路径。
|
||||
2. **测试驱动**:Aider 修复必须以 verify 脚本失败输出为依据,禁止无验收的盲改。
|
||||
3. **cwd 隔离**:用户 workspace 与 Memind 平台源码分开,不可混跑。
|
||||
4. **dev 优先**:生产环境默认不自动触发 Aider Page Data 修复;需白名单与 code run 灰度。
|
||||
5. **简单系统用 Aider**:复杂多文件改造才考虑 OpenHands,本方案默认 `executor=aider`。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三层架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[Goose page-data-collect] --> B[产物: HTML + PG表 + policy]
|
||||
B --> C[npm verify / 场景脚本]
|
||||
C -->|失败| D[Aider code run]
|
||||
D --> E[改 workspace 内文件]
|
||||
E --> C
|
||||
C -->|通过| F[交付]
|
||||
```
|
||||
|
||||
### 3.1 L0 — 创建(Goose,不变)
|
||||
|
||||
- Skill:`skills/page-data-collect/SKILL.md`
|
||||
- MCP:`mindspace-sandbox-mcp.mjs`(`private_data_*` + `write_file` / `edit_file`)
|
||||
- 守卫:`mindspace-page-data-finish-guard.mjs`、交付 sync
|
||||
|
||||
### 3.2 L1 — 测试(平台脚本,不变)
|
||||
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `npm run verify:page-data` | Page Data 平台单测与集成测 |
|
||||
| `npm run verify:children-hobby-diet-survey` | 问卷链路产物验证(~5s) |
|
||||
| `npm run test:scenario:john4-diet` | 全链路 E2E(含 Agent 聊天,~3–10min) |
|
||||
| `npm run verify:page-data-delivery` | 绑定/delivery dry-run |
|
||||
| `npm run repair:page-data-bindings` | 修复 workspace bind |
|
||||
|
||||
参考:`.claude/skills/memind-page-data-survey-verify/SKILL.md`
|
||||
|
||||
### 3.3 L2 — 修 bug(Aider,本方案新增)
|
||||
|
||||
- 入口:`toolMode='code'`,`executor='aider'`,建议 `taskType='page_data_dev'`
|
||||
- cwd:用户 workspace(`resolveWorkingDir(userId)`)或 Memind 仓库(平台开发)
|
||||
- 输入:verify 失败输出 + 目标文件路径 + Page Data 约束摘要
|
||||
- 输出:修改后的 HTML / policy JSON;可选 receipt
|
||||
- 验收:重跑 L1 verify 直至通过
|
||||
|
||||
---
|
||||
|
||||
## 4. Aider 能力边界
|
||||
|
||||
### 4.1 ✅ 适合修复
|
||||
|
||||
| 问题类型 | 可改路径 |
|
||||
|----------|----------|
|
||||
| HTML 字段与 dataset 列不一致 | `public/*-survey.html`、`public/*-admin.html` |
|
||||
| `page-data-client.js` API 误用 | 同上 |
|
||||
| policy insert/read 白名单错误 | `.mindspace/page-data-policies/{pageId}.json` |
|
||||
| CSP / 脚本路径 / 同源约束 | `public/assets/`、`public/*.html` |
|
||||
| 提交后 UI 无反馈 | HTML 内 JS |
|
||||
|
||||
### 4.2 ⚠️ 需配合脚本或 Goose
|
||||
|
||||
| 问题 | 推荐做法 |
|
||||
|------|----------|
|
||||
| PG 表缺列 | Goose 重新 `private_data_execute`,或运维 SQL |
|
||||
| dataset 未注册 | `scripts/ensure-page-data-datasets.mjs` |
|
||||
| bind 丢失 | `npm run repair:page-data-bindings` |
|
||||
| 策略索引不同步 | `page-data-policy-index` 相关 repair |
|
||||
|
||||
### 4.3 ❌ 不交给 Aider
|
||||
|
||||
| 问题 | 原因 |
|
||||
|------|------|
|
||||
| 首次建表 + register + bind | 无 `private_data_*` MCP |
|
||||
| 替代 Goose 做页面生成 | 缺 skill、finish sync、交付守卫 |
|
||||
| 生产环境无人值守自动改码 | 风险高,需严格灰度 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 两种开发场景
|
||||
|
||||
### 5.1 场景 A:用户 workspace 里的 Page Data 小系统
|
||||
|
||||
**典型产物:**
|
||||
|
||||
```text
|
||||
MindSpace/{userId}/
|
||||
public/my-survey.html
|
||||
public/my-admin.html
|
||||
.mindspace/page-data-policies/{pageId}.json
|
||||
```
|
||||
|
||||
**Aider 配置:**
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| cwd | `MindSpace/{userId}/` |
|
||||
| executor | `aider` |
|
||||
| taskType | `page_data_dev`(建议新增)或 `page_edit_code_task` |
|
||||
| validation | `expectedFiles`: 目标 `public/*.html` |
|
||||
|
||||
**测试裁判:** 问卷专用 verify 或自定义 `scripts/verify-*.mjs`
|
||||
|
||||
### 5.2 场景 B:Memind 平台 Page Data 模块开发
|
||||
|
||||
**典型修改:**
|
||||
|
||||
- `page-data-service.mjs`、`page-data-public-service.mjs`
|
||||
- `mindspace-page-data-finish-guard.mjs`
|
||||
- `public/assets/page-data-client.js`
|
||||
|
||||
**Aider 配置:**
|
||||
|
||||
| 项 | 值 |
|
||||
|----|-----|
|
||||
| cwd | `/Users/john/Project/Memind`(或 CI checkout 路径) |
|
||||
| executor | `aider` |
|
||||
| taskType | `h5_chat_code_task` 或专用 `page_data_platform_dev` |
|
||||
| validation | receipt + 单测通过 |
|
||||
|
||||
**测试裁判:** `npm run verify:page-data`
|
||||
|
||||
> **禁止**在同一次 Aider run 中混用场景 A 与 B 的 cwd。
|
||||
|
||||
---
|
||||
|
||||
## 6. 开发闭环操作手册
|
||||
|
||||
### 6.1 前置:开启 code run
|
||||
|
||||
> **Phase 1(当前):** env + VITE,适合本机 dev。
|
||||
> **Phase 1.5(目标):** memindadm 配置 + `/auth/status` 下发,见 [help-code02.md](./help-code02.md)。
|
||||
|
||||
```bash
|
||||
# .env 示例(开发机,Phase 1)
|
||||
MEMIND_AGENT_CODE_RUNS_ENABLED=1
|
||||
MEMIND_TOOL_GATEWAY_ENABLED=1
|
||||
MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR=aider
|
||||
|
||||
VITE_AGENT_CODE_RUNS_ENABLED=1
|
||||
VITE_AGENT_PAGE_DATA_DEV_AUTODETECT=1
|
||||
VITE_AGENT_CODE_RUNS_USER_IDS=<dev-user-uuid> # 或留空表示全用户(仅 dev)
|
||||
# 可选:MEMIND_AGENT_CODE_RUN_TASK_TYPES=page_data_dev,h5_chat_code_task,...
|
||||
```
|
||||
|
||||
还需:
|
||||
|
||||
- DB `h5_capability_grants`:目标用户 `aider=true`
|
||||
- `h5_llm_executor_bindings`:aider 有 enabled 的 provider/model
|
||||
- Portal 或 external worker 之一启用 Tool Gateway(本地 dev 可 Portal 直开)
|
||||
|
||||
### 6.2 标准循环
|
||||
|
||||
```bash
|
||||
# 1. 确认 Portal
|
||||
curl -sf http://127.0.0.1:8081/auth/status
|
||||
|
||||
# 2. Goose 已建好 Page Data 系统(聊天或 scenario)
|
||||
|
||||
# 3. 跑验证
|
||||
npm run verify:children-hobby-diet-survey
|
||||
# 或
|
||||
npm run verify:page-data
|
||||
|
||||
# 4. 若失败 → 触发 Aider(H5 / agent-run / 独立脚本)
|
||||
# instruction 必须包含完整 verify 失败输出
|
||||
|
||||
# 5. Aider 改完 → 回到步骤 3,直至全绿
|
||||
```
|
||||
|
||||
### 6.3 H5 触发话术示例
|
||||
|
||||
- 「问卷 submit 403,列 q4_diet_meals 不允许,请根据 verify 结果修 HTML 和 policy」
|
||||
- 「Page Data admin 页读不到数据,测试脚本报 policy read 未开启」
|
||||
- 「修 page-data 简单系统的 bug,不要改表,只改 public HTML」
|
||||
|
||||
### 6.4 Aider instruction 模板
|
||||
|
||||
```text
|
||||
[Page Data 开发修复任务]
|
||||
|
||||
工作目录: MindSpace/{userId}/
|
||||
taskType: page_data_dev
|
||||
|
||||
验证失败输出(必须原样附上):
|
||||
<paste verify script stderr/stdout>
|
||||
|
||||
目标文件(仅可改这些):
|
||||
- public/{survey}.html
|
||||
- public/{admin}.html
|
||||
- .mindspace/page-data-policies/{pageId}.json
|
||||
|
||||
约束:
|
||||
- 必须使用 /assets/page-data-client.js 公开 API
|
||||
- 禁止 localStorage / sessionStorage / IndexedDB
|
||||
- 禁止自建 Express 或独立后端
|
||||
- 列名必须与 dataset register 的 insert/read 白名单一致
|
||||
- 不要修改 Memind 平台源码
|
||||
|
||||
验收:
|
||||
- 修改后应能通过: npm run verify:children-hobby-diet-survey
|
||||
- 或: node scripts/verify-<your-scenario>.mjs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 与现有 Memind 架构的集成点
|
||||
|
||||
### 7.1 现有组件(复用,不重写)
|
||||
|
||||
| 组件 | 路径 | 角色 |
|
||||
|------|------|------|
|
||||
| Agent Run 队列 | `agent-run-gateway.mjs` | code run 调度 |
|
||||
| Tool Gateway | `tool-gateway.mjs` | spawn Aider |
|
||||
| Launch Plan | `llm-providers.mjs` | `aider --message ... --yes-always` |
|
||||
| 前端 code 检测 | `src/utils/agentRunMode.ts` | `toolMode:'code'` |
|
||||
| 能力门禁 | `capabilities.mjs` | code mode 才注入 aider |
|
||||
| Page Data 路由 | `page-data-routes.mjs` | 运行时 API(Aider 不直接调,HTML 调) |
|
||||
|
||||
### 7.2 建议新增(实施阶段)
|
||||
|
||||
| 项 | 说明 | 优先级 |
|
||||
|----|------|--------|
|
||||
| `taskType: page_data_dev` | router / autodetect 识别「修 page data bug」 | P1 |
|
||||
| chat-intent-router 规则 | 「修问卷/测试没过/page data bug」→ code run | P1 |
|
||||
| `buildAgentRunTaskValidation` 扩展 | page_data_dev 的 expectedFiles + receipt | P2 |
|
||||
| 独立 dev 脚本 | 仓库外或 `scripts/page-data-aider-dev-loop.mjs`:verify → aider → verify | P2 |
|
||||
| 文档索引 | 在 `docs/regression-guards/` 或 AGENTS.md 加链接 | P3 |
|
||||
|
||||
### 7.3 不建议的做法
|
||||
|
||||
- 用 Aider/OpenHands **替代** Goose 做 Page Data 创建
|
||||
- 用 `page-data-collect` skill 充当通用 code executor
|
||||
- 生产默认全开 `MEMIND_AGENT_CODE_RUNS_ENABLED` 给所有用户
|
||||
- 无 verify 输出让 Aider 盲改 workspace
|
||||
|
||||
---
|
||||
|
||||
## 8. 与 OpenHands / Cursor 的关系
|
||||
|
||||
| 引擎 | 本方案中的角色 |
|
||||
|------|----------------|
|
||||
| **Goose** | L0 创建(主路径,不变) |
|
||||
| **Aider** | L2 简单 Page Data dev 修 bug(**本方案核心**) |
|
||||
| **OpenHands** | 可选 L3:Aider 失败且涉及多文件/复杂 repo 时再 escalation |
|
||||
| **Cursor SDK** | 非必需;仅 Memind 平台源码开发或 IDE 级工具时可选用 |
|
||||
|
||||
OpenHands 任务类型保持现有 `repo_refactor,multi_file,complex_repo`;**不必**为 Page Data 简单 HTML bug 默认走 OpenHands。
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与运维
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| Aider 误改非目标文件 | validation `expectedFiles` + cwd 沙箱 + prompt 白名单 |
|
||||
| 表结构问题被误当 HTML bug | verify 区分 `columns_not_allowed` vs `table_not_found`;后者回 Goose |
|
||||
| code run 孤儿化导致 H5 loading | verify 脚本检查 orphan running;`--no-runs` 可选 |
|
||||
| 生产误触发 | 生产 `MEMIND_AGENT_CODE_RUNS_ENABLED=0` 或严格 user allowlist |
|
||||
| PG 连接失败 | fail closed(skill 已有);Aider 不得「先写 HTML 等 PG 恢复」 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 实施路线图
|
||||
|
||||
### Phase 0 — 文档与手动流程(当前)
|
||||
|
||||
- [x] 本方案文档 `docs/help-code01.md`
|
||||
- [ ] 开发者在本地手动:verify 失败 → 复制输出 → H5/code run 触发 Aider → 再 verify
|
||||
|
||||
### Phase 1 — 最小 Memind 改动
|
||||
|
||||
- [x] 新增 `page_data_dev` taskType
|
||||
- [x] `chat-intent-router` 增加 Page Data 修 bug 意图
|
||||
- [x] `agentRunMode.ts` 可选 autodetect 模式(dev only,`VITE_AGENT_PAGE_DATA_DEV_AUTODETECT`)
|
||||
|
||||
### Phase 1.5 — memindadm 运行时策略(推荐下一步)
|
||||
|
||||
> 详细设计见 **[help-code02.md](./help-code02.md)**。
|
||||
|
||||
- [x] `h5_agent_code_run_config` 表 + `agent-code-run-admin-config.mjs`
|
||||
- [x] memindadm API:`/admin-api/agent-code-run/config`、`/runtime`(UI 表单待做)
|
||||
- [x] `/auth/status` 下发 `agentCodeRun`,H5 不再依赖 `VITE_*` rebuild
|
||||
- [x] `agent-run-routes.mjs` 从 DB 读策略(env 仅紧急 override)
|
||||
- [x] Worker 拓扑(`MEMIND_TOOL_GATEWAY_ENABLED`)继续留 env
|
||||
- [x] `scripts/migrate-agent-code-run-config-from-env.mjs`
|
||||
|
||||
**为何需要 Phase 1.5:** Phase 1 的 env/VITE 适合 dev;要解决「用户经常失败」的运营灰度,必须在 memindadm 按用户/任务类型动态开关,且 H5 需运行时生效。
|
||||
|
||||
### Phase 2 — 自动化 dev loop
|
||||
|
||||
- [x] `scripts/page-data-aider-dev-loop.mjs`:读 verify 输出 → 调 Tool Gateway/Aider → 重跑 verify
|
||||
- [x] `npm run dev:page-data-aider-loop` 快捷入口
|
||||
- [ ] 接入 CI optional job(仅 staging)
|
||||
- [x] `npm run ci:page-data-dev-loop-smoke`(dry-run 烟测,可挂 CI optional job)
|
||||
|
||||
### Phase 3 — 可选 escalation
|
||||
|
||||
- [x] Aider 连续 N 次 verify 失败 → 升级 OpenHands(`taskType=page_data_dev_complex`,`--escalate-after`)
|
||||
- [x] 运维面板展示 page_data_dev run 历史(`/admin-api/agent-code-run/runs` + Ops UI)
|
||||
|
||||
---
|
||||
|
||||
## 11. 相关文档与命令速查
|
||||
|
||||
| 资源 | 路径 / 命令 |
|
||||
|------|-------------|
|
||||
| **adm 运行时策略(Phase 1.5)** | `docs/help-code02.md` |
|
||||
| Page Data API 用法 | `docs/page-data-api-usage.md` |
|
||||
| page-data-collect skill | `skills/page-data-collect/SKILL.md` |
|
||||
| 问卷 verify skill | `.claude/skills/memind-page-data-survey-verify/SKILL.md` |
|
||||
| Tool Gateway 架构 | `docs/architecture/memind-2-streaming-agent-runtime-plan.md` |
|
||||
| Agent run worker | `docs/agent-run-worker-rollout-runbook.md` |
|
||||
| `npm run verify:page-data` | Page Data 平台测试 |
|
||||
| `npm run verify:children-hobby-diet-survey` | 问卷产物验证 |
|
||||
| `npm run repair:page-data-bindings` | 修复 bind |
|
||||
|
||||
---
|
||||
|
||||
## 12. 一句话总结
|
||||
|
||||
**Goose 负责把 Page Data 简单系统「建起来」;现有 verify 脚本负责「验对不对」;Aider 负责开发阶段「根据测试结果改 HTML/policy 修 bug」——三者串联,不替代 Goose 主路径。**
|
||||
@@ -0,0 +1,382 @@
|
||||
# Help Code02:Code Run / Page Data Dev 的 memindadm 运行时策略
|
||||
|
||||
> 文档版本:2026-07-23
|
||||
> 状态:方案设计(未实施)
|
||||
> 前置:[help-code01.md](./help-code01.md)(Aider 补充 Page Data 开发兜底)
|
||||
> 关联:[memindadm-goose-gateway-design.md](./memindadm-goose-gateway-design.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么要做 Phase 1.5
|
||||
|
||||
Phase 1 用 **env + VITE_ 构建期变量** 控制 code run 与 `page_data_dev` autodetect。这在 dev/staging 可用,但对「用户经常页面失败、插入失败」的运营场景不够:
|
||||
|
||||
| 问题 | env/VITE 的局限 |
|
||||
|------|----------------|
|
||||
| 改开关要 rebuild H5 | `VITE_*` 打进 bundle,memindadm 点了不生效 |
|
||||
| 改开关要 SSH 改 `.env` | 103 Portal / worker 两套 env,易不一致 |
|
||||
| 无法按用户即时灰度 | 白名单改 env 无审计、无 UI |
|
||||
| 与现有 adm 能力分裂 | aider 能力已在 `h5_capability_grants`,策略却在 env |
|
||||
|
||||
**目标:** 把 **「谁、哪种 task、是否 page_data_dev autodetect」** 迁入 memindadm;**部署拓扑**(worker 是否 spawn Aider)继续留 env。
|
||||
|
||||
---
|
||||
|
||||
## 2. 配置分层(硬边界)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph adm ["memindadm 运行时策略(DB)"]
|
||||
P1[codeRun.enabled]
|
||||
P2[userAllowlist / roleAllowlist]
|
||||
P3[taskTypeAllowlist 含 page_data_dev]
|
||||
P4[pageDataDev.autodetect]
|
||||
P5[requireValidation]
|
||||
P6[generalCodeAutodetect]
|
||||
end
|
||||
|
||||
subgraph env ["部署 env(不改或只读 override)"]
|
||||
E1[MEMIND_TOOL_GATEWAY_ENABLED]
|
||||
E2[AIDER_BIN / OPENHANDS_BIN]
|
||||
E3[worker 并发 / 超时 / guard]
|
||||
E4[MEMIND_AGENT_RUN_* 紧急 override]
|
||||
end
|
||||
|
||||
subgraph cap ["已有用户能力(DB)"]
|
||||
C1[h5_capability_grants: aider/openhands]
|
||||
C2[h5_llm_executor_bindings]
|
||||
end
|
||||
|
||||
adm --> Portal[agent-run-routes 校验]
|
||||
adm --> Auth["/auth/status → H5"]
|
||||
cap --> Portal
|
||||
env --> Worker[agent-run-worker]
|
||||
Portal --> Worker
|
||||
```
|
||||
|
||||
### 2.1 进 memindadm 的项
|
||||
|
||||
| 原 env / VITE | adm 字段 | 说明 |
|
||||
|---------------|----------|------|
|
||||
| `MEMIND_AGENT_CODE_RUNS_ENABLED` | `codeRun.enabled` | 后端是否接受 `tool_mode=code` |
|
||||
| `MEMIND_AGENT_CODE_RUNS_USER_IDS` | `codeRun.userAllowlist` | 空 = 不限制(enabled 时) |
|
||||
| `MEMIND_AGENT_CODE_RUN_TASK_TYPES` | `codeRun.taskTypeAllowlist` | 含 `page_data_dev`、`h5_chat_code_task` |
|
||||
| `MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION` | `codeRun.requireValidation` | 是否强制 receipt |
|
||||
| `VITE_AGENT_CODE_RUNS_ENABLED` | `codeRun.clientEnabled` | H5 是否展示/走 code 路径 |
|
||||
| `VITE_AGENT_CODE_RUNS_AUTODETECT` | `codeRun.generalAutodetect` | 通用代码语义 autodetect |
|
||||
| `VITE_AGENT_PAGE_DATA_DEV_AUTODETECT` | `pageDataDev.autodetect` | Page Data 修 bug 专用 autodetect |
|
||||
|
||||
### 2.2 继续留 env 的项
|
||||
|
||||
| env | 原因 |
|
||||
|-----|------|
|
||||
| `MEMIND_TOOL_GATEWAY_ENABLED` | worker 进程级;Portal 与 worker 故意不同值 |
|
||||
| `MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR` | 机器上装的是 aider 还是 openhands |
|
||||
| `MEMIND_AGENT_RUN_QUEUE_CONCURRENCY` 等 | SLO / guard / LaunchAgent |
|
||||
| `MEMIND_AGENT_CODE_RUNS_*`(可选) | **紧急 override**:`MEMIND_CODE_RUN_POLICY_SOURCE=env` 时强制 env 优先 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据模型
|
||||
|
||||
### 3.1 表:`h5_agent_code_run_config`
|
||||
|
||||
与 `h5_skill_runtime_config`、`h5_image_make_admin_config` 同模式:`config_scope='global'` 单行 JSON。
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS h5_agent_code_run_config (
|
||||
config_scope VARCHAR(32) PRIMARY KEY,
|
||||
config_json JSON NOT NULL,
|
||||
updated_by CHAR(36) NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### 3.2 默认 JSON Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"codeRun": {
|
||||
"enabled": false,
|
||||
"clientEnabled": false,
|
||||
"generalAutodetect": false,
|
||||
"requireValidation": true,
|
||||
"userAllowlist": [],
|
||||
"taskTypeAllowlist": [
|
||||
"page_data_dev",
|
||||
"h5_chat_code_task",
|
||||
"page_edit_code_task"
|
||||
]
|
||||
},
|
||||
"pageDataDev": {
|
||||
"autodetect": false
|
||||
},
|
||||
"meta": {
|
||||
"notes": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 生效优先级(与 Memory V2 adm 一致)
|
||||
|
||||
参考 `memory-v2-admin-config.mjs` 的 `FIELD_SPECS + env override` 模式:
|
||||
|
||||
```text
|
||||
1. 若 MEMIND_CODE_RUN_POLICY_SOURCE=env → 仅读 env(紧急回滚)
|
||||
2. 否则读 h5_agent_code_run_config(admin-db)
|
||||
3. 若表为空且 MEMIND_AGENT_CODE_RUNS_ENABLED=1 → source=env-migration(兼容旧部署)
|
||||
4. 否则 default(全 false,fail closed)
|
||||
```
|
||||
|
||||
### 3.4 用户级能力(不重复造表)
|
||||
|
||||
以下 **仍用现有表**,adm「Code Run 策略」页只读展示 + 链到用户能力编辑:
|
||||
|
||||
- `h5_capability_grants`:`aider` / `openhands`
|
||||
- `h5_llm_executor_bindings`:Aider 模型绑定
|
||||
- `h5_user_policies`:`code_delegate_executor`、`code_task_routing`
|
||||
|
||||
**完整放行条件(与 today 相同,只是策略来源改为 DB):**
|
||||
|
||||
```text
|
||||
codeRun.enabled (adm)
|
||||
AND user ∈ allowlist(若配置)
|
||||
AND taskType ∈ taskTypeAllowlist
|
||||
AND capabilities.aider(用户 grant)
|
||||
AND executor binding 可用
|
||||
AND(若 requireValidation)message 含 validation metadata
|
||||
AND toolGateway.enabled(env,worker 侧)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 服务模块:`agent-code-run-admin-config.mjs`
|
||||
|
||||
建议新建,API 对齐 `skill-runtime-admin-config.mjs`:
|
||||
|
||||
| 方法 | 用途 |
|
||||
|------|------|
|
||||
| `getAdminConfig()` | memindadm 编辑页 |
|
||||
| `updateAdminConfig(patch, { updatedBy })` | 保存 + 审计 |
|
||||
| `getRuntimeState()` | adm 运行时预览 |
|
||||
| `getEffectivePolicy({ userId })` | Portal 校验用 |
|
||||
| `getPublicClientPolicy({ userId })` | `/auth/status` 下发 H5 |
|
||||
|
||||
### 4.1 `getEffectivePolicy` 返回示例
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "admin-db",
|
||||
"updatedAt": 1753276800000,
|
||||
"enabled": true,
|
||||
"userAllowed": true,
|
||||
"taskTypes": ["page_data_dev", "h5_chat_code_task", "page_edit_code_task"],
|
||||
"requireValidation": true,
|
||||
"pageDataDevAutodetect": true,
|
||||
"generalAutodetect": false
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 `getPublicClientPolicy` 返回示例(按用户过滤后)
|
||||
|
||||
```json
|
||||
{
|
||||
"codeRun": {
|
||||
"enabled": true,
|
||||
"pageDataDevAutodetect": true,
|
||||
"generalAutodetect": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
未登录或用户不在 allowlist 时:`codeRun.enabled=false`(或不返回该块)。
|
||||
|
||||
---
|
||||
|
||||
## 5. HTTP API
|
||||
|
||||
### 5.1 memindadm(`admin-routes.mjs`)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/admin/agent-code-run/config` | 读配置 + source + updatedBy |
|
||||
| PUT/PATCH | `/api/admin/agent-code-run/config` | 更新 |
|
||||
| GET | `/api/admin/agent-code-run/runtime` | 有效策略 + env 覆盖状态 + worker 只读摘要 |
|
||||
|
||||
Worker 摘要可代理现有 `/api/runtime/status` 的 `toolRuntime.codeRunPolicy` 与 `queue`(**只读**,不在 adm 改 worker env)。
|
||||
|
||||
### 5.2 Portal 用户面
|
||||
|
||||
**扩展 `GET /auth/status`**(已登录用户):
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"user": { "...": "..." },
|
||||
"capabilities": { "aider": true, "...": "..." },
|
||||
"skillRuntime": { "...": "..." },
|
||||
"agentCodeRun": {
|
||||
"enabled": true,
|
||||
"pageDataDevAutodetect": true,
|
||||
"generalAutodetect": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**扩展 `POST /api/agent/runs` 校验:** 从 `getEffectivePolicy(userId)` 读,不再只读 `process.env.MEMIND_AGENT_CODE_RUNS_*`。
|
||||
|
||||
### 5.3 只读运维
|
||||
|
||||
`GET /api/runtime/status` 增加:
|
||||
|
||||
```json
|
||||
{
|
||||
"toolRuntime": {
|
||||
"codeRunPolicy": {
|
||||
"source": "admin-db",
|
||||
"enabled": true,
|
||||
"userAllowlist": [],
|
||||
"taskTypeAllowlist": ["page_data_dev", "..."],
|
||||
"requireValidation": true,
|
||||
"pageDataDevAutodetect": true,
|
||||
"envOverrideActive": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端改造(去掉 VITE_ 硬依赖)
|
||||
|
||||
### 6.1 `src/utils/agentRunMode.ts`
|
||||
|
||||
```typescript
|
||||
// 优先级:runtime policy(/auth/status)> VITE_(dev fallback)
|
||||
|
||||
let runtimePolicy: AgentCodeRunClientPolicy | null = null;
|
||||
|
||||
export function applyAgentCodeRunClientPolicy(policy: AgentCodeRunClientPolicy | null) {
|
||||
runtimePolicy = policy;
|
||||
}
|
||||
|
||||
function clientCodeRunsEnabled(): boolean {
|
||||
if (runtimePolicy?.codeRun?.enabled != null) return runtimePolicy.codeRun.enabled;
|
||||
return agentCodeRunsEnabled; // VITE fallback
|
||||
}
|
||||
|
||||
function clientPageDataDevAutodetect(): boolean {
|
||||
if (runtimePolicy?.codeRun?.pageDataDevAutodetect != null) {
|
||||
return runtimePolicy.codeRun.pageDataDevAutodetect;
|
||||
}
|
||||
return agentPageDataDevAutodetectEnabled;
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 注入时机
|
||||
|
||||
在现有 `/auth/status` 加载处(如 `src/api/client.ts` 或 auth hook):
|
||||
|
||||
```typescript
|
||||
const status = await fetchAuthStatus();
|
||||
applyAgentCodeRunClientPolicy(status.agentCodeRun ?? null);
|
||||
```
|
||||
|
||||
**效果:** memindadm 打开 `pageDataDev.autodetect` 后,用户刷新页面即生效,**无需 rebuild H5**。
|
||||
|
||||
---
|
||||
|
||||
## 7. memindadm UI(Ops 后台)
|
||||
|
||||
建议菜单位置:**系统 / Agent 运行时 → Code Run & Page Data Dev**
|
||||
|
||||
| 控件 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| Code Run 总开关 | toggle | `codeRun.enabled` |
|
||||
| H5 客户端启用 | toggle | `codeRun.clientEnabled` |
|
||||
| Page Data Dev Autodetect | toggle | `pageDataDev.autodetect` |
|
||||
| 通用 Code Autodetect | toggle | `codeRun.generalAutodetect`(默认关) |
|
||||
| 强制 Validation | toggle | `codeRun.requireValidation` |
|
||||
| 用户白名单 | multi-select UUID | 空 = 全部(enabled 时) |
|
||||
| Task Types | checkbox list | 至少含 `page_data_dev` |
|
||||
| 当前 Worker 状态 | read-only | 来自 `/runtime/status` |
|
||||
| Env Override 警告 | banner | `MEMIND_CODE_RUN_POLICY_SOURCE=env` 时显示 |
|
||||
|
||||
**保存时:** 写 `updated_by` + `updated_at`;可选写 admin audit log(与 image-make 一致)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 迁移与兼容
|
||||
|
||||
### 8.1 首次上线
|
||||
|
||||
1. 建表 `h5_agent_code_run_config`,默认全 `false`
|
||||
2. 部署 `agent-code-run-admin-config.mjs` + admin API
|
||||
3. **迁移脚本**(可选):若 env 已开启,import 到 DB 并提示改 `POLICY_SOURCE=admin`
|
||||
|
||||
```bash
|
||||
node scripts/migrate-agent-code-run-config-from-env.mjs --dry-run
|
||||
node scripts/migrate-agent-code-run-config-from-env.mjs --apply
|
||||
```
|
||||
|
||||
### 8.2 回滚
|
||||
|
||||
| 场景 | 操作 |
|
||||
|------|------|
|
||||
| adm 配错了 | memindadm 关 `codeRun.enabled` |
|
||||
| 紧急全站关闭 | `MEMIND_CODE_RUN_POLICY_SOURCE=env` + unset `MEMIND_AGENT_CODE_RUNS_ENABLED` |
|
||||
| worker 异常 | LaunchAgent 停 worker(现有 runbook),与 adm 无关 |
|
||||
|
||||
### 8.3 与 help-code01 Phase 的关系
|
||||
|
||||
| Phase | 内容 | 配置来源 |
|
||||
|-------|------|----------|
|
||||
| Phase 1 ✅ | `page_data_dev` 意图 + autodetect 逻辑 | env/VITE |
|
||||
| **Phase 1.5** | adm 运行时策略 + `/auth/status` | **DB + adm UI** |
|
||||
| Phase 2 | verify → Aider dev loop 脚本 | 脚本 + adm 开关 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 实施清单(Phase 1.5)
|
||||
|
||||
- [x] `agent-code-run-admin-config.mjs` + 单测
|
||||
- [x] `admin-routes.mjs`:`/agent-code-run/config`、`/runtime`
|
||||
- [x] `server.mjs`:`/auth/status` 增加 `agentCodeRun`
|
||||
- [x] `agent-run-routes.mjs`:改用 `getEffectivePolicy(userId)`
|
||||
- [x] `agentRunMode.ts`:runtime policy 优先于 VITE_
|
||||
- [x] Ops UI 页面(表单 + JSON 高级编辑:`/ops/admin/agent-code-run`)
|
||||
- [x] `migrate-agent-code-run-config-from-env.mjs`
|
||||
- [x] 更新 `docs/agent-run-worker-rollout-runbook.md`
|
||||
- [x] `.env.example` 增加 `MEMIND_CODE_RUN_POLICY_SOURCE`
|
||||
- [x] verify:`agent-code-run-admin-config.test.mjs` + 扩展 `chat-agent-run-gate.test.mjs`
|
||||
|
||||
---
|
||||
|
||||
## 10. 安全与审计
|
||||
|
||||
1. **仅 admin 可写** — 复用 `requireAdmin`
|
||||
2. **默认 fail closed** — 新环境 adm 配置为空 = 全关
|
||||
3. **双闸门保留** — adm 开 + 用户 `aider` grant + worker gateway env
|
||||
4. **审计字段** — `updated_by`、`updated_at`;重要变更写 admin audit
|
||||
5. **生产建议** — 先 `userAllowlist` 小范围开 `page_data_dev`,再扩 `generalAutodetect`
|
||||
|
||||
---
|
||||
|
||||
## 11. 相关文件索引
|
||||
|
||||
| 类型 | 路径 |
|
||||
|------|------|
|
||||
| 方案总览 | `docs/help-code01.md` |
|
||||
| Goose 网关规划 | `docs/memindadm-goose-gateway-design.md` |
|
||||
| 可复用 adm 模式 | `skill-runtime-admin-config.mjs` |
|
||||
| env↔adm 映射先例 | `memory-v2-admin-config.mjs` |
|
||||
| 后端 code run 门禁 | `agent-run-routes.mjs` |
|
||||
| 前端 autodetect | `src/utils/agentRunMode.ts` |
|
||||
| 运行时只读 | `server.mjs` → `runtimeCodeRunPolicyStatus()` |
|
||||
| Worker runbook | `docs/agent-run-worker-rollout-runbook.md` |
|
||||
|
||||
---
|
||||
|
||||
## 12. 一句话总结
|
||||
|
||||
**memindadm 管「策略」(谁、哪种 task、是否 page_data_dev);env 管「部署」(worker 是否 spawn Aider);H5 从 `/auth/status` 读运行时策略,不再依赖 rebuild VITE_。**
|
||||
@@ -117,6 +117,24 @@ test('buildChatSavePreviewUrls use local api routes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('chat page previews keep generated scripts sandboxed and proxy analytics through Portal', async () => {
|
||||
const [pageSavePreview, chatSharePreview, viteConfig] = await Promise.all([
|
||||
fs.readFile(new URL('./src/components/PageSavePreviewPanel.tsx', import.meta.url), 'utf8'),
|
||||
fs.readFile(new URL('./src/components/ChatSharePreviewModal.tsx', import.meta.url), 'utf8'),
|
||||
fs.readFile(new URL('./vite.config.ts', import.meta.url), 'utf8'),
|
||||
]);
|
||||
|
||||
assert.match(pageSavePreview, /sandbox="allow-scripts allow-downloads"/);
|
||||
assert.match(chatSharePreview, /sandbox="allow-scripts allow-popups allow-downloads"/);
|
||||
for (const source of [pageSavePreview, chatSharePreview]) {
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/sandbox="[^"]*(?:allow-scripts[^"]*allow-same-origin|allow-same-origin[^"]*allow-scripts)[^"]*"/,
|
||||
);
|
||||
}
|
||||
assert.match(viteConfig, /['"]\/analytics['"]\s*:\s*portalTarget/);
|
||||
});
|
||||
|
||||
test('findPublishHtml resolves nested public html by basename', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-'));
|
||||
const publishDir = path.join(root, 'MindSpace', USER_ID, 'public');
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ReviewPage } from './pages/ReviewPage';
|
||||
import { SummaryPage } from './pages/admin/SummaryPage';
|
||||
import { UsersPage } from './pages/admin/UsersPage';
|
||||
import { BillingPage } from './pages/admin/BillingPage';
|
||||
import { AgentCodeRunPage } from './pages/admin/AgentCodeRunPage';
|
||||
import { WechatPage } from './pages/admin/WechatPage';
|
||||
import { SystemPolicyPage } from './pages/admin/SystemPolicyPage';
|
||||
|
||||
@@ -44,6 +45,7 @@ export function App() {
|
||||
<Route index element={<SummaryPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="billing" element={<BillingPage />} />
|
||||
<Route path="agent-code-run" element={<AgentCodeRunPage />} />
|
||||
<Route path="wechat" element={<WechatPage />} />
|
||||
<Route path="policy" element={<SystemPolicyPage />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
|
||||
@@ -358,3 +358,81 @@ export async function resumeWechatDigest(id: string) {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Agent Code Run ───────────────────────────────────────────────────────────
|
||||
|
||||
export type AgentCodeRunConfigShape = {
|
||||
codeRun: {
|
||||
enabled: boolean;
|
||||
clientEnabled: boolean;
|
||||
generalAutodetect: boolean;
|
||||
requireValidation: boolean;
|
||||
userAllowlist: string[];
|
||||
taskTypeAllowlist: string[];
|
||||
};
|
||||
pageDataDev: {
|
||||
autodetect: boolean;
|
||||
};
|
||||
meta: {
|
||||
notes: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentCodeRunAdminConfig = {
|
||||
config: AgentCodeRunConfigShape;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
source: string;
|
||||
envOverrideActive: boolean;
|
||||
};
|
||||
|
||||
export type AgentCodeRunRuntimeState = {
|
||||
source: string;
|
||||
updatedAt: number | null;
|
||||
updatedBy: string | null;
|
||||
config: AgentCodeRunConfigShape;
|
||||
policy: {
|
||||
source: string;
|
||||
enabled: boolean;
|
||||
clientEnabled: boolean;
|
||||
generalAutodetect: boolean;
|
||||
requireValidation: boolean;
|
||||
pageDataDevAutodetect: boolean;
|
||||
userAllowlist: string[];
|
||||
taskTypeAllowlist: string[];
|
||||
};
|
||||
envOverrideActive: boolean;
|
||||
};
|
||||
|
||||
export async function fetchAgentCodeRunConfig() {
|
||||
return adminFetch<AgentCodeRunAdminConfig>('/admin-api/agent-code-run/config');
|
||||
}
|
||||
|
||||
export async function fetchAgentCodeRunRuntime() {
|
||||
return adminFetch<AgentCodeRunRuntimeState>('/admin-api/agent-code-run/runtime');
|
||||
}
|
||||
|
||||
export async function patchAgentCodeRunConfig(body: { config: AgentCodeRunConfigShape }) {
|
||||
return adminFetch<AgentCodeRunAdminConfig>('/admin-api/agent-code-run/config', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export type AgentCodeRunHistoryRow = {
|
||||
id: string;
|
||||
userId: string;
|
||||
requestId: string;
|
||||
status: string;
|
||||
taskType: string;
|
||||
executor: string | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: number | null;
|
||||
updatedAt: number | null;
|
||||
};
|
||||
|
||||
export async function fetchAgentCodeRunHistory(limit = 50) {
|
||||
return adminFetch<{ runs: AgentCodeRunHistoryRow[]; limit: number }>(
|
||||
`/admin-api/agent-code-run/runs?limit=${limit}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const links = [
|
||||
{ to: '/admin/users', label: '用户管理' },
|
||||
{ to: '/admin/billing', label: '账单记录' },
|
||||
{ to: '/admin/wechat', label: '服务号管理' },
|
||||
{ to: '/admin/agent-code-run', label: 'Code Run' },
|
||||
{ to: '/admin/policy', label: '策略中心' },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
fetchAgentCodeRunConfig,
|
||||
fetchAgentCodeRunHistory,
|
||||
fetchAgentCodeRunRuntime,
|
||||
patchAgentCodeRunConfig,
|
||||
type AgentCodeRunAdminConfig,
|
||||
type AgentCodeRunHistoryRow,
|
||||
type AgentCodeRunRuntimeState,
|
||||
} from '../../api/admin';
|
||||
|
||||
type FormState = {
|
||||
enabled: boolean;
|
||||
clientEnabled: boolean;
|
||||
generalAutodetect: boolean;
|
||||
requireValidation: boolean;
|
||||
pageDataDevAutodetect: boolean;
|
||||
userAllowlist: string;
|
||||
taskTypeAllowlist: string;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
function listToText(values: string[] | undefined) {
|
||||
return (values ?? []).join(', ');
|
||||
}
|
||||
|
||||
function textToList(value: string) {
|
||||
return value
|
||||
.split(/[,\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function configToForm(config: AgentCodeRunAdminConfig['config']): FormState {
|
||||
return {
|
||||
enabled: Boolean(config.codeRun.enabled),
|
||||
clientEnabled: Boolean(config.codeRun.clientEnabled),
|
||||
generalAutodetect: Boolean(config.codeRun.generalAutodetect),
|
||||
requireValidation: Boolean(config.codeRun.requireValidation),
|
||||
pageDataDevAutodetect: Boolean(config.pageDataDev.autodetect),
|
||||
userAllowlist: listToText(config.codeRun.userAllowlist),
|
||||
taskTypeAllowlist: listToText(config.codeRun.taskTypeAllowlist),
|
||||
notes: config.meta?.notes ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function formToPatch(form: FormState) {
|
||||
return {
|
||||
config: {
|
||||
codeRun: {
|
||||
enabled: form.enabled,
|
||||
clientEnabled: form.clientEnabled,
|
||||
generalAutodetect: form.generalAutodetect,
|
||||
requireValidation: form.requireValidation,
|
||||
userAllowlist: textToList(form.userAllowlist),
|
||||
taskTypeAllowlist: textToList(form.taskTypeAllowlist),
|
||||
},
|
||||
pageDataDev: {
|
||||
autodetect: form.pageDataDevAutodetect,
|
||||
},
|
||||
meta: {
|
||||
notes: form.notes,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(value: number | null | undefined) {
|
||||
if (!value) return '—';
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 12, alignItems: 'start' }}>
|
||||
<span>
|
||||
<strong>{label}</strong>
|
||||
{hint ? <p style={{ margin: '4px 0 0', color: '#68716c', fontSize: 13 }}>{hint}</p> : null}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
style={{ width: 'auto', marginTop: 4 }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentCodeRunPage() {
|
||||
const [configState, setConfigState] = useState<AgentCodeRunAdminConfig | null>(null);
|
||||
const [runtimeState, setRuntimeState] = useState<AgentCodeRunRuntimeState | null>(null);
|
||||
const [runHistory, setRunHistory] = useState<AgentCodeRunHistoryRow[]>([]);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [jsonDraft, setJsonDraft] = useState('');
|
||||
const [useJson, setUseJson] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const locked = Boolean(configState?.envOverrideActive);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [config, runtime, history] = await Promise.all([
|
||||
fetchAgentCodeRunConfig(),
|
||||
fetchAgentCodeRunRuntime(),
|
||||
fetchAgentCodeRunHistory(30),
|
||||
]);
|
||||
setConfigState(config);
|
||||
setRuntimeState(runtime);
|
||||
setRunHistory(history.runs ?? []);
|
||||
setForm(configToForm(config.config));
|
||||
setJsonDraft(JSON.stringify(config.config, null, 2));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const effectivePolicy = runtimeState?.policy;
|
||||
|
||||
const previewJson = useMemo(() => {
|
||||
if (!form) return '';
|
||||
return JSON.stringify(formToPatch(form).config, null, 2);
|
||||
}, [form]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!form) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
let patch: ReturnType<typeof formToPatch>;
|
||||
if (useJson) {
|
||||
const parsed = JSON.parse(jsonDraft) as AgentCodeRunAdminConfig['config'];
|
||||
patch = { config: parsed };
|
||||
} else {
|
||||
patch = formToPatch(form);
|
||||
}
|
||||
const saved = await patchAgentCodeRunConfig(patch);
|
||||
setConfigState(saved);
|
||||
setForm(configToForm(saved.config));
|
||||
setJsonDraft(JSON.stringify(saved.config, null, 2));
|
||||
setMessage('配置已保存');
|
||||
const runtime = await fetchAgentCodeRunRuntime();
|
||||
setRuntimeState(runtime);
|
||||
const history = await fetchAgentCodeRunHistory(30);
|
||||
setRunHistory(history.runs ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <p>加载中…</p>;
|
||||
if (error && !form) return <p className="alert">{error}</p>;
|
||||
if (!form || !configState) return <p className="alert">配置不可用</p>;
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="card">
|
||||
<h2 style={{ marginTop: 0 }}>Agent Code Run & Page Data Dev</h2>
|
||||
<p style={{ color: '#68716c', marginTop: 0 }}>
|
||||
控制 code run 灰度、H5 客户端开关与 Page Data 开发修复 autodetect。Worker 拓扑仍由 env 管理。
|
||||
</p>
|
||||
<div style={{ display: 'grid', gap: 8, fontSize: 13 }}>
|
||||
<p style={{ margin: 0 }}>
|
||||
当前来源:<strong>{configState.source}</strong>
|
||||
{configState.envOverrideActive ? (
|
||||
<span className="warn"> · MEMIND_CODE_RUN_POLICY_SOURCE=env 已锁定后台修改</span>
|
||||
) : null}
|
||||
</p>
|
||||
<p style={{ margin: 0 }}>更新时间:{formatTime(configState.updatedAt)}</p>
|
||||
{effectivePolicy ? (
|
||||
<p style={{ margin: 0 }}>
|
||||
有效策略:server={effectivePolicy.enabled ? '开' : '关'} · client=
|
||||
{effectivePolicy.clientEnabled ? '开' : '关'} · pageDataDev=
|
||||
{effectivePolicy.pageDataDevAutodetect ? '开' : '关'}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{locked ? (
|
||||
<div className="card warn">
|
||||
当前环境启用了 <code>MEMIND_CODE_RUN_POLICY_SOURCE=env</code>,后台无法写入 DB。请改 env 或去掉该变量后再保存。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card" style={{ display: 'grid', gap: 16 }}>
|
||||
<h3 style={{ margin: 0 }}>开关</h3>
|
||||
<ToggleRow
|
||||
label="Code Run 总开关(服务端)"
|
||||
hint="POST /agent/runs 的 tool_mode=code 门禁"
|
||||
checked={form.enabled}
|
||||
disabled={locked}
|
||||
onChange={(enabled) => setForm((current) => (current ? { ...current, enabled } : current))}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="H5 客户端启用"
|
||||
hint="/auth/status.agentCodeRun.codeRun.enabled"
|
||||
checked={form.clientEnabled}
|
||||
disabled={locked}
|
||||
onChange={(clientEnabled) => setForm((current) => (current ? { ...current, clientEnabled } : current))}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Page Data Dev Autodetect"
|
||||
hint="聊天识别「修问卷 / verify 失败」→ page_data_dev"
|
||||
checked={form.pageDataDevAutodetect}
|
||||
disabled={locked}
|
||||
onChange={(pageDataDevAutodetect) =>
|
||||
setForm((current) => (current ? { ...current, pageDataDevAutodetect } : current))
|
||||
}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="通用 Code Autodetect"
|
||||
hint="普通聊天文本识别代码任务(默认建议关闭)"
|
||||
checked={form.generalAutodetect}
|
||||
disabled={locked}
|
||||
onChange={(generalAutodetect) =>
|
||||
setForm((current) => (current ? { ...current, generalAutodetect } : current))
|
||||
}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="强制 Validation"
|
||||
hint="code run 必须带 expectedFiles / receipt"
|
||||
checked={form.requireValidation}
|
||||
disabled={locked}
|
||||
onChange={(requireValidation) =>
|
||||
setForm((current) => (current ? { ...current, requireValidation } : current))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ display: 'grid', gap: 12 }}>
|
||||
<h3 style={{ margin: 0 }}>白名单</h3>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<strong>用户 UUID(逗号或换行分隔,留空=全部)</strong>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.userAllowlist}
|
||||
disabled={locked}
|
||||
onChange={(event) =>
|
||||
setForm((current) => (current ? { ...current, userAllowlist: event.target.value } : current))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<strong>taskType 允许列表</strong>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.taskTypeAllowlist}
|
||||
disabled={locked}
|
||||
onChange={(event) =>
|
||||
setForm((current) => (current ? { ...current, taskTypeAllowlist: event.target.value } : current))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'grid', gap: 6 }}>
|
||||
<strong>备注</strong>
|
||||
<input
|
||||
value={form.notes}
|
||||
disabled={locked}
|
||||
onChange={(event) => setForm((current) => (current ? { ...current, notes: event.target.value } : current))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ display: 'grid', gap: 12 }}>
|
||||
<label style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useJson}
|
||||
disabled={locked}
|
||||
onChange={(event) => setUseJson(event.target.checked)}
|
||||
style={{ width: 'auto' }}
|
||||
/>
|
||||
<strong>高级:直接编辑 JSON config</strong>
|
||||
</label>
|
||||
{useJson ? (
|
||||
<textarea
|
||||
rows={16}
|
||||
value={jsonDraft}
|
||||
disabled={locked}
|
||||
onChange={(event) => setJsonDraft(event.target.value)}
|
||||
style={{ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12 }}
|
||||
/>
|
||||
) : (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: '#f8f4ea',
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{previewJson}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn" type="button" disabled={saving || locked} onClick={() => void handleSave()}>
|
||||
{saving ? '保存中…' : '保存配置'}
|
||||
</button>
|
||||
<button className="btn secondary" type="button" disabled={loading} onClick={() => void load()}>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message ? <p style={{ color: '#2f6f57' }}>{message}</p> : null}
|
||||
{error ? <p className="alert">{error}</p> : null}
|
||||
|
||||
{runtimeState ? (
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>运行时只读</h3>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: '#f8f4ea',
|
||||
overflow: 'auto',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{JSON.stringify(runtimeState, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card">
|
||||
<h3 style={{ marginTop: 0 }}>Page Data Dev Run 历史</h3>
|
||||
{runHistory.length === 0 ? (
|
||||
<p style={{ color: '#68716c', margin: 0 }}>暂无 page_data_dev / page_data_dev_complex 记录</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>时间</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>用户</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>taskType</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>executor</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>状态</th>
|
||||
<th style={{ textAlign: 'left', padding: '8px 6px', color: '#68716c' }}>requestId</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runHistory.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da' }}>{formatTime(row.updatedAt)}</td>
|
||||
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da', fontFamily: 'monospace', fontSize: 11 }}>
|
||||
{row.userId.slice(0, 8)}…
|
||||
</td>
|
||||
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da' }}>{row.taskType}</td>
|
||||
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da' }}>{row.executor ?? '—'}</td>
|
||||
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da' }}>{row.status}</td>
|
||||
<td style={{ padding: '8px 6px', borderTop: '1px solid #eee7da', fontFamily: 'monospace', fontSize: 11 }}>
|
||||
{row.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,9 @@ export function SummaryPage() {
|
||||
<Link className="btn" to="/admin/wechat" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
进入通知推送
|
||||
</Link>
|
||||
<Link className="btn secondary" to="/admin/agent-code-run" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
Code Run 配置
|
||||
</Link>
|
||||
<Link
|
||||
className="btn secondary"
|
||||
to="/admin/users"
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"pretest": "node --test episodic-memory.test.mjs",
|
||||
"test:scenario": "node scripts/run-scenario-test.mjs",
|
||||
"verify:children-hobby-diet-survey": "node scripts/verify-children-hobby-diet-survey.mjs",
|
||||
"dev:page-data-aider-loop": "node scripts/page-data-aider-dev-loop.mjs",
|
||||
"ci:page-data-dev-loop-smoke": "node scripts/ci-page-data-dev-loop-smoke.mjs",
|
||||
"migrate:agent-code-run-config": "node scripts/migrate-agent-code-run-config-from-env.mjs",
|
||||
"test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update",
|
||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
|
||||
"test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "customer-order-system",
|
||||
"name": "客户下单系统(Page Data 前后台)",
|
||||
"description": "用户话术 → Goose page-data-collect 生成下单页 + 管理后台 → 验证 Page Data 交付",
|
||||
"account": {
|
||||
"username": "john4",
|
||||
"password": "888888"
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"action": "login",
|
||||
"label": "登录 john 账户"
|
||||
},
|
||||
{
|
||||
"action": "chat",
|
||||
"label": "创建客户下单系统(前端下单 + 后台管理)",
|
||||
"message": "帮我做一个客户下单系统,分前端和后端两个页面:\n\n【前端下单页】客户填写:客户姓名、联系电话、商品名称、购买数量、收货地址、备注(选填),提交后保存到数据库。\n\n【管理后台页】密码 88888888,能查看所有订单列表,显示下单时间和状态,能按商品名称做简单统计,支持导出 CSV。\n\n要求:使用 Page Data 公开 API(page-data-client.js),不要自建后端;页面简洁可用;做完把下单页链接和后台链接发给我。",
|
||||
"selectedChatSkill": "page-data-collect",
|
||||
"expect": {
|
||||
"assistantMinChars": 80,
|
||||
"timeoutMs": 600000,
|
||||
"replyKeywords": ["下单", "后台"],
|
||||
"forbidReplyPatterns": ["8899", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "survey-api"],
|
||||
"survey": {
|
||||
"requirePolicy": true,
|
||||
"requireDataset": true,
|
||||
"forbidHtmlPatterns": ["localStorage", "sessionStorage", "fetch('/api/"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CI/staging smoke for Page Data dev loop (dry-run only, no Aider spawn).
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const script = path.join(repoRoot, 'scripts/page-data-aider-dev-loop.mjs');
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[script, '--dry-run', '--skip-initial-verify', '--max-attempts', '1', '--escalate-after', '1'],
|
||||
{ cwd: repoRoot, encoding: 'utf8' },
|
||||
);
|
||||
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
process.exit(result.status ?? 1);
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 将当前 env 中的 code-run 灰度配置写入 h5_agent_code_run_config(一次性迁移)。
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/migrate-agent-code-run-config-from-env.mjs
|
||||
* node scripts/migrate-agent-code-run-config-from-env.mjs --dry-run
|
||||
* node scripts/migrate-agent-code-run-config-from-env.mjs --updated-by <admin-user-id>
|
||||
*/
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import {
|
||||
agentCodeRunAdminConfigInternals,
|
||||
createAgentCodeRunAdminConfigService,
|
||||
} from '../agent-code-run-admin-config.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { dryRun: false, updatedBy: null, force: false };
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--dry-run') options.dryRun = true;
|
||||
else if (arg === '--force') options.force = true;
|
||||
else if (arg === '--updated-by' && argv[i + 1]) options.updatedBy = argv[++i];
|
||||
else if (arg === '-h' || arg === '--help') {
|
||||
console.log('Usage: node scripts/migrate-agent-code-run-config-from-env.mjs [--dry-run] [--force] [--updated-by <uuid>]');
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadH5Environment(import.meta.dirname);
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (String(process.env.MEMIND_CODE_RUN_POLICY_SOURCE ?? '').trim().toLowerCase() === 'env') {
|
||||
throw new Error('MEMIND_CODE_RUN_POLICY_SOURCE=env 时不应写入 admin-db');
|
||||
}
|
||||
|
||||
const pool = createDbPool();
|
||||
const service = createAgentCodeRunAdminConfigService(pool, { env: process.env });
|
||||
const current = await service.getAdminConfig();
|
||||
const envConfig = agentCodeRunAdminConfigInternals.buildConfigFromEnv(process.env);
|
||||
|
||||
console.log('==> migrate agent code run config from env');
|
||||
console.log(` current source: ${current.source}`);
|
||||
console.log(` env enabled: ${envConfig.codeRun.enabled}`);
|
||||
console.log(` env clientEnabled: ${envConfig.codeRun.clientEnabled}`);
|
||||
console.log(` env pageDataDev.autodetect: ${envConfig.pageDataDev.autodetect}`);
|
||||
|
||||
if (current.source === 'admin-db' && !options.force) {
|
||||
console.log('\nadmin-db 已有配置;加 --force 才会覆盖');
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('\n(dry-run) 将写入:');
|
||||
console.log(JSON.stringify(envConfig, null, 2));
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const saved = await service.updateAdminConfig(
|
||||
{ config: envConfig, meta: { notes: 'migrated from env via migrate-agent-code-run-config-from-env.mjs' } },
|
||||
{ updatedBy: options.updatedBy },
|
||||
);
|
||||
console.log('\n✔ 已写入 admin-db');
|
||||
console.log(JSON.stringify(saved, null, 2));
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Page Data 开发闭环:verify 失败 → Aider 修 workspace → 重跑 verify(Phase 2)。
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/page-data-aider-dev-loop.mjs
|
||||
* node scripts/page-data-aider-dev-loop.mjs --preset children-hobby-diet-survey
|
||||
* node scripts/page-data-aider-dev-loop.mjs --preset page-data-platform --max-attempts 2
|
||||
* node scripts/page-data-aider-dev-loop.mjs --dry-run
|
||||
* node scripts/page-data-aider-dev-loop.mjs --verify-cmd "node scripts/verify-children-hobby-diet-survey.mjs --no-insert"
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { createLlmProviderService } from '../llm-providers.mjs';
|
||||
import { createToolGateway } from '../tool-gateway.mjs';
|
||||
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
|
||||
import { CHILDREN_HOBBY_DIET_SURVEY } from './scenario-test-lib.mjs';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PAGE_DATA_DEV_TASK_TYPE = 'page_data_dev';
|
||||
export const PAGE_DATA_DEV_COMPLEX_TASK_TYPE = 'page_data_dev_complex';
|
||||
|
||||
export const VERIFY_PRESETS = Object.freeze({
|
||||
'children-hobby-diet-survey': {
|
||||
label: 'john4 儿童爱好问卷饮食偏好',
|
||||
scenario: 'workspace',
|
||||
defaultUserId: CHILDREN_HOBBY_DIET_SURVEY.userId,
|
||||
buildVerifyArgs: (options) => [
|
||||
'scripts/verify-children-hobby-diet-survey.mjs',
|
||||
'--user-id',
|
||||
options.userId,
|
||||
...(options.testInsert === false ? ['--no-insert'] : []),
|
||||
...(options.checkRuns === false ? ['--no-runs'] : []),
|
||||
...(options.port ? ['--port', String(options.port)] : []),
|
||||
],
|
||||
defaultTargetFiles: () => [
|
||||
`public/${CHILDREN_HOBBY_DIET_SURVEY.surveyHtml}`,
|
||||
`public/${CHILDREN_HOBBY_DIET_SURVEY.adminHtml}`,
|
||||
],
|
||||
verifyCommandLabel: 'npm run verify:children-hobby-diet-survey',
|
||||
},
|
||||
'page-data-platform': {
|
||||
label: 'Memind Page Data 平台单测',
|
||||
scenario: 'platform',
|
||||
defaultUserId: null,
|
||||
buildVerifyArgs: () => ['--test', 'page-data-public-service.test.mjs', 'page-data-integration.test.mjs'],
|
||||
defaultTargetFiles: () => [
|
||||
'page-data-public-service.mjs',
|
||||
'page-data-service.mjs',
|
||||
'public/assets/page-data-client.js',
|
||||
],
|
||||
verifyCommandLabel: 'npm run verify:page-data',
|
||||
},
|
||||
});
|
||||
|
||||
function printHelp() {
|
||||
console.log([
|
||||
'Usage:',
|
||||
' node scripts/page-data-aider-dev-loop.mjs [options]',
|
||||
'',
|
||||
'Options:',
|
||||
' --preset <name> children-hobby-diet-survey (default) | page-data-platform',
|
||||
' --verify-cmd <shell> 自定义 verify 命令(覆盖 preset)',
|
||||
' --user-id <uuid> workspace 用户 ID(workspace 场景)',
|
||||
' --cwd <dir> 覆盖 Aider 工作目录',
|
||||
' --target-file <path> 可多次指定;未指定时用 preset 默认',
|
||||
' --max-attempts <n> 最大修复轮次(默认 3)',
|
||||
' --escalate-after <n> Aider 连续失败 N 轮后升级 OpenHands(默认 2,0=禁用)',
|
||||
' --executor <aider|openhands> 强制全程使用指定执行器',
|
||||
' --no-insert 传给 preset verify',
|
||||
' --no-runs 传给 preset verify(跳过 orphan run 检查)',
|
||||
' --port <n> Portal 端口(默认 H5_PORT 或 8081)',
|
||||
' --dry-run 只打印 instruction,不执行 Aider',
|
||||
' --skip-initial-verify 跳过首轮 verify,直接跑 Aider(调试用)',
|
||||
' -h, --help',
|
||||
'',
|
||||
'Requires: DATABASE_URL(Aider launch plan)、MEMIND_TOOL_GATEWAY_ENABLED=1(或 --dry-run)',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
export function parseDevLoopArgs(argv, env = process.env) {
|
||||
const options = {
|
||||
preset: 'children-hobby-diet-survey',
|
||||
verifyCmd: null,
|
||||
userId: null,
|
||||
cwd: null,
|
||||
targetFiles: [],
|
||||
maxAttempts: 3,
|
||||
escalateAfter: 2,
|
||||
executor: 'aider',
|
||||
testInsert: true,
|
||||
checkRuns: true,
|
||||
port: Number(env.H5_PORT ?? 8081),
|
||||
dryRun: false,
|
||||
skipInitialVerify: false,
|
||||
help: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === '-h' || arg === '--help') options.help = true;
|
||||
else if (arg === '--preset' && argv[i + 1]) options.preset = argv[++i];
|
||||
else if (arg === '--verify-cmd' && argv[i + 1]) options.verifyCmd = argv[++i];
|
||||
else if (arg === '--user-id' && argv[i + 1]) options.userId = argv[++i];
|
||||
else if (arg === '--cwd' && argv[i + 1]) options.cwd = path.resolve(argv[++i]);
|
||||
else if (arg === '--target-file' && argv[i + 1]) options.targetFiles.push(argv[++i]);
|
||||
else if (arg === '--max-attempts' && argv[i + 1]) options.maxAttempts = Math.max(1, Number(argv[++i]) || 3);
|
||||
else if (arg === '--escalate-after' && argv[i + 1]) {
|
||||
options.escalateAfter = Math.max(0, Number(argv[++i]) || 0);
|
||||
}
|
||||
else if (arg === '--executor' && argv[i + 1]) options.executor = String(argv[++i]).trim().toLowerCase();
|
||||
else if (arg === '--port' && argv[i + 1]) options.port = Number(argv[++i]);
|
||||
else if (arg === '--no-insert') options.testInsert = false;
|
||||
else if (arg === '--no-runs') options.checkRuns = false;
|
||||
else if (arg === '--dry-run') options.dryRun = true;
|
||||
else if (arg === '--skip-initial-verify') options.skipInitialVerify = true;
|
||||
else throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function resolveVerifyPreset(presetName) {
|
||||
const preset = VERIFY_PRESETS[presetName];
|
||||
if (!preset) {
|
||||
throw new Error(`未知 preset: ${presetName}(可选: ${Object.keys(VERIFY_PRESETS).join(', ')})`);
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceCwd({ repoRoot: root, userId, cwdOverride, scenario }) {
|
||||
if (cwdOverride) return cwdOverride;
|
||||
if (scenario === 'platform') return root;
|
||||
if (!userId) {
|
||||
throw new Error('workspace 场景需要 --user-id 或 preset 默认 userId');
|
||||
}
|
||||
return path.join(root, PUBLISH_ROOT_DIR, userId);
|
||||
}
|
||||
|
||||
export function resolveDevLoopExecutor(
|
||||
attempt,
|
||||
{ executor = 'aider', escalateAfter = 2 } = {},
|
||||
) {
|
||||
if (executor !== 'aider') {
|
||||
return {
|
||||
executor,
|
||||
taskType: executor === 'openhands' ? PAGE_DATA_DEV_COMPLEX_TASK_TYPE : PAGE_DATA_DEV_TASK_TYPE,
|
||||
escalated: false,
|
||||
};
|
||||
}
|
||||
if (escalateAfter > 0 && attempt > escalateAfter) {
|
||||
return {
|
||||
executor: 'openhands',
|
||||
taskType: PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
|
||||
escalated: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
executor: 'aider',
|
||||
taskType: PAGE_DATA_DEV_TASK_TYPE,
|
||||
escalated: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPageDataDevInstruction({
|
||||
verifyOutput,
|
||||
cwd,
|
||||
taskType = PAGE_DATA_DEV_TASK_TYPE,
|
||||
targetFiles = [],
|
||||
verifyCommandLabel = 'verify script',
|
||||
escalated = false,
|
||||
}) {
|
||||
const targets = targetFiles.length
|
||||
? targetFiles.map((item) => `- ${item}`).join('\n')
|
||||
: '- public/*.html\n- .mindspace/page-data-policies/*.json';
|
||||
|
||||
return [
|
||||
'[Page Data 开发修复任务]',
|
||||
escalated ? '[Escalation] 前序 Aider 修复未通过 verify,现由 OpenHands 接手。' : '',
|
||||
'',
|
||||
`工作目录: ${cwd}`,
|
||||
`taskType: ${taskType}`,
|
||||
'',
|
||||
'验证失败输出(必须原样附上):',
|
||||
String(verifyOutput ?? '').trim() || '(verify 未返回输出)',
|
||||
'',
|
||||
'目标文件(仅可改这些):',
|
||||
targets,
|
||||
'',
|
||||
'约束:',
|
||||
'- 必须使用 /assets/page-data-client.js 公开 API',
|
||||
'- 禁止 localStorage / sessionStorage / IndexedDB',
|
||||
'- 禁止自建 Express 或独立后端',
|
||||
'- 列名必须与 dataset register 的 insert/read 白名单一致',
|
||||
'- 不要修改 Memind 平台源码(workspace 场景)',
|
||||
'- 不要调用 private_data_execute 或重建 dataset(除非 verify 明确报 table_not_found)',
|
||||
'',
|
||||
'验收:',
|
||||
`- 修改后应能通过: ${verifyCommandLabel}`,
|
||||
'- 完成后简要说明改了哪些文件、为何能修复 verify 失败项',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function runVerifyCommand({ command, args, cwd, env }) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
||||
return {
|
||||
ok: result.status === 0,
|
||||
status: result.status,
|
||||
output: output || `(exit ${result.status ?? 'unknown'}, no output)`,
|
||||
commandLine: `${command} ${args.join(' ')}`.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveVerifyRun(options, preset) {
|
||||
if (options.verifyCmd) {
|
||||
return {
|
||||
label: options.verifyCmd,
|
||||
...runVerifyCommand({
|
||||
command: '/bin/sh',
|
||||
args: ['-lc', options.verifyCmd],
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const userId = options.userId ?? preset.defaultUserId;
|
||||
if (preset.scenario === 'workspace') {
|
||||
options.userId = userId;
|
||||
}
|
||||
|
||||
const verifyArgs = preset.buildVerifyArgs({
|
||||
userId,
|
||||
testInsert: options.testInsert,
|
||||
checkRuns: options.checkRuns,
|
||||
port: options.port,
|
||||
});
|
||||
|
||||
const useNpmForPlatform = preset.scenario === 'platform'
|
||||
&& verifyArgs[0] === '--test';
|
||||
|
||||
if (useNpmForPlatform) {
|
||||
return {
|
||||
label: preset.verifyCommandLabel,
|
||||
...runVerifyCommand({
|
||||
command: 'npm',
|
||||
args: ['run', 'verify:page-data'],
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: preset.verifyCommandLabel,
|
||||
...runVerifyCommand({
|
||||
command: process.execPath,
|
||||
args: verifyArgs,
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function runCodeExecutorFix({
|
||||
instruction,
|
||||
cwd,
|
||||
executor,
|
||||
taskType,
|
||||
dryRun,
|
||||
llmProviderService,
|
||||
}) {
|
||||
const previousGatewayFlag = process.env.MEMIND_TOOL_GATEWAY_ENABLED;
|
||||
if (!dryRun) {
|
||||
process.env.MEMIND_TOOL_GATEWAY_ENABLED = '1';
|
||||
}
|
||||
const gateway = createToolGateway({ llmProviderService, env: process.env });
|
||||
const status = gateway.getStatus();
|
||||
if (!status.enabled && !dryRun) {
|
||||
throw new Error('MEMIND_TOOL_GATEWAY_ENABLED 未开启;请设 1 或使用 --dry-run');
|
||||
}
|
||||
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: instruction }],
|
||||
metadata: {
|
||||
memindRun: {
|
||||
executor,
|
||||
taskType,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
return await gateway.executeJob({
|
||||
runId: `dev-loop-${Date.now()}`,
|
||||
requestId: `dev-loop-${Date.now()}`,
|
||||
userId: 'dev-loop',
|
||||
userMessage,
|
||||
taskType,
|
||||
cwd,
|
||||
timeoutMs: Number(process.env.MEMIND_PAGE_DATA_DEV_LOOP_TIMEOUT_MS ?? 20 * 60 * 1000),
|
||||
});
|
||||
} finally {
|
||||
if (previousGatewayFlag == null) {
|
||||
delete process.env.MEMIND_TOOL_GATEWAY_ENABLED;
|
||||
} else {
|
||||
process.env.MEMIND_TOOL_GATEWAY_ENABLED = previousGatewayFlag;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
loadH5Environment(import.meta.dirname);
|
||||
const options = parseDevLoopArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const preset = resolveVerifyPreset(options.preset);
|
||||
const userId = options.userId ?? preset.defaultUserId;
|
||||
const workspaceCwd = resolveWorkspaceCwd({
|
||||
repoRoot,
|
||||
userId,
|
||||
cwdOverride: options.cwd,
|
||||
scenario: preset.scenario,
|
||||
});
|
||||
|
||||
if (preset.scenario === 'workspace' && !fs.existsSync(workspaceCwd)) {
|
||||
throw new Error(`workspace 不存在: ${workspaceCwd}`);
|
||||
}
|
||||
|
||||
const targetFiles = options.targetFiles.length
|
||||
? options.targetFiles
|
||||
: preset.defaultTargetFiles();
|
||||
|
||||
console.log('==> Page Data Aider dev loop');
|
||||
console.log(` preset: ${options.preset} (${preset.label})`);
|
||||
console.log(` cwd: ${workspaceCwd}`);
|
||||
console.log(` max attempts: ${options.maxAttempts}`);
|
||||
console.log(` escalate after: ${options.escalateAfter} aider round(s)`);
|
||||
console.log(` dry-run: ${options.dryRun ? 'yes' : 'no'}\n`);
|
||||
|
||||
let pool = null;
|
||||
let llmProviderService = null;
|
||||
if (!options.dryRun) {
|
||||
pool = createDbPool();
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
const shutdown = async () => {
|
||||
if (pool) await pool.end().catch(() => {});
|
||||
};
|
||||
|
||||
try {
|
||||
let verifyResult = options.skipInitialVerify
|
||||
? { ok: false, output: '(跳过首轮 verify)', label: options.verifyCmd ?? preset.verifyCommandLabel }
|
||||
: null;
|
||||
|
||||
if (!options.skipInitialVerify) {
|
||||
console.log('--- 首轮 verify ---');
|
||||
verifyResult = resolveVerifyRun(options, preset);
|
||||
console.log(`$ ${verifyResult.commandLine ?? verifyResult.label}`);
|
||||
if (verifyResult.ok) {
|
||||
console.log('\n✔ verify 已通过,无需 Aider 修复');
|
||||
await shutdown();
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(verifyResult.output.slice(-8000));
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
|
||||
const execution = resolveDevLoopExecutor(attempt, {
|
||||
executor: options.executor,
|
||||
escalateAfter: options.escalateAfter,
|
||||
});
|
||||
const instruction = buildPageDataDevInstruction({
|
||||
verifyOutput: verifyResult.output,
|
||||
cwd: workspaceCwd,
|
||||
targetFiles,
|
||||
verifyCommandLabel: verifyResult.label,
|
||||
taskType: execution.taskType,
|
||||
escalated: execution.escalated,
|
||||
});
|
||||
|
||||
console.log(`\n--- 修复第 ${attempt}/${options.maxAttempts} 轮 (${execution.executor}) ---`);
|
||||
console.log(instruction.slice(0, 2000));
|
||||
if (instruction.length > 2000) {
|
||||
console.log(`... (${instruction.length - 2000} more chars)`);
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log('\n(dry-run: 未执行 Aider)');
|
||||
await shutdown();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\n--- 执行 ${execution.executor} ---`);
|
||||
const result = await runCodeExecutorFix({
|
||||
instruction,
|
||||
cwd: workspaceCwd,
|
||||
executor: execution.executor,
|
||||
taskType: execution.taskType,
|
||||
dryRun: false,
|
||||
llmProviderService,
|
||||
});
|
||||
console.log(`${execution.executor} 完成: exit=${result.exitCode ?? 0} taskType=${execution.taskType}`);
|
||||
if (result.stderr) {
|
||||
console.log(result.stderr.slice(-2000));
|
||||
}
|
||||
|
||||
console.log('\n--- 重跑 verify ---');
|
||||
verifyResult = resolveVerifyRun(options, preset);
|
||||
console.log(`$ ${verifyResult.commandLine ?? verifyResult.label}`);
|
||||
if (verifyResult.ok) {
|
||||
console.log(`\n✔ 第 ${attempt} 轮修复后 verify 通过`);
|
||||
await shutdown();
|
||||
process.exit(0);
|
||||
}
|
||||
console.log(verifyResult.output.slice(-4000));
|
||||
}
|
||||
|
||||
console.error(`\n✘ ${options.maxAttempts} 轮修复后 verify 仍未通过`);
|
||||
await shutdown();
|
||||
process.exit(1);
|
||||
} catch (error) {
|
||||
await shutdown();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMain) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack ?? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export const pageDataAiderDevLoopInternals = {
|
||||
PAGE_DATA_DEV_TASK_TYPE,
|
||||
PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
|
||||
VERIFY_PRESETS,
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
VERIFY_PRESETS,
|
||||
buildPageDataDevInstruction,
|
||||
parseDevLoopArgs,
|
||||
resolveDevLoopExecutor,
|
||||
resolveVerifyPreset,
|
||||
resolveWorkspaceCwd,
|
||||
PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
|
||||
} from './page-data-aider-dev-loop.mjs';
|
||||
|
||||
test('parseDevLoopArgs applies defaults and flags', () => {
|
||||
const options = parseDevLoopArgs([
|
||||
'--preset',
|
||||
'page-data-platform',
|
||||
'--max-attempts',
|
||||
'2',
|
||||
'--dry-run',
|
||||
'--no-insert',
|
||||
]);
|
||||
assert.equal(options.preset, 'page-data-platform');
|
||||
assert.equal(options.maxAttempts, 2);
|
||||
assert.equal(options.dryRun, true);
|
||||
assert.equal(options.testInsert, false);
|
||||
});
|
||||
|
||||
test('resolveVerifyPreset rejects unknown preset', () => {
|
||||
assert.throws(() => resolveVerifyPreset('missing'), /未知 preset/);
|
||||
assert.equal(resolveVerifyPreset('children-hobby-diet-survey').scenario, 'workspace');
|
||||
});
|
||||
|
||||
test('resolveWorkspaceCwd maps workspace and platform scenarios', () => {
|
||||
const root = '/repo';
|
||||
assert.equal(
|
||||
resolveWorkspaceCwd({
|
||||
repoRoot: root,
|
||||
userId: 'user-1',
|
||||
cwdOverride: null,
|
||||
scenario: 'workspace',
|
||||
}),
|
||||
path.join(root, 'MindSpace', 'user-1'),
|
||||
);
|
||||
assert.equal(
|
||||
resolveWorkspaceCwd({
|
||||
repoRoot: root,
|
||||
userId: null,
|
||||
cwdOverride: '/custom',
|
||||
scenario: 'workspace',
|
||||
}),
|
||||
'/custom',
|
||||
);
|
||||
assert.equal(
|
||||
resolveWorkspaceCwd({
|
||||
repoRoot: root,
|
||||
userId: null,
|
||||
cwdOverride: null,
|
||||
scenario: 'platform',
|
||||
}),
|
||||
root,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildPageDataDevInstruction includes verify output and targets', () => {
|
||||
const instruction = buildPageDataDevInstruction({
|
||||
verifyOutput: '✘ 问卷提交字段: insertRow 未包含 q4_diet_meals',
|
||||
cwd: '/repo/MindSpace/user-1',
|
||||
targetFiles: ['public/survey.html', '.mindspace/page-data-policies/page.json'],
|
||||
verifyCommandLabel: 'npm run verify:children-hobby-diet-survey',
|
||||
});
|
||||
assert.match(instruction, /Page Data 开发修复任务/);
|
||||
assert.match(instruction, /q4_diet_meals/);
|
||||
assert.match(instruction, /public\/survey\.html/);
|
||||
assert.match(instruction, /npm run verify:children-hobby-diet-survey/);
|
||||
assert.match(instruction, /page-data-client\.js/);
|
||||
});
|
||||
|
||||
test('resolveDevLoopExecutor escalates to openhands after N aider rounds', () => {
|
||||
assert.deepEqual(resolveDevLoopExecutor(1, { escalateAfter: 2 }), {
|
||||
executor: 'aider',
|
||||
taskType: 'page_data_dev',
|
||||
escalated: false,
|
||||
});
|
||||
assert.deepEqual(resolveDevLoopExecutor(3, { escalateAfter: 2 }), {
|
||||
executor: 'openhands',
|
||||
taskType: PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
|
||||
escalated: true,
|
||||
});
|
||||
assert.deepEqual(resolveDevLoopExecutor(3, { escalateAfter: 0 }), {
|
||||
executor: 'aider',
|
||||
taskType: 'page_data_dev',
|
||||
escalated: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('buildPageDataDevInstruction marks openhands escalation', () => {
|
||||
const instruction = buildPageDataDevInstruction({
|
||||
verifyOutput: 'failed',
|
||||
cwd: '/repo/MindSpace/u1',
|
||||
taskType: PAGE_DATA_DEV_COMPLEX_TASK_TYPE,
|
||||
escalated: true,
|
||||
verifyCommandLabel: 'npm run verify:page-data',
|
||||
});
|
||||
assert.match(instruction, /OpenHands 接手/);
|
||||
assert.match(instruction, /page_data_dev_complex/);
|
||||
});
|
||||
|
||||
test('children-hobby preset builds verify args with user id', () => {
|
||||
const preset = VERIFY_PRESETS['children-hobby-diet-survey'];
|
||||
const args = preset.buildVerifyArgs({
|
||||
userId: 'abc',
|
||||
testInsert: false,
|
||||
checkRuns: false,
|
||||
port: 9090,
|
||||
});
|
||||
assert.deepEqual(args, [
|
||||
'scripts/verify-children-hobby-diet-survey.mjs',
|
||||
'--user-id',
|
||||
'abc',
|
||||
'--no-insert',
|
||||
'--no-runs',
|
||||
'--port',
|
||||
'9090',
|
||||
]);
|
||||
});
|
||||
@@ -386,7 +386,9 @@ export async function verifySurveyDelivery({
|
||||
return false;
|
||||
}
|
||||
|
||||
const surveyLike = htmlFiles.filter((name) => /survey|问卷|feature/i.test(name));
|
||||
const surveyLike = htmlFiles.filter(
|
||||
(name) => /survey|问卷|feature|order|下单|submit|form|customer/i.test(name) && !/admin|后台|manage/i.test(name),
|
||||
);
|
||||
const adminLike = htmlFiles.filter((name) => /admin|后台|manage/i.test(name));
|
||||
if (surveyLike.length === 0) {
|
||||
reporter.fail('问卷 HTML', `public/ 中未找到问卷页,现有: ${htmlFiles.join(', ') || '(空)'}`);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* john4 客户下单系统(Page Data 前后台)演示搭建。
|
||||
* Usage: node scripts/setup-customer-order-demo.mjs
|
||||
*/
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
|
||||
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
|
||||
import { createUserDataSpaceService } from '../user-data-space-service.mjs';
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
const USER_ID = process.env.CUSTOMER_ORDER_USER_ID ?? '32035858-9a20-425b-89da-c118ef0779aa';
|
||||
const WORKSPACE_ROOT = path.join(root, 'MindSpace', USER_ID);
|
||||
const ADMIN_PASSWORD = '88888888';
|
||||
const DATASET = 'customer_orders';
|
||||
|
||||
const ORDER_FORM_HTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>客户下单</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: linear-gradient(135deg,#0f766e,#115e59); min-height: 100vh; padding: 32px 16px; }
|
||||
.card { max-width: 640px; margin: 0 auto; background: #fff; border-radius: 16px; padding: 28px; box-shadow: 0 20px 60px rgba(0,0,0,.18); }
|
||||
h1 { text-align: center; margin-bottom: 8px; color: #134e4a; }
|
||||
p.sub { text-align: center; color: #64748b; margin-bottom: 24px; font-size: 14px; }
|
||||
label { display: block; font-weight: 600; margin: 14px 0 6px; color: #334155; }
|
||||
input, textarea { width: 100%; padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 10px; font: inherit; }
|
||||
textarea { min-height: 80px; resize: vertical; }
|
||||
button { width: 100%; margin-top: 20px; padding: 14px; border: 0; border-radius: 12px; background: #0f766e; color: #fff; font-size: 16px; font-weight: 700; cursor: pointer; }
|
||||
button:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.msg { margin-top: 16px; padding: 12px; border-radius: 10px; display: none; }
|
||||
.msg.ok { display: block; background: #ecfdf5; color: #047857; }
|
||||
.msg.err { display: block; background: #fef2f2; color: #b91c1c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>客户下单</h1>
|
||||
<p class="sub">请填写订单信息,提交后将保存到 Page Data。</p>
|
||||
<form id="orderForm">
|
||||
<label>客户姓名 *</label><input id="customer_name" required>
|
||||
<label>联系电话 *</label><input id="phone" required>
|
||||
<label>商品名称 *</label><input id="product_name" required>
|
||||
<label>购买数量 *</label><input id="quantity" type="number" min="1" value="1" required>
|
||||
<label>收货地址 *</label><textarea id="address" required></textarea>
|
||||
<label>备注</label><textarea id="remark"></textarea>
|
||||
<button type="submit" id="submitBtn">提交订单</button>
|
||||
</form>
|
||||
<div class="msg" id="message"></div>
|
||||
</div>
|
||||
<script src="/assets/page-data-client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var client = MindSpacePageData.createClient({ apiBase: '/api' });
|
||||
var form = document.getElementById('orderForm');
|
||||
var message = document.getElementById('message');
|
||||
form.addEventListener('submit', async function (e) {
|
||||
e.preventDefault();
|
||||
message.className = 'msg';
|
||||
message.style.display = 'none';
|
||||
var payload = {
|
||||
customer_name: document.getElementById('customer_name').value.trim(),
|
||||
phone: document.getElementById('phone').value.trim(),
|
||||
product_name: document.getElementById('product_name').value.trim(),
|
||||
quantity: String(document.getElementById('quantity').value || '1'),
|
||||
address: document.getElementById('address').value.trim(),
|
||||
remark: document.getElementById('remark').value.trim(),
|
||||
status: '待处理'
|
||||
};
|
||||
if (!payload.customer_name || !payload.phone || !payload.product_name || !payload.address) {
|
||||
message.className = 'msg err'; message.textContent = '请填写所有必填项'; message.style.display = 'block'; return;
|
||||
}
|
||||
document.getElementById('submitBtn').disabled = true;
|
||||
try {
|
||||
await client.insertRow('customer_orders', payload);
|
||||
message.className = 'msg ok'; message.textContent = '下单成功!';
|
||||
message.style.display = 'block';
|
||||
form.reset();
|
||||
document.getElementById('quantity').value = '1';
|
||||
} catch (err) {
|
||||
message.className = 'msg err'; message.textContent = '提交失败:' + (err.message || err);
|
||||
message.style.display = 'block';
|
||||
} finally {
|
||||
document.getElementById('submitBtn').disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const ORDER_ADMIN_HTML = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>订单管理后台</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f1f5f9; min-height: 100vh; padding: 24px 16px; }
|
||||
.wrap { max-width: 1100px; margin: 0 auto; }
|
||||
.hero { background: #0f766e; color: #fff; border-radius: 14px; padding: 24px; margin-bottom: 16px; }
|
||||
.panel { background: #fff; border-radius: 14px; padding: 24px; box-shadow: 0 8px 24px rgba(15,23,42,.06); }
|
||||
input, button { font: inherit; }
|
||||
.auth input { width: 100%; max-width: 280px; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 8px; margin-right: 8px; }
|
||||
.auth button, .toolbar button { padding: 10px 16px; border: 0; border-radius: 8px; background: #0f766e; color: #fff; cursor: pointer; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
|
||||
th, td { padding: 10px 8px; border-bottom: 1px solid #e2e8f0; text-align: left; vertical-align: top; }
|
||||
th { background: #f8fafc; }
|
||||
.stats { display: flex; gap: 12px; flex-wrap: wrap; margin: 12px 0; }
|
||||
.stat { background: #ecfdf5; color: #065f46; padding: 10px 14px; border-radius: 10px; font-size: 13px; }
|
||||
.hidden { display: none; }
|
||||
.err { color: #b91c1c; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="hero"><h1>客户订单管理后台</h1><p>查看订单、按商品统计、导出 CSV</p></div>
|
||||
<div class="panel auth" id="authBox">
|
||||
<h2 style="margin-bottom:12px">请输入管理口令</h2>
|
||||
<input type="password" id="passwordInput" placeholder="管理口令">
|
||||
<button id="authBtn">进入后台</button>
|
||||
<div class="err hidden" id="authError"></div>
|
||||
</div>
|
||||
<div class="panel hidden" id="dash">
|
||||
<div class="toolbar">
|
||||
<button id="refreshBtn">刷新</button>
|
||||
<button id="exportBtn">导出 CSV</button>
|
||||
</div>
|
||||
<div class="stats" id="stats"></div>
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>客户</th><th>电话</th><th>商品</th><th>数量</th><th>地址</th><th>状态</th><th>时间</th></tr></thead>
|
||||
<tbody id="tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/assets/page-data-client.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var client = MindSpacePageData.createClient({ apiBase: '/api' });
|
||||
var rowsCache = [];
|
||||
document.getElementById('authBtn').addEventListener('click', async function () {
|
||||
try {
|
||||
await client.authenticate(document.getElementById('passwordInput').value.trim());
|
||||
document.getElementById('authBox').classList.add('hidden');
|
||||
document.getElementById('dash').classList.remove('hidden');
|
||||
await loadRows();
|
||||
} catch (e) {
|
||||
var err = document.getElementById('authError');
|
||||
err.textContent = '口令错误';
|
||||
err.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
document.getElementById('refreshBtn').addEventListener('click', loadRows);
|
||||
document.getElementById('exportBtn').addEventListener('click', exportCsv);
|
||||
async function loadRows() {
|
||||
var result = await client.listRows('customer_orders', { limit: 200 });
|
||||
rowsCache = result.rows || [];
|
||||
renderStats(rowsCache);
|
||||
renderTable(rowsCache);
|
||||
}
|
||||
function renderStats(rows) {
|
||||
var counts = {};
|
||||
rows.forEach(function (r) { counts[r.product_name] = (counts[r.product_name] || 0) + 1; });
|
||||
document.getElementById('stats').innerHTML = Object.keys(counts).map(function (k) {
|
||||
return '<div class="stat">' + k + ':' + counts[k] + ' 单</div>';
|
||||
}).join('') || '<div class="stat">暂无订单</div>';
|
||||
}
|
||||
function renderTable(rows) {
|
||||
var tbody = document.getElementById('tbody');
|
||||
if (!rows.length) { tbody.innerHTML = '<tr><td colspan="8">暂无数据</td></tr>'; return; }
|
||||
tbody.innerHTML = rows.map(function (r, i) {
|
||||
return '<tr><td>' + (i+1) + '</td><td>' + esc(r.customer_name) + '</td><td>' + esc(r.phone) +
|
||||
'</td><td>' + esc(r.product_name) + '</td><td>' + esc(r.quantity) + '</td><td>' + esc(r.address) +
|
||||
'</td><td>' + esc(r.status) + '</td><td>' + esc(r.created_at || '') + '</td></tr>';
|
||||
}).join('');
|
||||
}
|
||||
function exportCsv() {
|
||||
var header = ['customer_name','phone','product_name','quantity','address','remark','status','created_at'];
|
||||
var lines = [header.join(',')].concat(rowsCache.map(function (r) {
|
||||
return header.map(function (k) { return '"' + String(r[k] ?? '').replace(/"/g, '""') + '"'; }).join(',');
|
||||
}));
|
||||
var blob = new Blob([lines.join('\\n')], { type: 'text/csv;charset=utf-8' });
|
||||
var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'customer-orders.csv'; a.click();
|
||||
}
|
||||
function esc(v) { return String(v ?? '').replace(/[&<>"]/g, function (c) { return ({'&':'&','<':'<','>':'>','"':'"'}[c]); }); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const FORM_POLICY = {
|
||||
accessMode: 'public',
|
||||
datasets: {
|
||||
customer_orders: {
|
||||
insert: true,
|
||||
read: false,
|
||||
columns: {
|
||||
insert: ['customer_name', 'phone', 'product_name', 'quantity', 'address', 'remark', 'status'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ADMIN_POLICY = {
|
||||
accessMode: 'password',
|
||||
datasets: {
|
||||
customer_orders: {
|
||||
insert: false,
|
||||
read: true,
|
||||
columns: {
|
||||
read: ['id', 'customer_name', 'phone', 'product_name', 'quantity', 'address', 'remark', 'status', 'created_at'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const publicDir = path.join(WORKSPACE_ROOT, 'public');
|
||||
await fs.mkdir(publicDir, { recursive: true });
|
||||
await fs.writeFile(path.join(publicDir, 'customer-order-form.html'), ORDER_FORM_HTML, 'utf8');
|
||||
await fs.writeFile(path.join(publicDir, 'customer-order-admin.html'), ORDER_ADMIN_HTML, 'utf8');
|
||||
|
||||
const dataSpace = createUserDataSpaceService({ workspaceRoot: WORKSPACE_ROOT, userId: USER_ID });
|
||||
await dataSpace.executeSql(`CREATE TABLE IF NOT EXISTS customer_orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
customer_name TEXT NOT NULL DEFAULT '',
|
||||
phone TEXT NOT NULL DEFAULT '',
|
||||
product_name TEXT NOT NULL DEFAULT '',
|
||||
quantity TEXT NOT NULL DEFAULT '1',
|
||||
address TEXT NOT NULL DEFAULT '',
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT '待处理',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
|
||||
);`);
|
||||
await dataSpace.upsertDataset({
|
||||
name: DATASET,
|
||||
table: DATASET,
|
||||
description: '客户下单系统订单表',
|
||||
actions: ['read', 'insert'],
|
||||
columns: {
|
||||
insert: FORM_POLICY.datasets.customer_orders.columns.insert,
|
||||
read: ADMIN_POLICY.datasets.customer_orders.columns.read,
|
||||
},
|
||||
});
|
||||
|
||||
const pool = createDbPool();
|
||||
const storageRoot = resolveMindSpaceStorageRoot(root);
|
||||
const pages = [
|
||||
{ relativePath: 'public/customer-order-form.html', accessMode: 'public', password: null, policy: FORM_POLICY },
|
||||
{ relativePath: 'public/customer-order-admin.html', accessMode: 'password', password: ADMIN_PASSWORD, policy: ADMIN_POLICY },
|
||||
];
|
||||
|
||||
console.log('==> 客户下单系统 Page Data 演示\n');
|
||||
for (const page of pages) {
|
||||
const result = await bindWorkspaceHtmlForPageData({
|
||||
pool,
|
||||
h5Root: root,
|
||||
storageRoot,
|
||||
userId: USER_ID,
|
||||
workspaceRoot: WORKSPACE_ROOT,
|
||||
relativePath: page.relativePath,
|
||||
accessMode: page.accessMode,
|
||||
password: page.password,
|
||||
pageDataPolicy: page.policy,
|
||||
});
|
||||
console.log(`✓ ${page.relativePath}`);
|
||||
console.log(` pageId: ${result.pageId}`);
|
||||
console.log(` URL: ${result.workspaceUrl}\n`);
|
||||
}
|
||||
|
||||
console.log('后台口令:', ADMIN_PASSWORD);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.stack ?? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 验证 john4 客户下单系统 Page Data 链路(insert + admin read)。
|
||||
*/
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import {
|
||||
createReporter,
|
||||
loginViaApi,
|
||||
resolvePortalBase,
|
||||
} from './scenario-test-lib.mjs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const USER_ID = process.env.CUSTOMER_ORDER_USER_ID ?? '32035858-9a20-425b-89da-c118ef0779aa';
|
||||
const DATASET = 'customer_orders';
|
||||
const FORM_PAGE = 'customer-order-form.html';
|
||||
const ADMIN_PAGE = 'customer-order-admin.html';
|
||||
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
async function findPageIdFromPolicy(publishKey) {
|
||||
const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
|
||||
const entries = await fs.readdir(policyDir);
|
||||
for (const name of entries.filter((item) => item.endsWith('.json'))) {
|
||||
const raw = await fs.readFile(path.join(policyDir, name), 'utf8');
|
||||
const policy = JSON.parse(raw);
|
||||
if (policy?.datasets?.[DATASET]?.insert) {
|
||||
return { pageId: policy.pageId, fileName: name };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const port = Number(process.env.H5_PORT ?? 8081);
|
||||
const baseUrl = resolvePortalBase(port);
|
||||
const reporter = createReporter();
|
||||
|
||||
console.log('==> 客户下单系统 Page Data 验证');
|
||||
console.log(` Portal: ${baseUrl}`);
|
||||
console.log(` 用户: ${USER_ID}\n`);
|
||||
|
||||
const auth = await loginViaApi(baseUrl, { username: 'john4', password: '888888' }, reporter);
|
||||
const publishKey = auth.user?.id ?? USER_ID;
|
||||
|
||||
const formUrl = `${baseUrl}/MindSpace/${publishKey}/public/${FORM_PAGE}`;
|
||||
const adminUrl = `${baseUrl}/MindSpace/${publishKey}/public/${ADMIN_PAGE}`;
|
||||
|
||||
for (const [label, url] of [['下单页', formUrl], ['后台页', adminUrl]]) {
|
||||
const res = await fetch(url, { headers: { Cookie: auth.cookie } });
|
||||
const html = await res.text();
|
||||
if (res.status !== 200) {
|
||||
reporter.fail(`${label} HTTP`, `${res.status}`);
|
||||
} else {
|
||||
reporter.pass(`${label} HTTP 200`, url);
|
||||
}
|
||||
if (!html.includes('page-data-client.js')) {
|
||||
reporter.fail(`${label} 脚本`, '缺少 page-data-client.js');
|
||||
} else {
|
||||
reporter.pass(`${label} Page Data 客户端`, '已引用');
|
||||
}
|
||||
}
|
||||
|
||||
const formMeta = await findPageIdFromPolicy(publishKey);
|
||||
if (!formMeta?.pageId) {
|
||||
reporter.fail('Page Data policy', '未找到 customer_orders insert policy');
|
||||
process.exit(reporter.summary());
|
||||
}
|
||||
reporter.pass('Page Data policy', `${formMeta.fileName} pageId=${formMeta.pageId}`);
|
||||
|
||||
const insertPayload = {
|
||||
customer_name: '测试客户',
|
||||
phone: '13800138000',
|
||||
product_name: '演示商品A',
|
||||
quantity: '2',
|
||||
address: '上海市浦东新区测试路 1 号',
|
||||
remark: '自动化验证',
|
||||
status: '待处理',
|
||||
};
|
||||
|
||||
const insertRes = await fetch(
|
||||
`${baseUrl}/api/public/pages/${formMeta.pageId}/data/${DATASET}/rows`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: auth.cookie },
|
||||
body: JSON.stringify(insertPayload),
|
||||
},
|
||||
);
|
||||
const insertBody = await insertRes.json().catch(() => ({}));
|
||||
if (!insertRes.ok) {
|
||||
reporter.fail('下单 insert', `${insertRes.status} ${JSON.stringify(insertBody)}`);
|
||||
} else {
|
||||
reporter.pass('下单 insert', `row id=${insertBody?.data?.id ?? insertBody?.id ?? 'ok'}`);
|
||||
}
|
||||
|
||||
const adminMeta = await (async () => {
|
||||
const policyDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies');
|
||||
const entries = await fs.readdir(policyDir);
|
||||
for (const name of entries.filter((item) => item.endsWith('.json'))) {
|
||||
const policy = JSON.parse(await fs.readFile(path.join(policyDir, name), 'utf8'));
|
||||
if (policy?.datasets?.[DATASET]?.read) return policy.pageId;
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
if (!adminMeta) {
|
||||
reporter.fail('后台 policy', '未找到 read policy');
|
||||
} else {
|
||||
const tokenRes = await fetch(`${baseUrl}/api/public/pages/${adminMeta}/data-auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: auth.cookie },
|
||||
body: JSON.stringify({ password: '88888888' }),
|
||||
});
|
||||
const tokenBody = await tokenRes.json().catch(() => ({}));
|
||||
const token = tokenBody?.data?.token ?? tokenBody?.token;
|
||||
if (!tokenRes.ok || !token) {
|
||||
reporter.fail('后台口令认证', `${tokenRes.status}`);
|
||||
} else {
|
||||
const listRes = await fetch(
|
||||
`${baseUrl}/api/public/pages/${adminMeta}/data/${DATASET}?limit=20`,
|
||||
{ headers: { 'x-page-data-token': token, Cookie: auth.cookie } },
|
||||
);
|
||||
const listBody = await listRes.json().catch(() => ({}));
|
||||
const rows = listBody?.data?.rows ?? listBody?.rows ?? [];
|
||||
const hit = rows.some((row) => row.customer_name === insertPayload.customer_name && row.product_name === insertPayload.product_name);
|
||||
if (!listRes.ok || !hit) {
|
||||
reporter.fail('后台读订单', `rows=${rows.length}, 未找到测试订单`);
|
||||
} else {
|
||||
reporter.pass('后台读订单', `共 ${rows.length} 条,含测试订单`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sqlitePath = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'private-data.sqlite');
|
||||
try {
|
||||
await fs.stat(sqlitePath);
|
||||
reporter.pass('用户数据空间', 'private-data.sqlite 存在');
|
||||
} catch {
|
||||
reporter.pass('用户数据空间', 'PostgreSQL 模式(无本地 sqlite 文件)');
|
||||
}
|
||||
|
||||
console.log('\n--- 访问入口 ---');
|
||||
console.log(`下单页: ${formUrl}`);
|
||||
console.log(`后台页: ${adminUrl} (口令 88888888)`);
|
||||
|
||||
process.exit(reporter.summary());
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.stack ?? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
+38
-8
@@ -216,6 +216,7 @@ import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
||||
import { createEpisodicMemoryService } from './episodic-memory.mjs';
|
||||
import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs';
|
||||
import { createSystemDisclosurePolicyService } from './system-disclosure-policy.mjs';
|
||||
import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-config.mjs';
|
||||
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
|
||||
import { createExperienceService } from './experience-service.mjs';
|
||||
import { attachAsrRoutes } from './asr-proxy.mjs';
|
||||
@@ -388,6 +389,7 @@ let episodicMemoryService = null;
|
||||
let memoryV2ConfigService = null;
|
||||
let skillRuntimeConfigService = null;
|
||||
let systemDisclosurePolicyService = null;
|
||||
let agentCodeRunPolicyService = null;
|
||||
let wechatScheduleLlmConfigService = null;
|
||||
let mindSpace = null;
|
||||
let mindSpaceAssets = null;
|
||||
@@ -725,6 +727,7 @@ async function bootstrapUserAuth() {
|
||||
skillRuntimeConfigService = createSkillRuntimeAdminConfigService(pool, { h5Root: __dirname });
|
||||
systemDisclosurePolicyService = createSystemDisclosurePolicyService(pool);
|
||||
await systemDisclosurePolicyService.initialize();
|
||||
agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env });
|
||||
wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
||||
conversationMemoryService = createConversationMemoryService(pool, {
|
||||
llmProviderService,
|
||||
@@ -1033,6 +1036,16 @@ async function resolveSkillRuntimeForClient() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAgentCodeRunForClient(userId) {
|
||||
if (!agentCodeRunPolicyService?.getPublicClientPolicy) return null;
|
||||
try {
|
||||
return await agentCodeRunPolicyService.getPublicClientPolicy(userId);
|
||||
} catch (err) {
|
||||
console.warn('[AgentCodeRun] public policy unavailable:', err instanceof Error ? err.message : err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/auth/status', async (req, res) => {
|
||||
await userAuthReady;
|
||||
if (userAuth) {
|
||||
@@ -1042,6 +1055,7 @@ app.get('/auth/status', async (req, res) => {
|
||||
const row = await userAuth.getUserById(me.id);
|
||||
const capabilityState = await userAuth.resolveUserCapabilities(row);
|
||||
const skillRuntime = await resolveSkillRuntimeForClient();
|
||||
const agentCodeRun = await resolveAgentCodeRunForClient(me.id);
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
user: me,
|
||||
@@ -1050,6 +1064,7 @@ app.get('/auth/status', async (req, res) => {
|
||||
grantedSkills: capabilityState.grantedSkills ?? [],
|
||||
unrestricted: capabilityState.unrestricted,
|
||||
skillRuntime,
|
||||
agentCodeRun,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Auth] status failed:', err instanceof Error ? err.message : err);
|
||||
@@ -2238,12 +2253,21 @@ function runtimeCsvList(value) {
|
||||
}
|
||||
|
||||
function runtimeCodeRunPolicyStatus() {
|
||||
return {
|
||||
enabled: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED),
|
||||
userAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS),
|
||||
taskTypeAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES),
|
||||
requireValidation: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION),
|
||||
};
|
||||
return agentCodeRunPolicyService?.getRuntimeStatusSummary
|
||||
? agentCodeRunPolicyService.getRuntimeStatusSummary()
|
||||
: Promise.resolve({
|
||||
source: 'env',
|
||||
enabled: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_ENABLED),
|
||||
clientEnabled: runtimeEnvFlag(process.env.VITE_AGENT_CODE_RUNS_ENABLED),
|
||||
userAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUNS_USER_IDS),
|
||||
taskTypeAllowlist: runtimeCsvList(process.env.MEMIND_AGENT_CODE_RUN_TASK_TYPES),
|
||||
requireValidation: runtimeEnvFlag(process.env.MEMIND_AGENT_CODE_RUNS_REQUIRE_VALIDATION),
|
||||
pageDataDevAutodetect: runtimeEnvFlag(process.env.VITE_AGENT_PAGE_DATA_DEV_AUTODETECT),
|
||||
generalAutodetect: runtimeEnvFlag(process.env.VITE_AGENT_CODE_RUNS_AUTODETECT),
|
||||
envOverrideActive: String(process.env.MEMIND_CODE_RUN_POLICY_SOURCE ?? '').trim().toLowerCase() === 'env',
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
});
|
||||
}
|
||||
|
||||
api.get('/runtime/status', async (_req, res) => {
|
||||
@@ -2271,7 +2295,7 @@ api.get('/runtime/status', async (_req, res) => {
|
||||
if (toolQueue) {
|
||||
status.toolRuntime = {
|
||||
...(status.toolRuntime ?? {}),
|
||||
codeRunPolicy: runtimeCodeRunPolicyStatus(),
|
||||
codeRunPolicy: await runtimeCodeRunPolicyStatus(),
|
||||
queue: toolQueue,
|
||||
};
|
||||
}
|
||||
@@ -5109,7 +5133,13 @@ api.post('/agent/runs', async (req, res, next) => {
|
||||
[
|
||||
tkmindProxy.requireUser,
|
||||
tkmindProxy.ensureChatAllowed,
|
||||
createPostAgentRunsHandler({ userAuth, sessionAccess, agentRunGateway, mindSpaceAssetAgent }),
|
||||
createPostAgentRunsHandler({
|
||||
userAuth,
|
||||
sessionAccess,
|
||||
agentRunGateway,
|
||||
mindSpaceAssetAgent,
|
||||
codeRunPolicyService: agentCodeRunPolicyService,
|
||||
}),
|
||||
],
|
||||
req,
|
||||
res,
|
||||
|
||||
@@ -42,6 +42,7 @@ import type {
|
||||
} from '../types';
|
||||
import { normalizeConversationMessages, normalizeUserMessageForApi } from '../utils/message';
|
||||
import type { AgentRunCreateOptions, AgentRunValidation } from '../utils/agentRunMode';
|
||||
import { applyAgentCodeRunClientPolicy } from '../utils/agentRunMode';
|
||||
import {
|
||||
API,
|
||||
ApiError,
|
||||
@@ -319,6 +320,7 @@ export async function checkAuth(): Promise<AuthStatus> {
|
||||
return { authenticated: false };
|
||||
}
|
||||
if (status.authenticated) resetUnauthorizedGuard();
|
||||
applyAgentCodeRunClientPolicy(status.agentCodeRun ?? null);
|
||||
if (status.authenticated && status.mode === 'user' && !status.capabilities) {
|
||||
try {
|
||||
const me = await getMe();
|
||||
|
||||
@@ -139,7 +139,7 @@ export function ChatSharePreviewModal({
|
||||
<button type="button" className="chat-share-preview-close" onClick={onClose} aria-label="关闭">✕</button>
|
||||
</div>
|
||||
<div className="chat-share-preview-body">
|
||||
<iframe src={previewUrl} title="页面预览" sandbox="allow-same-origin allow-scripts allow-popups allow-downloads" className="chat-share-preview-frame" />
|
||||
<iframe src={previewUrl} title="页面预览" sandbox="allow-scripts allow-popups allow-downloads" className="chat-share-preview-frame" />
|
||||
|
||||
<div className="chat-share-side-actions">
|
||||
{phase.kind === 'done' ? (
|
||||
|
||||
@@ -112,7 +112,7 @@ export function PageSavePreviewPanel({
|
||||
title="页面预览"
|
||||
src={pagePreviewUrl}
|
||||
className="page-save-mini-page-frame"
|
||||
sandbox="allow-scripts allow-same-origin allow-downloads"
|
||||
sandbox="allow-scripts allow-downloads"
|
||||
scrolling="yes"
|
||||
onError={() => setPreviewFailed(true)}
|
||||
/>
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
CHAT_IMAGE_UPLOAD_MAX_INPUT_BYTES,
|
||||
} from '../utils/imageUpload';
|
||||
import { buildAbsoluteAssetDownloadUrl } from '../utils/mindspaceCards';
|
||||
import { resolveAgentRunOptions } from '../utils/agentRunMode';
|
||||
import { resolveAgentRunOptions, resolvePageDataDevTaskType } from '../utils/agentRunMode';
|
||||
import {
|
||||
buildUserMessage,
|
||||
normalizeConversationMessages,
|
||||
@@ -1472,8 +1472,9 @@ export function useTKMindChat(
|
||||
}
|
||||
|
||||
try {
|
||||
const pageDataDevTaskType = resolvePageDataDevTaskType(trimmed);
|
||||
const runOptions = resolveAgentRunOptions(trimmed, {
|
||||
taskType: 'h5_chat_code_task',
|
||||
taskType: pageDataDevTaskType ?? 'h5_chat_code_task',
|
||||
userId: userRef.current?.id ?? null,
|
||||
requestId,
|
||||
mindspaceContext: options?.mindspaceContext ?? null,
|
||||
|
||||
@@ -905,6 +905,14 @@ export type PolicyDefinition = {
|
||||
risk: 'low' | 'medium' | 'high';
|
||||
};
|
||||
|
||||
export type AgentCodeRunClientPolicy = {
|
||||
codeRun?: {
|
||||
enabled?: boolean;
|
||||
pageDataDevAutodetect?: boolean;
|
||||
generalAutodetect?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type AuthStatus = {
|
||||
authenticated: boolean;
|
||||
mode?: 'user' | 'legacy' | 'none' | 'unavailable';
|
||||
@@ -913,6 +921,7 @@ export type AuthStatus = {
|
||||
capabilities?: CapabilityMap;
|
||||
grantedSkills?: string[];
|
||||
unrestricted?: boolean;
|
||||
agentCodeRun?: AgentCodeRunClientPolicy | null;
|
||||
};
|
||||
|
||||
export type PathGrant = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MindSpaceChatContext } from '../types';
|
||||
import type { MindSpaceChatContext, AgentCodeRunClientPolicy } from '../types';
|
||||
|
||||
export type AgentRunCreateOptions = {
|
||||
toolMode?: 'chat' | 'code';
|
||||
@@ -28,6 +28,38 @@ export const agentCodeRunsEnabled = envFlag(import.meta.env.VITE_AGENT_CODE_RUNS
|
||||
export const agentCodeRunsAutodetectEnabled = envFlag(
|
||||
import.meta.env.VITE_AGENT_CODE_RUNS_AUTODETECT,
|
||||
);
|
||||
export const agentPageDataDevAutodetectEnabled = envFlag(
|
||||
import.meta.env.VITE_AGENT_PAGE_DATA_DEV_AUTODETECT,
|
||||
);
|
||||
|
||||
let runtimeAgentCodeRunPolicy: AgentCodeRunClientPolicy | null = null;
|
||||
|
||||
export function applyAgentCodeRunClientPolicy(policy: AgentCodeRunClientPolicy | null) {
|
||||
runtimeAgentCodeRunPolicy = policy;
|
||||
}
|
||||
|
||||
function clientCodeRunsEnabled(): boolean {
|
||||
if (runtimeAgentCodeRunPolicy?.codeRun?.enabled != null) {
|
||||
return runtimeAgentCodeRunPolicy.codeRun.enabled;
|
||||
}
|
||||
return agentCodeRunsEnabled;
|
||||
}
|
||||
|
||||
function clientCodeRunsAutodetectEnabled(): boolean {
|
||||
if (runtimeAgentCodeRunPolicy?.codeRun?.generalAutodetect != null) {
|
||||
return runtimeAgentCodeRunPolicy.codeRun.generalAutodetect;
|
||||
}
|
||||
return agentCodeRunsAutodetectEnabled;
|
||||
}
|
||||
|
||||
function clientPageDataDevAutodetectEnabled(): boolean {
|
||||
if (runtimeAgentCodeRunPolicy?.codeRun?.pageDataDevAutodetect != null) {
|
||||
return runtimeAgentCodeRunPolicy.codeRun.pageDataDevAutodetect;
|
||||
}
|
||||
return agentPageDataDevAutodetectEnabled;
|
||||
}
|
||||
|
||||
export const PAGE_DATA_DEV_TASK_TYPE = 'page_data_dev';
|
||||
|
||||
function parseUserIdSet(value: unknown): Set<string> {
|
||||
return new Set(
|
||||
@@ -41,11 +73,32 @@ function parseUserIdSet(value: unknown): Set<string> {
|
||||
const agentCodeRunUserIds = parseUserIdSet(import.meta.env.VITE_AGENT_CODE_RUNS_USER_IDS);
|
||||
|
||||
export function agentCodeRunsEnabledForUser(userId?: string | null): boolean {
|
||||
if (!agentCodeRunsEnabled) return false;
|
||||
if (!clientCodeRunsEnabled()) return false;
|
||||
if (agentCodeRunUserIds.size === 0) return true;
|
||||
return Boolean(userId && agentCodeRunUserIds.has(userId));
|
||||
}
|
||||
|
||||
const PAGE_DATA_DEV_TASK_PATTERNS = [
|
||||
/columns_not_allowed/i,
|
||||
/\bpage[\s-]?data[\s-]?dev\b/i,
|
||||
/(?:insert|提交|写入).{0,16}(?:失败|403|报错|error)/iu,
|
||||
/(?:修复|修|排查|debug|fix).{0,24}(?:问卷|page[\s-]?data|数据提交|插入|admin|后台|policy|绑定|bind)/iu,
|
||||
/(?:问卷|page[\s-]?data|数据页|表单页).{0,24}(?:bug|坏了|不通|失败|有问题)/iu,
|
||||
/(?:测试|verify).{0,16}(?:没过|失败|不通过)/iu,
|
||||
/(?:repair|修复).{0,16}(?:bind|绑定|policy|策略)/iu,
|
||||
];
|
||||
|
||||
export function isPageDataDevTaskText(text: string): boolean {
|
||||
const normalized = String(text ?? '').trim();
|
||||
if (!normalized) return false;
|
||||
return PAGE_DATA_DEV_TASK_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||
}
|
||||
|
||||
export function resolvePageDataDevTaskType(text: string): string | null {
|
||||
if (!clientPageDataDevAutodetectEnabled()) return null;
|
||||
return isPageDataDevTaskText(text) ? PAGE_DATA_DEV_TASK_TYPE : null;
|
||||
}
|
||||
|
||||
const CODE_TASK_PATTERNS = [
|
||||
/\b(repo|repository|branch|commit|pull request|pr|diff|patch)\b/i,
|
||||
/\b(aider|openhands|codex|codebase|workspace)\b/i,
|
||||
@@ -185,6 +238,22 @@ export function buildAgentRunTaskValidation({
|
||||
);
|
||||
}
|
||||
|
||||
if (taskType === PAGE_DATA_DEV_TASK_TYPE) {
|
||||
const taskReceiptPath = `.memind/agent-runs/${safeRequestId}-page-data-dev.json`;
|
||||
expectedFiles.push({ path: taskReceiptPath, contains: PAGE_DATA_DEV_TASK_TYPE });
|
||||
instructions.push(
|
||||
'',
|
||||
'[Memind page-data-dev validation]',
|
||||
`Before finishing this Page Data dev repair task, create or update ${taskReceiptPath}.`,
|
||||
'The file must be valid JSON and include:',
|
||||
`- requestId: ${requestId}`,
|
||||
`- taskType: ${PAGE_DATA_DEV_TASK_TYPE}`,
|
||||
'- a brief summary of HTML/policy fixes or why no file change was needed.',
|
||||
'Only modify public/*.html and .mindspace/page-data-policies/*.json unless explicitly told otherwise.',
|
||||
'Do not recreate datasets or call private_data_execute unless the user pasted schema errors.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
validation: expectedFiles.length ? { expectedFiles } : null,
|
||||
instruction: instructions.join('\n'),
|
||||
@@ -221,7 +290,8 @@ export function resolveAgentRunOptions(
|
||||
{
|
||||
taskType = 'code_task',
|
||||
forceCode = false,
|
||||
allowAutodetect = agentCodeRunsAutodetectEnabled,
|
||||
allowAutodetect = clientCodeRunsAutodetectEnabled(),
|
||||
allowPageDataDevAutodetect = clientPageDataDevAutodetectEnabled(),
|
||||
userId = null,
|
||||
requestId = null,
|
||||
mindspaceContext = null,
|
||||
@@ -230,6 +300,7 @@ export function resolveAgentRunOptions(
|
||||
taskType?: string;
|
||||
forceCode?: boolean;
|
||||
allowAutodetect?: boolean;
|
||||
allowPageDataDevAutodetect?: boolean;
|
||||
userId?: string | null;
|
||||
requestId?: string | null;
|
||||
mindspaceContext?: MindSpaceChatContext | null;
|
||||
@@ -237,29 +308,37 @@ export function resolveAgentRunOptions(
|
||||
} = {},
|
||||
): AgentRunCreateOptions {
|
||||
const normalizedText = String(text ?? '').trim();
|
||||
const pageDataDevTaskType =
|
||||
allowPageDataDevAutodetect && resolvePageDataDevTaskType(normalizedText);
|
||||
const effectiveTaskType = pageDataDevTaskType ?? taskType;
|
||||
const shouldUseDeepReasoning =
|
||||
forceCode || DEEP_REASONING_TASK_PATTERNS.some((pattern) => pattern.test(normalizedText));
|
||||
forceCode ||
|
||||
Boolean(pageDataDevTaskType) ||
|
||||
DEEP_REASONING_TASK_PATTERNS.some((pattern) => pattern.test(normalizedText));
|
||||
|
||||
if (!agentCodeRunsEnabledForUser(userId)) {
|
||||
return shouldUseDeepReasoning ? { forceDeepReasoning: true, taskType } : {};
|
||||
return shouldUseDeepReasoning ? { forceDeepReasoning: true, taskType: effectiveTaskType } : {};
|
||||
}
|
||||
|
||||
const shouldUseCode = forceCode || (allowAutodetect && CODE_TASK_PATTERNS.some((pattern) => pattern.test(normalizedText)));
|
||||
const shouldUseCode =
|
||||
forceCode ||
|
||||
Boolean(pageDataDevTaskType) ||
|
||||
(allowAutodetect && CODE_TASK_PATTERNS.some((pattern) => pattern.test(normalizedText)));
|
||||
if (!shouldUseCode) {
|
||||
return shouldUseDeepReasoning ? { forceDeepReasoning: true, taskType } : {};
|
||||
return shouldUseDeepReasoning ? { forceDeepReasoning: true, taskType: effectiveTaskType } : {};
|
||||
}
|
||||
const normalizedRequestId = requestId ?? crypto.randomUUID();
|
||||
const receipt = buildAgentRunValidationReceipt(normalizedRequestId);
|
||||
const taskValidation = buildAgentRunTaskValidation({
|
||||
requestId: normalizedRequestId,
|
||||
taskType,
|
||||
taskType: effectiveTaskType,
|
||||
text: normalizedText,
|
||||
mindspaceContext,
|
||||
pageEdit,
|
||||
});
|
||||
return {
|
||||
toolMode: 'code',
|
||||
taskType,
|
||||
taskType: effectiveTaskType,
|
||||
forceDeepReasoning: true,
|
||||
validation: mergeAgentRunValidationFiles(receipt.validation, taskValidation.validation),
|
||||
validationInstruction: `${receipt.instruction}${taskValidation.instruction}`,
|
||||
|
||||
+2
-1
@@ -64,7 +64,8 @@ export function createToolGateway({
|
||||
const dryRun = envFlag(env.MEMIND_TOOL_GATEWAY_DRY_RUN, false);
|
||||
const defaultExecutor = normalizeExecutor(env.MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR, 'aider');
|
||||
const openhandsTaskTypes = csvSet(
|
||||
env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES ?? 'repo_refactor,multi_file,complex_repo',
|
||||
env.MEMIND_TOOL_GATEWAY_OPENHANDS_TASK_TYPES
|
||||
?? 'repo_refactor,multi_file,complex_repo,page_data_dev_complex',
|
||||
);
|
||||
const stdioLimit = positiveInteger(env.MEMIND_TOOL_GATEWAY_STDIO_LIMIT, DEFAULT_STDIO_LIMIT);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ test('tool gateway is disabled by default and reports protocol', () => {
|
||||
protocol: 'agent-run-v1',
|
||||
executors: ['aider', 'openhands'],
|
||||
defaultExecutor: 'aider',
|
||||
openhandsTaskTypes: ['repo_refactor', 'multi_file', 'complex_repo'],
|
||||
openhandsTaskTypes: ['repo_refactor', 'multi_file', 'complex_repo', 'page_data_dev_complex'],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,11 @@ test('tool gateway selects openhands for configured task types', () => {
|
||||
assert.equal(gateway.selectExecutor({ taskType: 'small_patch' }), 'aider');
|
||||
});
|
||||
|
||||
test('tool gateway selects openhands for page_data_dev_complex by default', () => {
|
||||
const gateway = createToolGateway({ env: {} });
|
||||
assert.equal(gateway.selectExecutor({ taskType: 'page_data_dev_complex' }), 'openhands');
|
||||
});
|
||||
|
||||
test('tool gateway dry run builds executor launch plan without spawning', async () => {
|
||||
const plans = [];
|
||||
const gateway = createToolGateway({
|
||||
|
||||
@@ -17,6 +17,7 @@ export default defineConfig(({ mode }) => {
|
||||
proxy: {
|
||||
'/api': portalTarget,
|
||||
'/auth': portalTarget,
|
||||
'/analytics': portalTarget,
|
||||
'/admin-api': adminTarget,
|
||||
'/MindSpace': portalTarget,
|
||||
'/dev': portalTarget,
|
||||
|
||||
Reference in New Issue
Block a user