merge: integrate recent product analytics and Aider development
This commit is contained in:
@@ -18,7 +18,11 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
|
||||
# MEMIND_ANALYTICS_URL=http://127.0.0.1:3100
|
||||
# MEMIND_ANALYTICS_WEBSITE_ID=<local Umami Website ID>
|
||||
# MEMIND_ANALYTICS_ID_SECRET=<local-only pseudonymization secret>
|
||||
# MEMIND_ANALYTICS_IDENTITY_MODE=pseudonymous # use raw only in an approved first-party analytics environment
|
||||
# MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost
|
||||
# Memind 产品壳层使用独立 Website,避免与用户生成页面的浏览口径混在一起
|
||||
# MEMIND_PRODUCT_ANALYTICS_ENABLED=true
|
||||
# MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID=<local Umami product Website ID>
|
||||
|
||||
# Local Rybbit analytics (same-origin /rybbit proxy to rybbit.tkmind.cn).
|
||||
# Create a Rybbit Site for localhost / 127.0.0.1, then set its numeric site_id.
|
||||
|
||||
+237
-6
@@ -230,6 +230,15 @@ function summarizeText(value, limit = TOOL_GATEWAY_SUMMARY_LIMIT) {
|
||||
return text.slice(text.length - limit);
|
||||
}
|
||||
|
||||
export function buildCodeRunCompletionReply(result) {
|
||||
const executor = String(result?.executor ?? 'code executor').trim() || 'code executor';
|
||||
const output = summarizeText(result?.stdout, 2400).trim();
|
||||
return [
|
||||
`已由 ${executor} 完成执行,并通过平台文件验收。`,
|
||||
output ? `\n${output}` : '',
|
||||
].join('').trim();
|
||||
}
|
||||
|
||||
function normalizeExpectedFileCheck(value) {
|
||||
if (typeof value === 'string') {
|
||||
const expectedPath = value.trim();
|
||||
@@ -346,6 +355,26 @@ function normalizeSessionMessageCount(value) {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
export function resolveRequiredCodeExecutor(userMessage) {
|
||||
const metadata = userMessage?.metadata;
|
||||
const runMetadata = metadata?.[RUN_METADATA_KEY] ?? metadata?.agentRun ?? {};
|
||||
const executor = String(runMetadata?.executor ?? '').trim().toLowerCase();
|
||||
return ['aider', 'openhands'].includes(executor) ? executor : null;
|
||||
}
|
||||
|
||||
export function assertRequiredCodeExecutorAvailable(requiredExecutor, toolGatewayStatus) {
|
||||
if (!requiredExecutor) return;
|
||||
const executors = Array.isArray(toolGatewayStatus?.executors)
|
||||
? toolGatewayStatus.executors.map((item) => String(item).trim().toLowerCase())
|
||||
: [];
|
||||
if (!toolGatewayStatus?.enabled || !executors.includes(requiredExecutor)) {
|
||||
const error = new Error(`必需执行器 ${requiredExecutor} 不可用,任务已停止且不会回退`);
|
||||
error.code = 'REQUIRED_EXECUTOR_UNAVAILABLE';
|
||||
error.retryable = false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getRunOptionsFromMessage(userMessage) {
|
||||
const metadata = userMessage?.metadata;
|
||||
const runMetadata = metadata?.[RUN_METADATA_KEY] ?? metadata?.agentRun ?? {};
|
||||
@@ -358,6 +387,13 @@ function getRunOptionsFromMessage(userMessage) {
|
||||
return {
|
||||
toolMode,
|
||||
taskType: normalizeTaskType(runMetadata?.taskType ?? metadata?.taskType),
|
||||
requiredExecutor: resolveRequiredCodeExecutor(userMessage),
|
||||
reviewExecutor: ['aider', 'openhands'].includes(
|
||||
String(runMetadata?.reviewExecutor ?? '').trim().toLowerCase(),
|
||||
)
|
||||
? String(runMetadata.reviewExecutor).trim().toLowerCase()
|
||||
: null,
|
||||
pageDataAiderWorkflow: runMetadata?.pageDataAiderWorkflow === true,
|
||||
forceDeepReasoning: runMetadata?.forceDeepReasoning === true || metadata?.forceDeepReasoning === true,
|
||||
validation: normalizeToolGatewayValidation(runMetadata?.validation ?? metadata?.toolGatewayValidation),
|
||||
sessionMessageCount: normalizeSessionMessageCount(
|
||||
@@ -366,6 +402,48 @@ function getRunOptionsFromMessage(userMessage) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function collectAiderReviewFiles(cwd, sinceMs = 0) {
|
||||
if (!cwd) return [];
|
||||
const root = path.resolve(String(cwd));
|
||||
const candidates = [
|
||||
{ relativeDir: 'public', extensions: new Set(['.html', '.css', '.js']) },
|
||||
{ relativeDir: '.mindspace/page-data-policies', extensions: new Set(['.json']) },
|
||||
];
|
||||
const files = [];
|
||||
async function walk(baseDir, relativeDir, extensions) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.readdir(baseDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (files.length >= 40) return;
|
||||
const absolute = path.join(baseDir, entry.name);
|
||||
const relative = path.posix.join(relativeDir.split(path.sep).join('/'), entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(absolute, relative, extensions);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !extensions.has(path.extname(entry.name).toLowerCase())) continue;
|
||||
try {
|
||||
const stat = await fs.stat(absolute);
|
||||
if (Number(stat.mtimeMs) + 5_000 >= Number(sinceMs || 0)) files.push(relative);
|
||||
} catch {
|
||||
// File disappeared between directory listing and stat.
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
await walk(
|
||||
path.join(root, candidate.relativeDir),
|
||||
candidate.relativeDir,
|
||||
candidate.extensions,
|
||||
);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function projectRun(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
@@ -837,6 +915,89 @@ export function createAgentRunGateway({
|
||||
}
|
||||
}
|
||||
|
||||
async function runRequiredCodeReview({
|
||||
row,
|
||||
runId,
|
||||
userMessage,
|
||||
runOptions,
|
||||
sessionId,
|
||||
}) {
|
||||
if (!runOptions.reviewExecutor) return;
|
||||
const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null;
|
||||
assertRequiredCodeExecutorAvailable(runOptions.reviewExecutor, toolGatewayStatus);
|
||||
const workingDir = userAuth?.resolveWorkingDir
|
||||
? await userAuth.resolveWorkingDir(row.user_id)
|
||||
: undefined;
|
||||
const claimedRun = await getRunById(runId);
|
||||
const contextFiles = await collectAiderReviewFiles(
|
||||
workingDir,
|
||||
claimedRun?.started_at ?? row.started_at ?? 0,
|
||||
);
|
||||
const reviewMessage = {
|
||||
...userMessage,
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: [
|
||||
'[Mandatory Aider Page Data review]',
|
||||
`Original user request: ${extractRunDisplayText(row)}`,
|
||||
`Session: ${sessionId}`,
|
||||
'Review the provided workspace files, fix concrete HTML/client-policy defects if needed,',
|
||||
'do not recreate PostgreSQL tables or datasets, and update the required validation receipt.',
|
||||
'Do not commit, push, publish, or modify files outside the current user workspace.',
|
||||
contextFiles.length
|
||||
? `Review files:\n${contextFiles.map((item) => `- ${item}`).join('\n')}`
|
||||
: 'No recent page files were detected; record that fact in the receipt so platform delivery validation can fail closed.',
|
||||
`Receipt requestId: ${row.request_id}`,
|
||||
].join('\n'),
|
||||
}],
|
||||
metadata: {
|
||||
...(userMessage?.metadata ?? {}),
|
||||
memindRun: {
|
||||
...(userMessage?.metadata?.memindRun ?? {}),
|
||||
executor: runOptions.reviewExecutor,
|
||||
aiderContextFiles: contextFiles,
|
||||
},
|
||||
},
|
||||
};
|
||||
await appendEvent(runId, 'required_code_review_dispatch', {
|
||||
executor: runOptions.reviewExecutor,
|
||||
contextFiles,
|
||||
});
|
||||
const result = await toolGateway.executeJob({
|
||||
runId,
|
||||
userId: row.user_id,
|
||||
requestId: row.request_id,
|
||||
userMessage: reviewMessage,
|
||||
taskType: 'page_data_dev',
|
||||
cwd: workingDir,
|
||||
timeoutMs: runTimeoutMs,
|
||||
});
|
||||
if (
|
||||
String(result?.executor ?? '').trim().toLowerCase() !== runOptions.reviewExecutor
|
||||
) {
|
||||
const error = new Error(
|
||||
`审查执行器不匹配:要求 ${runOptions.reviewExecutor},实际 ${result?.executor ?? 'unknown'}`,
|
||||
);
|
||||
error.code = 'REQUIRED_REVIEW_EXECUTOR_MISMATCH';
|
||||
error.retryable = false;
|
||||
throw error;
|
||||
}
|
||||
await appendEvent(runId, 'required_code_review_result', {
|
||||
executor: result.executor ?? null,
|
||||
exitCode: result.exitCode ?? null,
|
||||
stdoutTail: summarizeText(result.stdout),
|
||||
stderrTail: summarizeText(result.stderr),
|
||||
});
|
||||
const validation = await validateToolGatewayResult({
|
||||
result,
|
||||
validation: runOptions.validation,
|
||||
cwd: workingDir,
|
||||
});
|
||||
if (validation) {
|
||||
await appendEvent(runId, 'required_code_review_validation', validation);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRunRouting(row, userMessage, runOptions) {
|
||||
if (!chatIntentRouter?.classify) return null;
|
||||
const enabled = chatIntentRouter.isEnabled
|
||||
@@ -859,6 +1020,13 @@ export function createAgentRunGateway({
|
||||
let userMessage = safeJsonParse(row.user_message_json, {});
|
||||
const runOptions = getRunOptionsFromMessage(userMessage);
|
||||
const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null;
|
||||
assertRequiredCodeExecutorAvailable(
|
||||
runOptions.requiredExecutor ?? runOptions.reviewExecutor,
|
||||
toolGatewayStatus,
|
||||
);
|
||||
const effectiveToolMode = runOptions.pageDataAiderWorkflow
|
||||
? 'chat'
|
||||
: runOptions.toolMode;
|
||||
let disclosureDecision = null;
|
||||
try {
|
||||
disclosureDecision = systemDisclosurePolicyService?.evaluate?.({
|
||||
@@ -1018,7 +1186,11 @@ export function createAgentRunGateway({
|
||||
}
|
||||
}
|
||||
}
|
||||
if (runOptions.toolMode === 'code' && toolGatewayStatus?.enabled) {
|
||||
if (
|
||||
runOptions.toolMode === 'code' &&
|
||||
!runOptions.pageDataAiderWorkflow &&
|
||||
toolGatewayStatus?.enabled
|
||||
) {
|
||||
const workingDir = userAuth?.resolveWorkingDir
|
||||
? await userAuth.resolveWorkingDir(row.user_id)
|
||||
: undefined;
|
||||
@@ -1037,6 +1209,17 @@ export function createAgentRunGateway({
|
||||
cwd: workingDir,
|
||||
timeoutMs: runTimeoutMs,
|
||||
});
|
||||
if (
|
||||
runOptions.requiredExecutor &&
|
||||
String(result?.executor ?? '').trim().toLowerCase() !== runOptions.requiredExecutor
|
||||
) {
|
||||
const error = new Error(
|
||||
`执行器不匹配:要求 ${runOptions.requiredExecutor},实际 ${result?.executor ?? 'unknown'}`,
|
||||
);
|
||||
error.code = 'REQUIRED_EXECUTOR_MISMATCH';
|
||||
error.retryable = false;
|
||||
throw error;
|
||||
}
|
||||
await appendEvent(runId, 'tool_gateway_result', {
|
||||
executor: result.executor ?? null,
|
||||
dryRun: Boolean(result.dryRun),
|
||||
@@ -1061,6 +1244,38 @@ export function createAgentRunGateway({
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
if (runOptions.requiredExecutor) {
|
||||
if (!directChatService?.respondDeterministically) {
|
||||
const error = new Error('代码任务已执行,但结果回传服务不可用');
|
||||
error.code = 'CODE_RUN_RESULT_DELIVERY_UNAVAILABLE';
|
||||
error.retryable = false;
|
||||
throw error;
|
||||
}
|
||||
const delivery = await directChatService.respondDeterministically({
|
||||
userId: row.user_id,
|
||||
sessionId: row.agent_session_id ?? null,
|
||||
requestId: row.request_id,
|
||||
userMessage,
|
||||
reply: buildCodeRunCompletionReply(result),
|
||||
metadata: {
|
||||
source: 'tool-gateway-code-run',
|
||||
executor: result.executor ?? null,
|
||||
validated: true,
|
||||
},
|
||||
onSessionReady: async (activeSessionId) => {
|
||||
await pool.query(
|
||||
`UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`,
|
||||
[activeSessionId, nowMs(), runId],
|
||||
);
|
||||
await appendRunSnapshot(runId);
|
||||
},
|
||||
});
|
||||
await appendEvent(runId, 'tool_gateway_result_delivered', {
|
||||
sessionId: delivery.sessionId,
|
||||
executor: result.executor ?? null,
|
||||
});
|
||||
return { sessionId: delivery.sessionId, routing };
|
||||
}
|
||||
return { sessionId: row.agent_session_id ?? null, routing };
|
||||
}
|
||||
|
||||
@@ -1079,7 +1294,7 @@ export function createAgentRunGateway({
|
||||
}
|
||||
if (!sessionId) {
|
||||
const sessionOptions = {};
|
||||
if (runOptions.toolMode === 'code' && userAuth?.getCodeAgentSessionPolicy) {
|
||||
if (effectiveToolMode === 'code' && userAuth?.getCodeAgentSessionPolicy) {
|
||||
sessionOptions.sessionPolicy = await userAuth.getCodeAgentSessionPolicy(row.user_id);
|
||||
}
|
||||
const session = await tkmindProxy.startSessionForUser(row.user_id, sessionOptions);
|
||||
@@ -1090,7 +1305,7 @@ export function createAgentRunGateway({
|
||||
);
|
||||
await appendEvent(runId, 'session_started', {
|
||||
sessionId,
|
||||
toolMode: runOptions.toolMode,
|
||||
toolMode: effectiveToolMode,
|
||||
taskType: runOptions.taskType,
|
||||
});
|
||||
await appendRunSnapshot(runId);
|
||||
@@ -1132,8 +1347,17 @@ export function createAgentRunGateway({
|
||||
await invalidatePortalDirectChatSnapshot(sessionId);
|
||||
let toolEvidence = null;
|
||||
const awaitSessionFinish = envFlag(process.env.MEMIND_AGENT_RUN_AWAIT_SESSION_FINISH, true)
|
||||
&& runOptions.toolMode === 'chat'
|
||||
&& effectiveToolMode === 'chat'
|
||||
&& typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function';
|
||||
if (
|
||||
runOptions.pageDataAiderWorkflow &&
|
||||
typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser !== 'function'
|
||||
) {
|
||||
const error = new Error('Page Data + Aider 工作流需要等待 Page Data Agent 完成');
|
||||
error.code = 'PAGE_DATA_AIDER_FINISH_UNAVAILABLE';
|
||||
error.retryable = false;
|
||||
throw error;
|
||||
}
|
||||
if (awaitSessionFinish) {
|
||||
let submitMessage = ensureGooseUserMessageMetadata(userMessage);
|
||||
let replacedPoisonedSession = false;
|
||||
@@ -1145,7 +1369,7 @@ export function createAgentRunGateway({
|
||||
row.request_id,
|
||||
submitMessage,
|
||||
{
|
||||
toolMode: runOptions.toolMode,
|
||||
toolMode: effectiveToolMode,
|
||||
forceDeepReasoning: runOptions.forceDeepReasoning,
|
||||
timeoutMs: runTimeoutMs,
|
||||
},
|
||||
@@ -1211,11 +1435,18 @@ export function createAgentRunGateway({
|
||||
row.request_id,
|
||||
ensureGooseUserMessageMetadata(userMessage),
|
||||
{
|
||||
toolMode: runOptions.toolMode,
|
||||
toolMode: effectiveToolMode,
|
||||
forceDeepReasoning: runOptions.forceDeepReasoning,
|
||||
},
|
||||
);
|
||||
}
|
||||
await runRequiredCodeReview({
|
||||
row,
|
||||
runId,
|
||||
userMessage,
|
||||
runOptions,
|
||||
sessionId,
|
||||
});
|
||||
return { sessionId, routing, toolEvidence };
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,43 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
assertRequiredCodeExecutorAvailable,
|
||||
assertRequiredImageGenerationCompleted,
|
||||
createAgentRunGateway,
|
||||
normalizeAgentRunWorkerIdentity,
|
||||
resolveRequiredCodeExecutor,
|
||||
} from './agent-run-gateway.mjs';
|
||||
|
||||
test('required code executor is read from run metadata', () => {
|
||||
assert.equal(resolveRequiredCodeExecutor({
|
||||
metadata: { memindRun: { executor: 'AIDER' } },
|
||||
}), 'aider');
|
||||
assert.equal(resolveRequiredCodeExecutor({
|
||||
metadata: { memindRun: { executor: 'unknown' } },
|
||||
}), null);
|
||||
});
|
||||
|
||||
test('required Aider executor fails closed when Tool Gateway is unavailable', () => {
|
||||
assert.doesNotThrow(() => assertRequiredCodeExecutorAvailable('aider', {
|
||||
enabled: true,
|
||||
executors: ['aider', 'openhands'],
|
||||
}));
|
||||
assert.throws(
|
||||
() => assertRequiredCodeExecutorAvailable('aider', {
|
||||
enabled: false,
|
||||
executors: ['aider', 'openhands'],
|
||||
}),
|
||||
(error) => error?.code === 'REQUIRED_EXECUTOR_UNAVAILABLE' && error?.retryable === false,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertRequiredCodeExecutorAvailable('aider', {
|
||||
enabled: true,
|
||||
executors: ['openhands'],
|
||||
}),
|
||||
(error) => error?.code === 'REQUIRED_EXECUTOR_UNAVAILABLE',
|
||||
);
|
||||
});
|
||||
|
||||
test('required image generation cannot succeed without a verified raster image_make result', () => {
|
||||
const row = {
|
||||
user_message_json: JSON.stringify({
|
||||
@@ -1056,6 +1088,114 @@ test('Page Data run succeeds only after a generated session page is detected', a
|
||||
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
|
||||
});
|
||||
|
||||
test('Page Data plus Aider workflow builds with Agent and then performs mandatory Aider review', async () => {
|
||||
const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-page-data-aider-'));
|
||||
const pool = createFakePool({
|
||||
sessionDeliverables: {
|
||||
'user-1:session-order-system': [{
|
||||
page_id: 'page-order',
|
||||
title: '下单系统',
|
||||
publication_id: 'pub-order',
|
||||
publication_status: 'online',
|
||||
public_url: 'http://127.0.0.1:5173/u/john/pages/page-order',
|
||||
workspace_relative_path: 'public/order.html',
|
||||
}],
|
||||
},
|
||||
});
|
||||
const reviewJobs = [];
|
||||
const gateway = createAgentRunGateway({
|
||||
pool,
|
||||
userAuth: {
|
||||
async resolveWorkingDir() {
|
||||
return workdir;
|
||||
},
|
||||
},
|
||||
tkmindProxy: {
|
||||
async startSessionForUser() {
|
||||
return { id: 'session-order-system' };
|
||||
},
|
||||
async submitSessionReplyAndAwaitFinishForUser(_userId, _sessionId, _requestId, _message, options) {
|
||||
assert.equal(options.toolMode, 'chat');
|
||||
await fs.mkdir(path.join(workdir, 'public'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workdir, 'public', 'order.html'),
|
||||
'<!doctype html><script src="/assets/page-data-client.js"></script>',
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
finishEvent: { type: 'Finish' },
|
||||
toolEvidence: { calls: ['private_data_execute', 'private_data_bind_workspace_page'] },
|
||||
};
|
||||
},
|
||||
},
|
||||
toolGateway: {
|
||||
getStatus() {
|
||||
return {
|
||||
enabled: true,
|
||||
protocol: 'agent-run-v1',
|
||||
executors: ['aider', 'openhands'],
|
||||
};
|
||||
},
|
||||
async executeJob(job) {
|
||||
reviewJobs.push(job);
|
||||
await fs.mkdir(path.join(workdir, '.memind', 'agent-runs'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workdir, '.memind', 'agent-runs', 'req-page-data-aider.json'),
|
||||
JSON.stringify({ requestId: 'req-page-data-aider', review: 'passed' }),
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
executor: 'aider',
|
||||
exitCode: 0,
|
||||
cwd: workdir,
|
||||
stdout: 'Reviewed public/order.html and the Page Data client usage.',
|
||||
};
|
||||
},
|
||||
},
|
||||
syncUserPagesOnSuccess: async () => ({
|
||||
pageDataBind: { errors: [] },
|
||||
pageDataRelativePaths: ['public/order.html'],
|
||||
}),
|
||||
validateRunDeliverables: async () => ({ errors: [] }),
|
||||
retryDelaysMs: [],
|
||||
});
|
||||
|
||||
const run = await gateway.createRun('user-1', {
|
||||
requestId: 'req-page-data-aider',
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '创建下单系统并在后台管理订单' }],
|
||||
metadata: {
|
||||
displayText: '创建下单系统并在后台管理订单',
|
||||
memindRun: {
|
||||
reviewExecutor: 'aider',
|
||||
pageDataAiderWorkflow: true,
|
||||
validation: {
|
||||
expectedFile: {
|
||||
path: '.memind/agent-runs/req-page-data-aider.json',
|
||||
contains: 'req-page-data-aider',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
toolMode: 'chat',
|
||||
});
|
||||
|
||||
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
|
||||
assert.equal(reviewJobs.length, 1);
|
||||
assert.equal(reviewJobs[0].taskType, 'page_data_dev');
|
||||
assert.deepEqual(
|
||||
reviewJobs[0].userMessage.metadata.memindRun.aiderContextFiles,
|
||||
['public/order.html'],
|
||||
);
|
||||
assert.ok(
|
||||
pool.events.some(
|
||||
(event) => event.runId === run.id && event.eventType === 'required_code_review_validation',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('agent run fails closed when a generated page violates browser storage policy', async () => {
|
||||
const pool = createFakePool({
|
||||
sessionDeliverables: {
|
||||
@@ -2042,6 +2182,88 @@ test('agent run validates expected tool gateway artifacts before succeeding', as
|
||||
assert.equal(JSON.parse(validationEvent.dataJson).expectedFiles[0].path, 'RESULT.md');
|
||||
});
|
||||
|
||||
test('required Aider run persists a validated result into a chat session', async () => {
|
||||
const pool = createFakePool();
|
||||
const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-aider-delivery-'));
|
||||
const deliveries = [];
|
||||
const gateway = createAgentRunGateway({
|
||||
pool,
|
||||
userAuth: {
|
||||
async resolveWorkingDir() {
|
||||
return workdir;
|
||||
},
|
||||
},
|
||||
tkmindProxy: {},
|
||||
directChatService: {
|
||||
async respondDeterministically(options) {
|
||||
deliveries.push(options);
|
||||
await options.onSessionReady('h5direct_aider_result');
|
||||
return { sessionId: 'h5direct_aider_result' };
|
||||
},
|
||||
},
|
||||
toolGateway: {
|
||||
getStatus() {
|
||||
return {
|
||||
enabled: true,
|
||||
protocol: 'agent-run-v1',
|
||||
executors: ['aider', 'openhands'],
|
||||
};
|
||||
},
|
||||
async executeJob() {
|
||||
await fs.mkdir(path.join(workdir, '.memind', 'agent-runs'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workdir, '.memind', 'agent-runs', 'req-aider-delivery.json'),
|
||||
JSON.stringify({ requestId: 'req-aider-delivery', tests: 'passed' }),
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
dryRun: false,
|
||||
executor: 'aider',
|
||||
exitCode: 0,
|
||||
cwd: workdir,
|
||||
stdout: 'Implemented the requested page and ran its checks.',
|
||||
stderr: '',
|
||||
};
|
||||
},
|
||||
},
|
||||
retryDelaysMs: [],
|
||||
});
|
||||
|
||||
const run = await gateway.createRun('user-1', {
|
||||
requestId: 'req-aider-delivery',
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'build the page' }],
|
||||
metadata: {
|
||||
displayText: 'build the page',
|
||||
memindRun: {
|
||||
executor: 'aider',
|
||||
validation: {
|
||||
expectedFile: {
|
||||
path: '.memind/agent-runs/req-aider-delivery.json',
|
||||
contains: 'req-aider-delivery',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
toolMode: 'code',
|
||||
taskType: 'h5_chat_code_task',
|
||||
});
|
||||
|
||||
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
|
||||
assert.equal(pool.runs.get(run.id).agent_session_id, 'h5direct_aider_result');
|
||||
assert.equal(deliveries.length, 1);
|
||||
assert.match(deliveries[0].reply, /Aider/i);
|
||||
assert.match(deliveries[0].reply, /通过平台文件验收/);
|
||||
assert.equal(
|
||||
pool.events.some(
|
||||
(event) => event.runId === run.id && event.eventType === 'tool_gateway_result_delivered',
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('agent run fails non-retryably when tool gateway artifact validation fails', async () => {
|
||||
const pool = createFakePool();
|
||||
const workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-tool-validation-missing-'));
|
||||
|
||||
+119
-5
@@ -1,4 +1,10 @@
|
||||
import { normalizeAgentRunToolMode } from './agent-run-gateway.mjs';
|
||||
import {
|
||||
AIDER_DEVELOPMENT_SKILL_NAME,
|
||||
buildChatSkillPrompt,
|
||||
extractAiderDevelopmentTask,
|
||||
isPageDataIntent,
|
||||
} from './chat-skills.mjs';
|
||||
import { createSessionAccess } from './session-broker.mjs';
|
||||
import {
|
||||
extractRunFromStreamEvent,
|
||||
@@ -51,6 +57,82 @@ function hasExpectedFileValidation(userMessage) {
|
||||
});
|
||||
}
|
||||
|
||||
function selectedChatSkill(userMessage) {
|
||||
const metadata = userMessage?.metadata;
|
||||
const runMetadata = metadata?.memindRun ?? metadata?.agentRun ?? {};
|
||||
return String(runMetadata.selectedChatSkill ?? '').trim();
|
||||
}
|
||||
|
||||
function rewriteAiderPageDataInstruction(userMessage, taskText) {
|
||||
const aiderPrompt = buildChatSkillPrompt(
|
||||
AIDER_DEVELOPMENT_SKILL_NAME,
|
||||
AIDER_DEVELOPMENT_SKILL_NAME,
|
||||
);
|
||||
const pageDataPrompt = buildChatSkillPrompt('page-data-collect', 'page-data-collect');
|
||||
const compositePrompt = [
|
||||
pageDataPrompt,
|
||||
taskText,
|
||||
'\n\n[强制 Aider 审查]',
|
||||
'先由当前 Agent 使用 private_data_* 和 Page Data 工具完成建表、dataset、页面与绑定。',
|
||||
'完成后平台会强制调用 Aider 审查当前工作区产物;禁止省略该审查或声称 Aider 已执行。',
|
||||
].join('');
|
||||
const content = Array.isArray(userMessage?.content)
|
||||
? userMessage.content
|
||||
.filter((item) => (
|
||||
item?.type !== 'text' ||
|
||||
!String(item.text ?? '').trim().startsWith('[Memind code-run validation]')
|
||||
))
|
||||
.map((item) => {
|
||||
if (item?.type !== 'text') return item;
|
||||
const text = String(item.text ?? '');
|
||||
return {
|
||||
...item,
|
||||
text: text.includes(aiderPrompt)
|
||||
? text.replace(`${aiderPrompt}${taskText}`, compositePrompt)
|
||||
: text,
|
||||
};
|
||||
})
|
||||
: userMessage?.content;
|
||||
return { ...userMessage, content };
|
||||
}
|
||||
|
||||
export function enforceSelectedSkillRuntime(userMessage, {
|
||||
rawToolMode = 'chat',
|
||||
taskType = null,
|
||||
} = {}) {
|
||||
if (selectedChatSkill(userMessage) !== AIDER_DEVELOPMENT_SKILL_NAME) {
|
||||
return { userMessage, rawToolMode, taskType, requiredExecutor: null };
|
||||
}
|
||||
const message = userMessage && typeof userMessage === 'object' && !Array.isArray(userMessage)
|
||||
? { ...userMessage }
|
||||
: { value: userMessage };
|
||||
const metadata = message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
|
||||
? { ...message.metadata }
|
||||
: {};
|
||||
const runMetadata = metadata.memindRun && typeof metadata.memindRun === 'object' && !Array.isArray(metadata.memindRun)
|
||||
? { ...metadata.memindRun }
|
||||
: {};
|
||||
const taskText = extractAiderDevelopmentTask(userMessage);
|
||||
const requiresPageDataBuild = isPageDataIntent(taskText);
|
||||
metadata.memindRun = {
|
||||
...runMetadata,
|
||||
selectedChatSkill: AIDER_DEVELOPMENT_SKILL_NAME,
|
||||
...(requiresPageDataBuild
|
||||
? { reviewExecutor: 'aider', pageDataAiderWorkflow: true }
|
||||
: { executor: 'aider' }),
|
||||
};
|
||||
if (requiresPageDataBuild) delete metadata.memindRun.executor;
|
||||
return {
|
||||
userMessage: requiresPageDataBuild
|
||||
? rewriteAiderPageDataInstruction({ ...message, metadata }, taskText)
|
||||
: { ...message, metadata },
|
||||
rawToolMode: requiresPageDataBuild ? 'chat' : 'code',
|
||||
taskType: requiresPageDataBuild ? null : 'h5_chat_code_task',
|
||||
requiredExecutor: requiresPageDataBuild ? null : 'aider',
|
||||
requiredReviewExecutor: requiresPageDataBuild ? 'aider' : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPostAgentRunsHandler({
|
||||
userAuth,
|
||||
sessionAccess = null,
|
||||
@@ -86,9 +168,9 @@ export function createPostAgentRunsHandler({
|
||||
try {
|
||||
const sessionId = String(request.body?.session_id ?? '').trim() || null;
|
||||
const requestId = String(request.body?.request_id ?? '').trim();
|
||||
const userMessage = request.body?.user_message ?? null;
|
||||
const rawToolMode = request.body?.tool_mode ?? request.body?.toolMode ?? 'chat';
|
||||
const taskType = String(request.body?.task_type ?? request.body?.taskType ?? '').trim() || null;
|
||||
let userMessage = request.body?.user_message ?? null;
|
||||
let rawToolMode = request.body?.tool_mode ?? request.body?.toolMode ?? 'chat';
|
||||
let taskType = String(request.body?.task_type ?? request.body?.taskType ?? '').trim() || null;
|
||||
const forceDeepReasoning = request.body?.force_deep_reasoning === true || request.body?.forceDeepReasoning === true;
|
||||
if (!requestId) {
|
||||
response.status(400).json({ message: '缺少 request_id' });
|
||||
@@ -98,6 +180,30 @@ export function createPostAgentRunsHandler({
|
||||
response.status(400).json({ message: '缺少 user_message' });
|
||||
return;
|
||||
}
|
||||
const selectedSkillRuntime = enforceSelectedSkillRuntime(userMessage, {
|
||||
rawToolMode,
|
||||
taskType,
|
||||
});
|
||||
userMessage = selectedSkillRuntime.userMessage;
|
||||
rawToolMode = selectedSkillRuntime.rawToolMode;
|
||||
taskType = selectedSkillRuntime.taskType;
|
||||
if (
|
||||
(selectedSkillRuntime.requiredExecutor || selectedSkillRuntime.requiredReviewExecutor) &&
|
||||
!extractAiderDevelopmentTask(userMessage)
|
||||
) {
|
||||
response.status(400).json({ message: '请输入需要 Aider 执行的具体开发任务' });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(selectedSkillRuntime.requiredExecutor || selectedSkillRuntime.requiredReviewExecutor) &&
|
||||
userAuth?.getUserSkills
|
||||
) {
|
||||
const skillState = await userAuth.getUserSkills(request.currentUser.id);
|
||||
if (!skillState?.skills?.[AIDER_DEVELOPMENT_SKILL_NAME]) {
|
||||
response.status(403).json({ message: '当前用户未授权 Aider 开发技能' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
let toolMode = 'chat';
|
||||
try {
|
||||
toolMode = normalizeAgentRunToolMode(rawToolMode);
|
||||
@@ -107,8 +213,11 @@ export function createPostAgentRunsHandler({
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (toolMode === 'code') {
|
||||
if (toolMode === 'code' || selectedSkillRuntime.requiredReviewExecutor) {
|
||||
const codeRunPolicy = await resolveCodeRunPolicy(request.currentUser.id);
|
||||
const policyTaskType = selectedSkillRuntime.requiredReviewExecutor
|
||||
? 'h5_chat_code_task'
|
||||
: taskType;
|
||||
if (!codeRunPolicy.enabled) {
|
||||
response.status(403).json({ message: '代码任务灰度未开启' });
|
||||
return;
|
||||
@@ -120,7 +229,12 @@ export function createPostAgentRunsHandler({
|
||||
const taskTypeAllowlist = codeRunPolicy.taskTypeAllowlist ?? [];
|
||||
if (
|
||||
taskTypeAllowlist.length > 0 &&
|
||||
(!taskType || !taskTypeAllowlist.map((item) => String(item).toLowerCase()).includes(taskType.toLowerCase()))
|
||||
(
|
||||
!policyTaskType ||
|
||||
!taskTypeAllowlist
|
||||
.map((item) => String(item).toLowerCase())
|
||||
.includes(policyTaskType.toLowerCase())
|
||||
)
|
||||
) {
|
||||
response.status(403).json({ message: '当前代码任务类型未开启灰度' });
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createAgentRunEventsHandler,
|
||||
createGetAgentRunHandler,
|
||||
createPostAgentRunsHandler,
|
||||
enforceSelectedSkillRuntime,
|
||||
} from './agent-run-routes.mjs';
|
||||
|
||||
function createResponseRecorder() {
|
||||
@@ -100,6 +101,223 @@ test('POST /agent/runs creates a run and returns 202', async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('selected Aider development skill forces code mode, task type, and Aider executor', async () => {
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '修改页面并测试' }],
|
||||
metadata: {
|
||||
memindRun: {
|
||||
selectedChatSkill: 'aider-development',
|
||||
executor: 'openhands',
|
||||
validation: { expectedFile: '.memind/agent-runs/req-aider.json' },
|
||||
},
|
||||
},
|
||||
};
|
||||
const enforced = enforceSelectedSkillRuntime(userMessage, {
|
||||
rawToolMode: 'chat',
|
||||
taskType: 'page_data_dev_complex',
|
||||
});
|
||||
assert.equal(enforced.rawToolMode, 'code');
|
||||
assert.equal(enforced.taskType, 'h5_chat_code_task');
|
||||
assert.equal(enforced.requiredExecutor, 'aider');
|
||||
assert.equal(enforced.userMessage.metadata.memindRun.executor, 'aider');
|
||||
|
||||
const created = [];
|
||||
const handler = createPostAgentRunsHandler({
|
||||
userAuth: {
|
||||
async getUserSkills() {
|
||||
return { skills: { 'aider-development': true } };
|
||||
},
|
||||
},
|
||||
agentRunGateway: {
|
||||
async createRun(userId, payload) {
|
||||
created.push({ userId, payload });
|
||||
return { id: 'run-aider', status: 'queued' };
|
||||
},
|
||||
},
|
||||
codeRunsEnabled: true,
|
||||
requireCodeRunValidation: true,
|
||||
});
|
||||
const res = createResponseRecorder();
|
||||
await handler(
|
||||
{
|
||||
currentUser: { id: 'user-1' },
|
||||
body: {
|
||||
request_id: 'req-aider',
|
||||
user_message: userMessage,
|
||||
tool_mode: 'chat',
|
||||
task_type: 'page_data_dev_complex',
|
||||
},
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
assert.equal(res.statusCode, 202);
|
||||
assert.equal(created[0].payload.toolMode, 'code');
|
||||
assert.equal(created[0].payload.taskType, 'h5_chat_code_task');
|
||||
assert.equal(created[0].payload.userMessage.metadata.memindRun.executor, 'aider');
|
||||
});
|
||||
|
||||
test('selected Aider development skill rejects a template-only task before creating a run', async () => {
|
||||
let created = false;
|
||||
const handler = createPostAgentRunsHandler({
|
||||
userAuth: {
|
||||
async getUserSkills() {
|
||||
return { skills: { 'aider-development': true } };
|
||||
},
|
||||
},
|
||||
agentRunGateway: {
|
||||
async createRun() {
|
||||
created = true;
|
||||
return { id: 'must-not-run' };
|
||||
},
|
||||
},
|
||||
codeRunsEnabled: true,
|
||||
});
|
||||
const res = createResponseRecorder();
|
||||
const template =
|
||||
'请使用 aider-development 技能:本轮必须由 Aider code run 在当前用户工作区执行开发、修复和验证;Aider 或 Tool Gateway 不可用时直接失败,禁止回退到 Goose、OpenHands 或普通聊天冒充执行。我的开发任务是:';
|
||||
|
||||
await handler(
|
||||
{
|
||||
currentUser: { id: 'user-1' },
|
||||
body: {
|
||||
request_id: 'req-aider-empty',
|
||||
user_message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: template }],
|
||||
metadata: {
|
||||
displayText: template,
|
||||
memindRun: { selectedChatSkill: 'aider-development' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
assert.equal(res.statusCode, 400);
|
||||
assert.match(res.body.message, /具体开发任务/);
|
||||
assert.equal(created, false);
|
||||
});
|
||||
|
||||
test('Aider development routes Page Data creation through Agent then requires Aider review', () => {
|
||||
const template =
|
||||
'请使用 aider-development 技能:本轮必须由 Aider code run 在当前用户工作区执行开发、修复和验证;Aider 或 Tool Gateway 不可用时直接失败,禁止回退到 Goose、OpenHands 或普通聊天冒充执行。我的开发任务是:';
|
||||
const task = '帮我设计一个简单下单系统,不要支付,可以有简单后台管理订单';
|
||||
const enforced = enforceSelectedSkillRuntime(
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: `${template}${task}` },
|
||||
{
|
||||
type: 'text',
|
||||
text: '[Memind code-run validation]\nBefore finishing, create the receipt.',
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
displayText: `${template}${task}`,
|
||||
memindRun: { selectedChatSkill: 'aider-development', executor: 'openhands' },
|
||||
},
|
||||
},
|
||||
{ rawToolMode: 'code', taskType: 'h5_chat_code_task' },
|
||||
);
|
||||
assert.equal(enforced.rawToolMode, 'chat');
|
||||
assert.equal(enforced.taskType, null);
|
||||
assert.equal(enforced.requiredExecutor, null);
|
||||
assert.equal(enforced.requiredReviewExecutor, 'aider');
|
||||
assert.equal(enforced.userMessage.metadata.memindRun.executor, undefined);
|
||||
assert.equal(enforced.userMessage.metadata.memindRun.reviewExecutor, 'aider');
|
||||
assert.equal(enforced.userMessage.metadata.memindRun.pageDataAiderWorkflow, true);
|
||||
assert.match(enforced.userMessage.content[0].text, /private_data_execute/);
|
||||
assert.match(enforced.userMessage.content[0].text, /强制 Aider 审查/);
|
||||
assert.match(enforced.userMessage.content[0].text, /简单下单系统/);
|
||||
assert.equal(enforced.userMessage.content.length, 1);
|
||||
});
|
||||
|
||||
test('selected Aider development skill fails closed when code runs are disabled', async () => {
|
||||
const handler = createPostAgentRunsHandler({
|
||||
userAuth: {
|
||||
async getUserSkills() {
|
||||
return { skills: { 'aider-development': true } };
|
||||
},
|
||||
},
|
||||
agentRunGateway: {
|
||||
async createRun() {
|
||||
throw new Error('must not fall back to a normal run');
|
||||
},
|
||||
},
|
||||
codeRunsEnabled: false,
|
||||
});
|
||||
const res = createResponseRecorder();
|
||||
await handler(
|
||||
{
|
||||
currentUser: { id: 'user-1' },
|
||||
body: {
|
||||
request_id: 'req-aider-disabled',
|
||||
user_message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '修复代码' }],
|
||||
metadata: {
|
||||
memindRun: {
|
||||
selectedChatSkill: 'aider-development',
|
||||
validation: { expectedFile: '.memind/agent-runs/req-aider-disabled.json' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
assert.equal(res.statusCode, 403);
|
||||
assert.match(res.body.message, /代码任务灰度未开启/);
|
||||
});
|
||||
|
||||
test('Page Data plus Aider review also respects the code-run policy gate', async () => {
|
||||
const template =
|
||||
'请使用 aider-development 技能:本轮必须由 Aider code run 在当前用户工作区执行开发、修复和验证;Aider 或 Tool Gateway 不可用时直接失败,禁止回退到 Goose、OpenHands 或普通聊天冒充执行。我的开发任务是:';
|
||||
const task = '创建下单系统并在后台管理订单';
|
||||
const handler = createPostAgentRunsHandler({
|
||||
userAuth: {
|
||||
async getUserSkills() {
|
||||
return { skills: { 'aider-development': true } };
|
||||
},
|
||||
},
|
||||
agentRunGateway: {
|
||||
async createRun() {
|
||||
assert.fail('disabled review policy must not create a run');
|
||||
},
|
||||
},
|
||||
codeRunsEnabled: false,
|
||||
});
|
||||
const res = createResponseRecorder();
|
||||
await handler(
|
||||
{
|
||||
currentUser: { id: 'user-1' },
|
||||
body: {
|
||||
request_id: 'req-page-data-aider-disabled',
|
||||
user_message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `${template}${task}` }],
|
||||
metadata: {
|
||||
displayText: `${template}${task}`,
|
||||
memindRun: {
|
||||
selectedChatSkill: 'aider-development',
|
||||
validation: {
|
||||
expectedFile: '.memind/agent-runs/req-page-data-aider-disabled.json',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
res,
|
||||
);
|
||||
assert.equal(res.statusCode, 403);
|
||||
assert.match(res.body.message, /代码任务灰度未开启/);
|
||||
});
|
||||
|
||||
test('POST /agent/runs forwards deep reasoning flag to the run gateway', async () => {
|
||||
const created = [];
|
||||
const handler = createPostAgentRunsHandler({
|
||||
|
||||
+19
-2
@@ -162,6 +162,11 @@ export function resolveExcelMcpServerPath(overridePath, runtimeRoot) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBundledMcpRuntimeRoot(sandboxMcp) {
|
||||
if (!sandboxMcp?.containerized || !sandboxMcp?.serverPath) return undefined;
|
||||
return path.dirname(sandboxMcp.serverPath);
|
||||
}
|
||||
|
||||
export const CAPABILITY_CATALOG = [
|
||||
{
|
||||
key: 'shell',
|
||||
@@ -535,6 +540,7 @@ export function buildAgentExtensionPolicy(
|
||||
}
|
||||
|
||||
const extensions = [];
|
||||
const bundledMcpRuntimeRoot = resolveBundledMcpRuntimeRoot(sandboxMcp);
|
||||
if (capabilities.static_publish || (capabilities.private_data_space && sandboxMcp)) {
|
||||
const localRoot = resolveSandboxMcpLocalRoot(sandboxMcp);
|
||||
const compatRoot = resolveSandboxMcpCompatRoot(sandboxMcp);
|
||||
@@ -645,7 +651,12 @@ export function buildAgentExtensionPolicy(
|
||||
display_name: 'tkmind-search',
|
||||
bundled: false,
|
||||
cmd: resolveSandboxMcpNodeExecPath(process.env.GOOSED_MCP_NODE_PATH),
|
||||
args: [resolveMindSearchMcpServerPath(process.env.TKMIND_SEARCH_MCP_SERVER_PATH)],
|
||||
args: [
|
||||
resolveMindSearchMcpServerPath(
|
||||
process.env.TKMIND_SEARCH_MCP_SERVER_PATH,
|
||||
bundledMcpRuntimeRoot,
|
||||
),
|
||||
],
|
||||
envs: {
|
||||
TKMIND_SEARCH_ENABLED: '1',
|
||||
TKMIND_SEARCH_MODE: mindSearchConfig.mode,
|
||||
@@ -687,7 +698,13 @@ export function buildAgentExtensionPolicy(
|
||||
display_name: 'Excel Analyst',
|
||||
bundled: false,
|
||||
cmd: resolveSandboxMcpNodeExecPath(sandboxMcp?.nodeExecPath),
|
||||
args: [resolveExcelMcpServerPath(process.env.GOOSED_EXCEL_MCP_SERVER_PATH), excelWorkspaceRoot],
|
||||
args: [
|
||||
resolveExcelMcpServerPath(
|
||||
process.env.GOOSED_EXCEL_MCP_SERVER_PATH,
|
||||
bundledMcpRuntimeRoot,
|
||||
),
|
||||
excelWorkspaceRoot,
|
||||
],
|
||||
envs: {
|
||||
EXCEL_ANALYST_ENABLED: '1',
|
||||
MINDSPACE_WORKSPACE_ROOT: excelWorkspaceRoot,
|
||||
|
||||
@@ -503,3 +503,21 @@ test('sandboxMcp can use workspaceRoot as the local runtime root compatibility f
|
||||
assert.equal(sandboxExt.envs.MINDSPACE_WORKSPACE_ROOT, '/opt/h5/MindSpace/abc123');
|
||||
assert.equal(sandboxExt.envs.MINDSPACE_WORKSPACE_REF, 'mindspace://users/abc123/workspace');
|
||||
});
|
||||
|
||||
test('Excel analyst uses the container-visible bundled MCP directory', () => {
|
||||
const policy = buildAgentExtensionPolicy(
|
||||
{
|
||||
...DEFAULT_USER_CAPABILITIES,
|
||||
excel_analysis: true,
|
||||
},
|
||||
{
|
||||
sandboxMcp: {
|
||||
containerized: true,
|
||||
serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs',
|
||||
sandboxRoot: '/opt/portal/MindSpace/user-1',
|
||||
},
|
||||
},
|
||||
);
|
||||
const extension = policy.extensionOverrides.find((ext) => ext.name === 'tkmind-excel');
|
||||
assert.equal(extension.args[0], '/opt/portal/tkmind-excel-mcp.mjs');
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const PUBLISH_SKILL_NAME = 'static-page-publish';
|
||||
export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
|
||||
export const EXCEL_ANALYST_SKILL_NAME = 'excel-analyst';
|
||||
export const AIDER_DEVELOPMENT_SKILL_NAME = 'aider-development';
|
||||
export const SKILL_ROUTER_V2_ENV = 'TKMIND_SKILL_ROUTER_V2';
|
||||
|
||||
const EXCEL_ANALYSIS_INTENT_PATTERNS = [
|
||||
@@ -51,6 +52,8 @@ const PAGE_DATA_INTENT_PATTERNS = [
|
||||
/(?:页面|网页|H5|h5).{0,80}(?:每天|每日|新增|添加|填写|记录).{0,80}(?:所有记录|历史记录|管理|汇总|统计)/u,
|
||||
/(?:页面|网页|商城|店铺).{0,80}(?:下单|订单|购物车).{0,80}(?:后台|管理|上架|商品|产品|库存)/u,
|
||||
/(?:后台|管理).{0,80}(?:上架|下架|商品|产品|库存).{0,80}(?:下单|订单|购物车|页面|网页|商城|店铺)/u,
|
||||
/(?:下单|订单).{0,40}(?:后台|管理|记录|保存|查询|状态)/u,
|
||||
/(?:后台|管理).{0,40}(?:下单|订单)/u,
|
||||
];
|
||||
|
||||
const INTERACTIVE_PAGE_DATA_SUBJECT_PATTERN = /(?:便签|便利贴|备忘录|待办|清单)/u;
|
||||
@@ -173,6 +176,15 @@ export const CHAT_SKILL_DEFINITIONS = [
|
||||
prefillOnly: true,
|
||||
promptKey: 'service-integration-smoke',
|
||||
},
|
||||
{
|
||||
id: AIDER_DEVELOPMENT_SKILL_NAME,
|
||||
label: 'Aider 开发',
|
||||
icon: 'spark',
|
||||
skillName: AIDER_DEVELOPMENT_SKILL_NAME,
|
||||
requiresSkill: AIDER_DEVELOPMENT_SKILL_NAME,
|
||||
prefillOnly: true,
|
||||
promptKey: AIDER_DEVELOPMENT_SKILL_NAME,
|
||||
},
|
||||
{
|
||||
id: 'form-collect',
|
||||
label: '表单收集',
|
||||
@@ -253,6 +265,11 @@ export function buildChatSkillPrompt(promptKey, skillName) {
|
||||
);
|
||||
case 'service-integration-smoke':
|
||||
return `请使用 ${skillName ?? 'service-integration-smoke'} 技能:按标准联调流程检查当前服务,覆盖身份、普通聊天、记忆读取,以及我本轮明确要求验证的技能/发布链路,并输出通过项、失败项、待确认项:`;
|
||||
case AIDER_DEVELOPMENT_SKILL_NAME:
|
||||
return (
|
||||
`请使用 ${skillName ?? AIDER_DEVELOPMENT_SKILL_NAME} 技能:` +
|
||||
'本轮必须由 Aider code run 在当前用户工作区执行开发、修复和验证;Aider 或 Tool Gateway 不可用时直接失败,禁止回退到 Goose、OpenHands 或普通聊天冒充执行。我的开发任务是:'
|
||||
);
|
||||
case 'table-viewer':
|
||||
return `请使用 ${skillName ?? 'table-viewer'} 技能:请把以下数据整理成可排序、可筛选的交互式表格:`;
|
||||
case 'product-campaign-page':
|
||||
@@ -284,6 +301,33 @@ export function buildChatSkillPrompt(promptKey, skillName) {
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeChatSkillPromptWithInput(prompt, currentInput) {
|
||||
const normalizedPrompt = String(prompt ?? '');
|
||||
const existing = String(currentInput ?? '').trim();
|
||||
if (!existing) return normalizedPrompt;
|
||||
if (existing.startsWith(normalizedPrompt)) return existing;
|
||||
return `${normalizedPrompt}${existing}`;
|
||||
}
|
||||
|
||||
export function extractAiderDevelopmentTask(userMessage) {
|
||||
const metadata = userMessage?.metadata;
|
||||
const displayText = String(metadata?.displayText ?? '').trim();
|
||||
const contentText = Array.isArray(userMessage?.content)
|
||||
? userMessage.content
|
||||
.filter((item) => item?.type === 'text')
|
||||
.map((item) => String(item.text ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
: String(userMessage?.content ?? userMessage?.text ?? userMessage?.value ?? '').trim();
|
||||
const source = displayText || contentText;
|
||||
const prompt = buildChatSkillPrompt(
|
||||
AIDER_DEVELOPMENT_SKILL_NAME,
|
||||
AIDER_DEVELOPMENT_SKILL_NAME,
|
||||
);
|
||||
if (!source.startsWith(prompt)) return source;
|
||||
return source.slice(prompt.length).trim();
|
||||
}
|
||||
|
||||
function buildWebNewsSkillPrompt(skillName) {
|
||||
return `请使用 ${skillName ?? 'web'} 技能:先搜索今天/最新相关的新闻与热点,优先一手来源和权威媒体,整理 3-5 条最相关结果,按时间或热度排序;然后给出中文摘要、关键信息、事件背景和来源链接。我的问题是:`;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
buildChatSkillPrompt,
|
||||
CHAT_SKILL_DEFINITIONS,
|
||||
filterChatSkills,
|
||||
extractAiderDevelopmentTask,
|
||||
isExcelAnalysisIntent,
|
||||
isPageDataDevIntent,
|
||||
isPageDataIntent,
|
||||
isPageGenerationIntent,
|
||||
isGenericPageGenerationRequest,
|
||||
mergeChatSkillPromptWithInput,
|
||||
} from './chat-skills.mjs';
|
||||
|
||||
test('filterChatSkills shows summarize and analyze without granted skills', () => {
|
||||
@@ -47,6 +49,49 @@ test('filterChatSkills shows service integration smoke when granted', () => {
|
||||
assert.ok(visible.some((item) => item.id === 'service-integration-smoke'));
|
||||
});
|
||||
|
||||
test('Aider development skill prefills instead of submitting an empty template', () => {
|
||||
const aider = CHAT_SKILL_DEFINITIONS.find((item) => item.id === 'aider-development');
|
||||
assert.equal(aider?.prefillOnly, true);
|
||||
});
|
||||
|
||||
test('mergeChatSkillPromptWithInput preserves an existing user task', () => {
|
||||
const prompt = buildChatSkillPrompt('aider-development', 'aider-development');
|
||||
const task = '帮我设计一个简单下单系统,不要支付,可以有简单后台';
|
||||
assert.equal(mergeChatSkillPromptWithInput(prompt, task), `${prompt}${task}`);
|
||||
assert.equal(mergeChatSkillPromptWithInput(prompt, `${prompt}${task}`), `${prompt}${task}`);
|
||||
});
|
||||
|
||||
test('extractAiderDevelopmentTask rejects the template-only submission', () => {
|
||||
const prompt = buildChatSkillPrompt('aider-development', 'aider-development');
|
||||
assert.equal(
|
||||
extractAiderDevelopmentTask({
|
||||
metadata: { displayText: prompt },
|
||||
content: [{ type: 'text', text: prompt }],
|
||||
}),
|
||||
'',
|
||||
);
|
||||
assert.equal(
|
||||
extractAiderDevelopmentTask({
|
||||
metadata: { displayText: `${prompt}修复页面` },
|
||||
content: [{ type: 'text', text: `${prompt}修复页面` }],
|
||||
}),
|
||||
'修复页面',
|
||||
);
|
||||
});
|
||||
|
||||
test('filterChatSkills only shows Aider development when granted', () => {
|
||||
const hidden = filterChatSkills(CHAT_SKILL_DEFINITIONS, {
|
||||
canPublish: false,
|
||||
grantedSkills: [],
|
||||
});
|
||||
assert.equal(hidden.some((item) => item.id === 'aider-development'), false);
|
||||
const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, {
|
||||
canPublish: false,
|
||||
grantedSkills: ['aider-development'],
|
||||
});
|
||||
assert.ok(visible.some((item) => item.id === 'aider-development'));
|
||||
});
|
||||
|
||||
test('filterChatSkills shows page-data-collect when granted without static publish', () => {
|
||||
const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, {
|
||||
canPublish: false,
|
||||
@@ -69,6 +114,8 @@ test('buildChatSkillPrompt includes skill name for platform skills', () => {
|
||||
assert.match(enhancedPrompt, /tkmind_search/);
|
||||
assert.match(enhancedPrompt, /web_search/);
|
||||
assert.match(buildChatSkillPrompt('service-integration-smoke'), /标准联调流程/);
|
||||
assert.match(buildChatSkillPrompt('aider-development'), /必须由 Aider code run/);
|
||||
assert.match(buildChatSkillPrompt('aider-development'), /禁止回退/);
|
||||
assert.match(buildChatSkillPrompt('product-campaign-page'), /商品宣传 \/ 活动页/);
|
||||
assert.match(buildChatSkillPrompt('image-generation'), /asset\.htmlSrc/);
|
||||
assert.match(buildChatSkillPrompt('image-generation'), /workspaceRelativePath 只用于文件操作/);
|
||||
@@ -161,6 +208,13 @@ test('isPageDataIntent treats storefront ordering plus product administration as
|
||||
);
|
||||
});
|
||||
|
||||
test('isPageDataIntent recognizes order systems with an admin backend', () => {
|
||||
assert.equal(
|
||||
isPageDataIntent('帮我设计一个简单下单系统,不要支付,可以有简单后台管理订单'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('isPageDataIntent matches implicit interactive sticky-note app requests', () => {
|
||||
const text = '帮我设计一个便签提醒,可以写便签提交,时间轴来显示';
|
||||
assert.equal(isPageDataIntent(text), true);
|
||||
|
||||
@@ -10,9 +10,31 @@ MEMIND_ANALYTICS_ENABLED=true
|
||||
MEMIND_ANALYTICS_URL=http://127.0.0.1:3100
|
||||
MEMIND_ANALYTICS_WEBSITE_ID=<website-id>
|
||||
MEMIND_ANALYTICS_ID_SECRET=<random-local-secret>
|
||||
MEMIND_ANALYTICS_IDENTITY_MODE=raw
|
||||
MEMIND_ANALYTICS_DOMAINS=127.0.0.1,localhost
|
||||
```
|
||||
|
||||
`MEMIND_ANALYTICS_IDENTITY_MODE=raw` sends the stable Memind user ID to an
|
||||
explicitly approved first-party analytics service. Omit it elsewhere to keep the
|
||||
pseudonymous default.
|
||||
|
||||
Create a second Umami Website for the Memind product shell. Keeping it separate
|
||||
prevents chat, MindSpace, and feedback navigation from inflating generated-page
|
||||
views. Do not create a Website per page or per user.
|
||||
|
||||
```dotenv
|
||||
MEMIND_PRODUCT_ANALYTICS_ENABLED=true
|
||||
MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID=<product-website-id>
|
||||
```
|
||||
|
||||
The product shell loads its tracker from the same-origin
|
||||
`/analytics/script.js` endpoint. It records one standard page view per SPA route
|
||||
transition, `product_click` for links/buttons, and 10/30-second route engagement
|
||||
events. Query strings are restricted to a small allowlist and click labels never
|
||||
copy arbitrary chat or generated-page text. Authenticated product events use the
|
||||
same configured identity as generated pages while remaining in the separate
|
||||
product Website.
|
||||
|
||||
## Rybbit (recommended for behavior analytics)
|
||||
|
||||
Rybbit runs on 105 as `https://rybbit.tkmind.cn`. Local Memind does not talk to
|
||||
|
||||
@@ -144,6 +144,28 @@ test('MindSearch never changes legacy web extension and is gated by capability/c
|
||||
assert.ok(extension.available_tools.includes('tkmind_research_cancel'));
|
||||
});
|
||||
|
||||
test('MindSearch uses the container-visible bundled MCP directory', () => {
|
||||
const policy = buildAgentExtensionPolicy(
|
||||
{
|
||||
...DEFAULT_USER_CAPABILITIES,
|
||||
search_external: true,
|
||||
},
|
||||
{
|
||||
sandboxMcp: {
|
||||
containerized: true,
|
||||
serverPath: '/opt/portal/mindspace-sandbox-mcp.mjs',
|
||||
},
|
||||
mindSearchConfig: {
|
||||
enabled: true,
|
||||
mode: 'assist',
|
||||
providers: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const extension = policy.extensionOverrides.find((ext) => ext.name === 'tkmind-search');
|
||||
assert.equal(extension.args[0], '/opt/portal/tkmind-search-mcp.mjs');
|
||||
});
|
||||
|
||||
test('SearXNG adapter normalizes provider results without requiring a live network', async () => {
|
||||
const result = await searchSearxng('goose', { endpoint: 'http://search.local', fetchImpl: async () => ({ ok: true, json: async () => ({ results: [{ title: 'Goose', url: 'https://example.com', content: 'snippet' }] }) }) });
|
||||
assert.deepEqual(result[0], { title: 'Goose', url: 'https://example.com', snippet: 'snippet', source: 'searxng', rank: 1 });
|
||||
|
||||
+68
-7
@@ -10,6 +10,9 @@ export function resolveMindSpaceAnalyticsConfig(env = process.env) {
|
||||
enabled: enabled && Boolean(websiteId) && Boolean(secret),
|
||||
websiteId,
|
||||
idSecret: secret,
|
||||
identityMode: String(env.MEMIND_ANALYTICS_IDENTITY_MODE ?? 'pseudonymous').trim().toLowerCase() === 'raw'
|
||||
? 'raw'
|
||||
: 'pseudonymous',
|
||||
analyticsUrl: String(env.MEMIND_ANALYTICS_URL ?? 'http://127.0.0.1:3100').trim() || 'http://127.0.0.1:3100',
|
||||
scriptPath: String(env.MEMIND_ANALYTICS_SCRIPT_PATH ?? '/analytics/script.js').trim() || '/analytics/script.js',
|
||||
hostPath: String(env.MEMIND_ANALYTICS_HOST_PATH ?? '/analytics').trim() || '/analytics',
|
||||
@@ -17,6 +20,23 @@ export function resolveMindSpaceAnalyticsConfig(env = process.env) {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProductAnalyticsConfig(
|
||||
env = process.env,
|
||||
baseConfig = resolveMindSpaceAnalyticsConfig(env),
|
||||
) {
|
||||
const websiteId = String(env.MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID ?? '').trim();
|
||||
const enabled = String(env.MEMIND_PRODUCT_ANALYTICS_ENABLED ?? '').toLowerCase() === 'true';
|
||||
return {
|
||||
enabled: enabled && Boolean(websiteId) && Boolean(baseConfig?.idSecret),
|
||||
websiteId,
|
||||
idSecret: String(baseConfig?.idSecret ?? '').trim(),
|
||||
identityMode: baseConfig?.identityMode === 'raw' ? 'raw' : 'pseudonymous',
|
||||
scriptPath: String(baseConfig?.scriptPath ?? '/analytics/script.js').trim() || '/analytics/script.js',
|
||||
hostPath: String(baseConfig?.hostPath ?? '/analytics').trim() || '/analytics',
|
||||
domains: String(baseConfig?.domains ?? '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function pseudonymizeAnalyticsId(value, secret) {
|
||||
const normalized = String(value ?? '').trim();
|
||||
const key = String(secret ?? '').trim();
|
||||
@@ -24,17 +44,53 @@ export function pseudonymizeAnalyticsId(value, secret) {
|
||||
return crypto.createHmac('sha256', key).update(normalized).digest('hex').slice(0, 32);
|
||||
}
|
||||
|
||||
export function resolveAnalyticsIdentity(value, config = {}) {
|
||||
const normalized = String(value ?? '').trim().replace(/[\r\n\t]+/g, '').slice(0, 128);
|
||||
if (!normalized) return '';
|
||||
return config?.identityMode === 'raw'
|
||||
? normalized
|
||||
: pseudonymizeAnalyticsId(normalized, config?.idSecret);
|
||||
}
|
||||
|
||||
export function resolveAnalyticsOwnerSegment(user = {}) {
|
||||
if (user?.role === 'admin') return 'admin';
|
||||
const plan = String(user?.planType ?? user?.plan_type ?? 'free').trim().toLowerCase();
|
||||
return `plan:${plan || 'free'}`;
|
||||
}
|
||||
|
||||
export function resolveAnalyticsPlan(user = {}) {
|
||||
if (user?.role === 'admin') return 'admin';
|
||||
return String(user?.planType ?? user?.plan_type ?? 'free').trim().toLowerCase() || 'free';
|
||||
}
|
||||
|
||||
export function resolveAnalyticsOwnerLabel(user = {}) {
|
||||
const label = String(user?.displayName ?? user?.display_name ?? user?.username ?? '').trim();
|
||||
return label.replace(/[\r\n\t]+/g, ' ').slice(0, 80) || '未命名用户';
|
||||
}
|
||||
|
||||
export function buildProductAnalyticsContext({ config, user = null } = {}) {
|
||||
if (!config?.enabled || !config.websiteId) return { enabled: false };
|
||||
const distinctId = user?.id ? resolveAnalyticsIdentity(user.id, config) : '';
|
||||
return {
|
||||
enabled: true,
|
||||
websiteId: config.websiteId,
|
||||
scriptPath: config.scriptPath || '/analytics/script.js',
|
||||
hostPath: config.hostPath || '/analytics',
|
||||
domains: config.domains || '',
|
||||
identity: distinctId
|
||||
? {
|
||||
distinctId,
|
||||
username: resolveAnalyticsOwnerLabel(user),
|
||||
ownerSegment: resolveAnalyticsOwnerSegment(user),
|
||||
planType: resolveAnalyticsPlan(user),
|
||||
channel: 'h5',
|
||||
surface: 'product',
|
||||
identityMode: config.identityMode === 'raw' ? 'raw' : 'pseudonymous',
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function sendMindSpaceAnalyticsEvent({
|
||||
config,
|
||||
eventName,
|
||||
@@ -45,14 +101,17 @@ export function sendMindSpaceAnalyticsEvent({
|
||||
channel = 'h5',
|
||||
ownerSegment = 'unknown',
|
||||
ownerLabel = '未命名用户',
|
||||
planType = 'unknown',
|
||||
generatedAt = '',
|
||||
url = '',
|
||||
} = {}) {
|
||||
if (!config?.enabled || !config.websiteId || !config.idSecret || !eventName) return Promise.resolve(false);
|
||||
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
|
||||
const owner = resolveAnalyticsIdentity(ownerId, config);
|
||||
if (!owner) return Promise.resolve(false);
|
||||
const endpoint = `${String(config.analyticsUrl || 'http://127.0.0.1:3100').replace(/\/$/, '')}/api/send`;
|
||||
const payload = {
|
||||
website: config.websiteId,
|
||||
id: owner,
|
||||
hostname: '127.0.0.1',
|
||||
url: url || '/',
|
||||
name: String(eventName),
|
||||
@@ -63,7 +122,10 @@ export function sendMindSpaceAnalyticsEvent({
|
||||
agent_run_id: String(agentRunId || ''),
|
||||
channel,
|
||||
owner_segment: String(ownerSegment || 'unknown'),
|
||||
plan_type: String(planType || 'unknown'),
|
||||
owner_label: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }),
|
||||
generated_at: String(generatedAt || ''),
|
||||
identity_mode: config.identityMode === 'raw' ? 'raw' : 'pseudonymous',
|
||||
},
|
||||
};
|
||||
return fetch(endpoint, {
|
||||
@@ -87,18 +149,17 @@ export function injectMindSpaceAnalytics(html, {
|
||||
publicationId = '',
|
||||
ownerSegment = 'unknown',
|
||||
ownerLabel = '未命名用户',
|
||||
planType = 'unknown',
|
||||
generatedAt = '',
|
||||
channel = 'h5',
|
||||
config = resolveMindSpaceAnalyticsConfig(),
|
||||
} = {}) {
|
||||
const source = String(html ?? '');
|
||||
if (!config?.enabled || !config.websiteId || !/^\s*(<!doctype html|<html\b)/i.test(source)) return source;
|
||||
if (source.includes(ANALYTICS_MARKER)) return source;
|
||||
const owner = pseudonymizeAnalyticsId(ownerId, config.idSecret);
|
||||
const owner = resolveAnalyticsIdentity(ownerId, config);
|
||||
if (!owner) return source;
|
||||
// The stable pseudonym remains the Umami identity key. The readable username
|
||||
// is an explicitly enabled analytics property so operators can recognize the
|
||||
// Memind user, while the current public page URL is resolved in the browser.
|
||||
const metadata = { owner_id: owner, username: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }), owner_segment: String(ownerSegment || 'unknown'), page_id: String(pageId || ''), publication_id: String(publicationId || ''), channel };
|
||||
const metadata = { owner_id: owner, username: resolveAnalyticsOwnerLabel({ displayName: ownerLabel }), owner_segment: String(ownerSegment || 'unknown'), plan_type: String(planType || 'unknown'), page_id: String(pageId || ''), publication_id: String(publicationId || ''), generated_at: String(generatedAt || ''), identity_mode: config.identityMode === 'raw' ? 'raw' : 'pseudonymous', channel, surface: 'generated_page' };
|
||||
const attrs = [
|
||||
ANALYTICS_MARKER,
|
||||
`data-website-id="${config.websiteId.replaceAll('"', '"')}"`,
|
||||
@@ -106,7 +167,7 @@ export function injectMindSpaceAnalytics(html, {
|
||||
`data-host-url="${config.hostPath}"`,
|
||||
];
|
||||
if (config.domains) attrs.push(`data-domains="${config.domains.replaceAll('"', '"')}"`);
|
||||
const block = `<script ${attrs.join(' ')} defer src="${config.scriptPath}"></script><script ${ANALYTICS_MARKER}>(function(){var d=${jsonForInlineScript(metadata)},seen={};function t(n,x){if(!window.umami||typeof window.umami.track!=='function')return;var p=Object.assign({},d,{page_url:location.href,page_title:document.title},x||{});window.umami.track(n,p);}function identify(){if(!window.umami||typeof window.umami.identify!=='function')return;window.umami.identify(d.owner_id,{username:d.username,memind_page_url:location.href,owner_segment:d.owner_segment,channel:d.channel});}function pageview(){if(!window.umami||typeof window.umami.track!=='function')return;window.umami.track();}function once(n,x){if(seen[n])return;seen[n]=1;t(n,x);}function ready(){identify();pageview();document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('a,button,[role="button"],[data-umami-event]'):null;if(!el)return;var custom=el.getAttribute('data-umami-event');var href=el.tagName==='A'?el.getAttribute('href')||'':'';t(custom||'page_click',{element:el.tagName.toLowerCase(),element_id:el.id||'',event_label:(custom||el.getAttribute('aria-label')||'').slice(0,100),target_url:href.slice(0,500)});},{passive:true});document.addEventListener('submit',function(e){var form=e.target;t('page_form_submit',{form_id:form&&form.id||'',form_action:form&&form.getAttribute('action')||''});},{passive:true});var marks=[25,50,75,90];function scroll(){var h=document.documentElement.scrollHeight-window.innerHeight;if(h<=0){once('page_scroll_100');return;}var pct=Math.round(window.scrollY/h*100);marks.forEach(function(m){if(pct>=m)once('page_scroll_'+m);});}window.addEventListener('scroll',scroll,{passive:true});setTimeout(function(){once('page_engaged_10s');},10000);setTimeout(function(){once('page_engaged_30s');},30000);scroll();}if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',ready,{once:true});}else{ready();}})();</script>`;
|
||||
const block = `<script ${attrs.join(' ')} defer src="${config.scriptPath}"></script><script ${ANALYTICS_MARKER}>(function(){var d=${jsonForInlineScript(metadata)},seen={};function safeTarget(h){if(!h)return'';try{var u=new URL(h,location.href);return u.origin===location.origin?u.pathname:'external:'+u.hostname;}catch{return'';}}function t(n,x){if(!window.umami||typeof window.umami.track!=='function')return;var p=Object.assign({},d,{page_url:location.href,page_route:location.pathname,page_title:document.title},x||{});window.umami.track(n,p);}function identify(){if(!window.umami||typeof window.umami.identify!=='function')return;window.umami.identify(d.owner_id,{username:d.username,memind_page_url:location.href,owner_segment:d.owner_segment,plan_type:d.plan_type,channel:d.channel,surface:d.surface,identity_mode:d.identity_mode});}function pageview(){if(!window.umami||typeof window.umami.track!=='function')return;window.umami.track();}function once(n,x){if(seen[n])return;seen[n]=1;t(n,x);}function ready(){identify();pageview();document.addEventListener('click',function(e){var el=e.target&&e.target.closest?e.target.closest('a,button,[role="button"],[data-umami-event]'):null;if(!el)return;var custom=el.getAttribute('data-umami-event');var href=el.tagName==='A'?el.getAttribute('href')||'':'';t(custom||'page_click',{element:el.tagName.toLowerCase(),element_id:el.id||'',event_label:(custom||el.getAttribute('aria-label')||'').slice(0,100),target_path:safeTarget(href)});},{passive:true});document.addEventListener('submit',function(e){var form=e.target;t('page_form_submit',{form_id:form&&form.id||'',form_action:safeTarget(form&&form.getAttribute('action')||'')});},{passive:true});var marks=[25,50,75,90];function scroll(){var h=document.documentElement.scrollHeight-window.innerHeight;if(h<=0){once('page_scroll_100');return;}var pct=Math.round(window.scrollY/h*100);marks.forEach(function(m){if(pct>=m)once('page_scroll_'+m);});}window.addEventListener('scroll',scroll,{passive:true});setTimeout(function(){once('page_engaged_10s');},10000);setTimeout(function(){once('page_engaged_30s');},30000);scroll();}if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',ready,{once:true});}else{ready();}})();</script>`;
|
||||
if (/<\/head>/i.test(source)) return source.replace(/<\/head>/i, `${block}</head>`);
|
||||
return source.replace(/<body\b/i, `${block}<body`);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,62 @@ import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
|
||||
import {
|
||||
buildProductAnalyticsContext,
|
||||
injectMindSpaceAnalytics,
|
||||
pseudonymizeAnalyticsId,
|
||||
resolveAnalyticsIdentity,
|
||||
resolveAnalyticsPlan,
|
||||
resolveAnalyticsOwnerSegment,
|
||||
resolveAnalyticsOwnerLabel,
|
||||
resolveMindSpaceAnalyticsConfig,
|
||||
resolveProductAnalyticsConfig,
|
||||
sendMindSpaceAnalyticsEvent,
|
||||
} from './mindspace-analytics.mjs';
|
||||
|
||||
test('product analytics uses a separate website and the existing pseudonymization secret', () => {
|
||||
const base = resolveMindSpaceAnalyticsConfig({
|
||||
MEMIND_ANALYTICS_ENABLED: 'true',
|
||||
MEMIND_ANALYTICS_WEBSITE_ID: 'generated-pages',
|
||||
MEMIND_ANALYTICS_ID_SECRET: 'local-secret',
|
||||
});
|
||||
const config = resolveProductAnalyticsConfig({
|
||||
MEMIND_PRODUCT_ANALYTICS_ENABLED: 'true',
|
||||
MEMIND_PRODUCT_ANALYTICS_WEBSITE_ID: 'product-shell',
|
||||
}, base);
|
||||
assert.equal(config.enabled, true);
|
||||
assert.equal(config.websiteId, 'product-shell');
|
||||
assert.notEqual(config.websiteId, base.websiteId);
|
||||
const context = buildProductAnalyticsContext({
|
||||
config,
|
||||
user: { id: 'user-123', displayName: '张三', role: 'user', planType: 'pro' },
|
||||
});
|
||||
assert.deepEqual(context.identity, {
|
||||
distinctId: pseudonymizeAnalyticsId('user-123', 'local-secret'),
|
||||
username: '张三',
|
||||
ownerSegment: 'plan:pro',
|
||||
planType: 'pro',
|
||||
channel: 'h5',
|
||||
surface: 'product',
|
||||
identityMode: 'pseudonymous',
|
||||
});
|
||||
});
|
||||
|
||||
test('product analytics context stays public-safe before login', () => {
|
||||
const context = buildProductAnalyticsContext({
|
||||
config: {
|
||||
enabled: true,
|
||||
websiteId: 'product-shell',
|
||||
idSecret: 'local-secret',
|
||||
scriptPath: '/analytics/script.js',
|
||||
hostPath: '/analytics',
|
||||
},
|
||||
});
|
||||
assert.equal(context.enabled, true);
|
||||
assert.equal(context.websiteId, 'product-shell');
|
||||
assert.equal(context.identity, null);
|
||||
assert.equal('idSecret' in context, false);
|
||||
});
|
||||
|
||||
test('analytics config is disabled unless explicitly enabled and configured', () => {
|
||||
assert.equal(resolveMindSpaceAnalyticsConfig({ MEMIND_ANALYTICS_ENABLED: 'true' }).enabled, false);
|
||||
assert.equal(resolveMindSpaceAnalyticsConfig({
|
||||
@@ -35,10 +83,18 @@ test('owner ids are stable pseudonyms and never expose the source id', () => {
|
||||
assert.notEqual(first, pseudonymizeAnalyticsId('user-456', 'secret'));
|
||||
});
|
||||
|
||||
test('approved raw identity mode preserves the stable Memind user id', () => {
|
||||
const config = { identityMode: 'raw', idSecret: 'unused' };
|
||||
assert.equal(resolveAnalyticsIdentity('user-123', config), 'user-123');
|
||||
assert.equal(resolveAnalyticsIdentity(' user-123\n', config), 'user-123');
|
||||
});
|
||||
|
||||
test('owner segments come from server-side Memind user profile data', () => {
|
||||
assert.equal(resolveAnalyticsOwnerSegment({ role: 'admin' }), 'admin');
|
||||
assert.equal(resolveAnalyticsOwnerSegment({ role: 'user', planType: 'pro' }), 'plan:pro');
|
||||
assert.equal(resolveAnalyticsOwnerSegment({ role: 'user' }), 'plan:free');
|
||||
assert.equal(resolveAnalyticsPlan({ role: 'user', planType: 'pro' }), 'pro');
|
||||
assert.equal(resolveAnalyticsPlan({ role: 'user' }), 'free');
|
||||
});
|
||||
|
||||
test('owner labels are readable but bounded and stripped of control characters', () => {
|
||||
@@ -53,6 +109,8 @@ test('injects one local same-origin tracker with page dimensions', () => {
|
||||
ownerLabel: '张三',
|
||||
pageId: 'page-1',
|
||||
publicationId: 'pub-1',
|
||||
planType: 'pro',
|
||||
generatedAt: '2026-07-20T01:02:03.000Z',
|
||||
config: {
|
||||
enabled: true,
|
||||
websiteId: 'local-website',
|
||||
@@ -60,12 +118,13 @@ test('injects one local same-origin tracker with page dimensions', () => {
|
||||
scriptPath: '/analytics/script.js',
|
||||
hostPath: '/analytics',
|
||||
domains: '127.0.0.1,localhost',
|
||||
identityMode: 'raw',
|
||||
},
|
||||
});
|
||||
assert.match(out, /src="\/analytics\/script\.js"/);
|
||||
assert.match(out, /data-host-url="\/analytics"/);
|
||||
assert.match(out, /data-auto-track="false"/);
|
||||
assert.match(out, /window\.umami\.identify\(d\.owner_id,\{username:d\.username,memind_page_url:location\.href,owner_segment:d\.owner_segment,channel:d\.channel\}\)/);
|
||||
assert.match(out, /window\.umami\.identify\(d\.owner_id,\{username:d\.username,memind_page_url:location\.href,owner_segment:d\.owner_segment,plan_type:d\.plan_type,channel:d\.channel,surface:d\.surface,identity_mode:d\.identity_mode\}\)/);
|
||||
assert.match(out, /function pageview\(\).*window\.umami\.track\(\)/);
|
||||
assert.ok(out.indexOf('identify();pageview();') > 0);
|
||||
assert.doesNotMatch(out, /t\('page_view'\)/);
|
||||
@@ -74,10 +133,13 @@ test('injects one local same-origin tracker with page dimensions', () => {
|
||||
assert.doesNotMatch(out, /owner_label/);
|
||||
assert.match(out, /"username":"张三"/);
|
||||
assert.match(out, /page_click/);
|
||||
assert.match(out, /target_path/);
|
||||
assert.doesNotMatch(out, /target_url/);
|
||||
assert.match(out, /page_form_submit/);
|
||||
assert.match(out, /page_scroll_/);
|
||||
assert.match(out, /page_engaged_10s/);
|
||||
assert.doesNotMatch(out, /user-123/);
|
||||
assert.match(out, /"owner_id":"user-123"/);
|
||||
assert.match(out, /"generated_at":"2026-07-20T01:02:03.000Z"/);
|
||||
assert.equal(injectMindSpaceAnalytics(out, { ownerId: 'user-123', config: { enabled: true, websiteId: 'local-website', idSecret: 'secret' } }), out);
|
||||
});
|
||||
|
||||
@@ -125,6 +187,9 @@ test('identifies the pseudonymous owner before sending a standard page view', ()
|
||||
memind_page_url: 'https://m.tkmind.cn/MindSpace/demo/public/page.html',
|
||||
owner_segment: 'plan:pro',
|
||||
channel: 'h5',
|
||||
surface: 'generated_page',
|
||||
plan_type: 'unknown',
|
||||
identity_mode: 'pseudonymous',
|
||||
}],
|
||||
['track'],
|
||||
]);
|
||||
@@ -140,3 +205,50 @@ test('does not alter non-full-html or disabled pages', () => {
|
||||
test('analytics event sender is fail-open when analytics is disabled', async () => {
|
||||
assert.equal(await sendMindSpaceAnalyticsEvent({ eventName: 'page_generated', ownerId: 'u', config: { enabled: false } }), false);
|
||||
});
|
||||
|
||||
test('generation events are attributed to raw identity and include analysis dimensions', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let requestBody;
|
||||
globalThis.fetch = async (_url, init) => {
|
||||
requestBody = JSON.parse(init.body);
|
||||
return { ok: true };
|
||||
};
|
||||
try {
|
||||
assert.equal(await sendMindSpaceAnalyticsEvent({
|
||||
config: {
|
||||
enabled: true,
|
||||
websiteId: 'generated-pages',
|
||||
idSecret: 'secret',
|
||||
identityMode: 'raw',
|
||||
analyticsUrl: 'http://127.0.0.1:3100',
|
||||
},
|
||||
eventName: 'page_generated',
|
||||
ownerId: 'user-123',
|
||||
ownerLabel: '张三',
|
||||
ownerSegment: 'plan:pro',
|
||||
planType: 'pro',
|
||||
pageId: 'page-123',
|
||||
generatedAt: '2026-07-20T01:02:03.000Z',
|
||||
}), true);
|
||||
assert.equal(requestBody.payload.id, 'user-123');
|
||||
assert.deepEqual(requestBody.payload.data, expectPayload({
|
||||
owner_id: 'user-123',
|
||||
owner_label: '张三',
|
||||
owner_segment: 'plan:pro',
|
||||
plan_type: 'pro',
|
||||
page_id: 'page-123',
|
||||
generated_at: '2026-07-20T01:02:03.000Z',
|
||||
identity_mode: 'raw',
|
||||
}));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
function expectPayload(expected) {
|
||||
return Object.assign({
|
||||
publication_id: '',
|
||||
agent_run_id: '',
|
||||
channel: 'h5',
|
||||
}, expected);
|
||||
}
|
||||
|
||||
@@ -150,6 +150,9 @@ export async function resolveMindSpacePageDataContext({
|
||||
pageId: page.id,
|
||||
accessMode: page.publicationAccessMode ?? null,
|
||||
publicationId: page.currentPublishId ?? page.current_publish_id ?? null,
|
||||
...(page.createdAt
|
||||
? { generatedAt: new Date(page.createdAt).toISOString() }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ test('resolveMindSpacePageDataContext falls back to the MindSpace page service',
|
||||
id: 'remote-page',
|
||||
publicationAccessMode: 'internal',
|
||||
currentPublishId: 'remote-publish',
|
||||
createdAt: '2026-07-20T12:34:56.000Z',
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -108,5 +109,6 @@ test('resolveMindSpacePageDataContext falls back to the MindSpace page service',
|
||||
pageId: 'remote-page',
|
||||
accessMode: 'internal',
|
||||
publicationId: 'remote-publish',
|
||||
generatedAt: '2026-07-20T12:34:56.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
+14
-1
@@ -13,7 +13,12 @@ import { createWikiAuth } from './wiki-auth.mjs';
|
||||
import { isLocalDevHostname } from './scripts/local-test-config.mjs';
|
||||
import { PUBLISH_ROOT_DIR } from './user-publish.mjs';
|
||||
import { startWorkspaceThumbnailWatcher } from './mindspace-workspace-thumbnails.mjs';
|
||||
import { resolveMindSpaceAnalyticsConfig, sendMindSpaceAnalyticsEvent } from './mindspace-analytics.mjs';
|
||||
import {
|
||||
buildProductAnalyticsContext,
|
||||
resolveMindSpaceAnalyticsConfig,
|
||||
resolveProductAnalyticsConfig,
|
||||
sendMindSpaceAnalyticsEvent,
|
||||
} from './mindspace-analytics.mjs';
|
||||
import { resolveMindSpaceRybbitConfig } from './mindspace-rybbit.mjs';
|
||||
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
|
||||
import { attachRequestId, sendData, sendError } from './api-response.mjs';
|
||||
@@ -689,6 +694,8 @@ api.use(createPortalApiAuthMiddleware({
|
||||
getLegacySessionToken: legacySessionToken,
|
||||
isPageDataPublicPath,
|
||||
isLegacyPageDataApiPath,
|
||||
isProductAnalyticsPublicPath: (requestPath, method) =>
|
||||
method === 'GET' && requestPath === '/analytics/context',
|
||||
accessPolicyMode: portalAccessPolicyMode,
|
||||
accessEnforcementConfig: portalAccessEnforcementConfig,
|
||||
accessShadowReporter: portalAccessShadowReporter,
|
||||
@@ -699,6 +706,12 @@ attachPortalImageMakeRuntimeConfigRoute(api, {
|
||||
getImageMakeAdminConfigService: () => imageMakeAdminConfigService,
|
||||
});
|
||||
|
||||
api.get('/analytics/context', (req, res) => {
|
||||
const config = resolveProductAnalyticsConfig(process.env, mindSpaceAnalyticsConfig);
|
||||
res.set('Cache-Control', 'private, no-store');
|
||||
res.json(buildProductAnalyticsContext({ config, user: req.currentUser ?? null }));
|
||||
});
|
||||
|
||||
attachAsrRoutes(api, { sendError, sendData });
|
||||
attachMindSpaceImageGenerationRoutes(api, {
|
||||
getService: () => mindSpaceImageGeneration,
|
||||
|
||||
@@ -21,6 +21,7 @@ export function createPortalApiAuthMiddleware({
|
||||
getLegacySessionToken = () => null,
|
||||
isPageDataPublicPath = () => false,
|
||||
isLegacyPageDataApiPath = () => false,
|
||||
isProductAnalyticsPublicPath = () => false,
|
||||
accessPolicyMode = PORTAL_ACCESS_POLICY_MODE.OFF,
|
||||
accessEnforcementConfig = Object.freeze({
|
||||
masterEnabled: false,
|
||||
@@ -99,13 +100,24 @@ export function createPortalApiAuthMiddleware({
|
||||
|
||||
const plazaPublic = isPortalPlazaOptionalUserPath(req.path, req.method);
|
||||
const pageDataPublic = isPageDataPublicPath(req.path, req.method);
|
||||
const productAnalyticsPublic = isProductAnalyticsPublicPath(
|
||||
req.path,
|
||||
req.method,
|
||||
);
|
||||
// The retired namespace must reach its explicit 410 route instead of
|
||||
// being converted into a misleading global 401/403 response.
|
||||
const legacyPageDataApi = isLegacyPageDataApiPath(req.path);
|
||||
|
||||
if (userAuth && tkmindProxy) {
|
||||
if (req.userSessionError) {
|
||||
if (plazaPublic || pageDataPublic || legacyPageDataApi) return next();
|
||||
if (
|
||||
plazaPublic ||
|
||||
pageDataPublic ||
|
||||
legacyPageDataApi ||
|
||||
productAnalyticsPublic
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
return res.status(503).json({ message: '用户认证服务不可用,请稍后重试' });
|
||||
}
|
||||
try {
|
||||
@@ -113,7 +125,14 @@ export function createPortalApiAuthMiddleware({
|
||||
const me = await userAuth.getMe(req.userToken);
|
||||
if (me) req.currentUser = me;
|
||||
}
|
||||
if (plazaPublic || pageDataPublic || legacyPageDataApi) return next();
|
||||
if (
|
||||
plazaPublic ||
|
||||
pageDataPublic ||
|
||||
legacyPageDataApi ||
|
||||
productAnalyticsPublic
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
if (!req.userSession) {
|
||||
return res.status(401).json({ message: '未授权,请重新登录' });
|
||||
}
|
||||
@@ -130,6 +149,7 @@ export function createPortalApiAuthMiddleware({
|
||||
}
|
||||
}
|
||||
|
||||
if (productAnalyticsPublic) return next();
|
||||
if (legacyAuth?.verify(getLegacySessionToken(req))) return next();
|
||||
return res.status(401).json({ message: '未授权,请重新登录' });
|
||||
};
|
||||
|
||||
@@ -57,6 +57,8 @@ function createMiddleware(overrides = {}) {
|
||||
return createPortalApiAuthMiddleware({
|
||||
isPageDataPublicPath,
|
||||
isLegacyPageDataApiPath,
|
||||
isProductAnalyticsPublicPath: (path, method) =>
|
||||
method === 'GET' && path === '/analytics/context',
|
||||
logger: createLogger(),
|
||||
...overrides,
|
||||
});
|
||||
@@ -143,6 +145,36 @@ test('multi-user public routes preserve optional user hydration', async () => {
|
||||
assert.equal(getMeCalls, 1);
|
||||
});
|
||||
|
||||
test('product analytics context preserves optional user hydration', async () => {
|
||||
let getMeCalls = 0;
|
||||
const user = { id: 'analytics-user' };
|
||||
const middleware = createMiddleware({
|
||||
getUserAuth: () => ({
|
||||
async getMe(token) {
|
||||
getMeCalls += 1;
|
||||
assert.equal(token, 'user-token');
|
||||
return user;
|
||||
},
|
||||
}),
|
||||
getTkmindProxy: () => ({}),
|
||||
});
|
||||
|
||||
const anonymous = await invoke(middleware, {
|
||||
path: '/analytics/context',
|
||||
userSession: null,
|
||||
});
|
||||
assert.equal(anonymous.nextCalls, 1);
|
||||
assert.equal(anonymous.req.currentUser, undefined);
|
||||
|
||||
const authenticated = await invoke(middleware, {
|
||||
path: '/analytics/context',
|
||||
userSession: { id: 'session-analytics' },
|
||||
});
|
||||
assert.equal(authenticated.nextCalls, 1);
|
||||
assert.equal(authenticated.req.currentUser, user);
|
||||
assert.equal(getMeCalls, 1);
|
||||
});
|
||||
|
||||
test('multi-user private routes preserve login, expiry, and double-check behavior', async () => {
|
||||
let currentUser = { id: 'user-1' };
|
||||
let getMeCalls = 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
resolveAnalyticsOwnerLabel,
|
||||
resolveAnalyticsOwnerSegment,
|
||||
resolveAnalyticsPlan,
|
||||
sendMindSpaceAnalyticsEvent,
|
||||
} from '../mindspace-analytics.mjs';
|
||||
import {
|
||||
@@ -43,6 +44,7 @@ export async function bootstrapPortalIntegrationServices({
|
||||
resolveAnalyticsOwnerSegment,
|
||||
resolveAnalyticsOwnerLabelFn =
|
||||
resolveAnalyticsOwnerLabel,
|
||||
resolveAnalyticsPlanFn = resolveAnalyticsPlan,
|
||||
sendMindSpaceAnalyticsEventFn =
|
||||
sendMindSpaceAnalyticsEvent,
|
||||
sendMindSpaceRybbitEventFn =
|
||||
@@ -155,6 +157,8 @@ export async function bootstrapPortalIntegrationServices({
|
||||
ownerLabel: resolveAnalyticsOwnerLabelFn(
|
||||
pageOwner,
|
||||
),
|
||||
planType: resolveAnalyticsPlanFn(pageOwner),
|
||||
generatedAt: new Date().toISOString(),
|
||||
pageId: artifact.relativePath,
|
||||
publicationId: sessionId,
|
||||
agentRunId: sessionId,
|
||||
|
||||
@@ -148,6 +148,10 @@ function createSetup(overrides = {}) {
|
||||
calls.push(['owner-label', user]);
|
||||
return 'label';
|
||||
},
|
||||
resolveAnalyticsPlanFn(user) {
|
||||
calls.push(['plan-type', user]);
|
||||
return 'pro';
|
||||
},
|
||||
sendMindSpaceAnalyticsEventFn(event) {
|
||||
calls.push(['analytics', event]);
|
||||
return Promise.resolve();
|
||||
@@ -349,12 +353,18 @@ test('preserves generated-page analytics projection', async () => {
|
||||
ownerId: 'user-1',
|
||||
ownerSegment: 'segment',
|
||||
ownerLabel: 'label',
|
||||
planType: 'pro',
|
||||
generatedAt: analyticsCall[1].generatedAt,
|
||||
pageId: 'public/page.html',
|
||||
publicationId: 'session-1',
|
||||
agentRunId: 'session-1',
|
||||
channel: 'wechat_mp',
|
||||
url: 'https://example/page',
|
||||
});
|
||||
assert.match(
|
||||
analyticsCall[1].generatedAt,
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
|
||||
);
|
||||
const rybbitCall = setup.calls.find(
|
||||
([name]) => name === 'rybbit',
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
resolveAnalyticsOwnerLabel,
|
||||
resolveAnalyticsOwnerSegment,
|
||||
resolveAnalyticsPlan,
|
||||
sendMindSpaceAnalyticsEvent,
|
||||
} from '../mindspace-analytics.mjs';
|
||||
import {
|
||||
@@ -96,6 +97,7 @@ export function attachPortalSessionRoutes(
|
||||
resolveAnalyticsOwnerSegment,
|
||||
resolveAnalyticsOwnerLabelFn =
|
||||
resolveAnalyticsOwnerLabel,
|
||||
resolveAnalyticsPlanFn = resolveAnalyticsPlan,
|
||||
syncPublicHtmlAfterFinishFn = syncPublicHtmlAfterFinish,
|
||||
resolveMindSpaceRuntimeConfigFn =
|
||||
resolveMindSpaceRuntimeConfig,
|
||||
@@ -432,6 +434,8 @@ export function attachPortalSessionRoutes(
|
||||
resolveAnalyticsOwnerSegmentFn(req.currentUser),
|
||||
ownerLabel:
|
||||
resolveAnalyticsOwnerLabelFn(req.currentUser),
|
||||
planType: resolveAnalyticsPlanFn(req.currentUser),
|
||||
generatedAt: new Date().toISOString(),
|
||||
pageId: relativePath,
|
||||
publicationId: sessionId,
|
||||
agentRunId: sessionId,
|
||||
|
||||
@@ -165,6 +165,7 @@ function createDependencies(overrides = {}) {
|
||||
sendMindSpaceAnalyticsEventFn: async () => {},
|
||||
resolveAnalyticsOwnerSegmentFn: () => 'segment',
|
||||
resolveAnalyticsOwnerLabelFn: () => 'label',
|
||||
resolveAnalyticsPlanFn: () => 'pro',
|
||||
syncPublicHtmlAfterFinishFn: async () => ({
|
||||
publicHtmlRelativePaths: [],
|
||||
}),
|
||||
@@ -524,6 +525,11 @@ test('stream hook uses current session id for contracts and analytics', async ()
|
||||
assert.equal(calls[1].kind, 'materialize');
|
||||
assert.equal(calls[2].input.publicationId, 'session-1');
|
||||
assert.equal(calls[2].input.agentRunId, 'session-1');
|
||||
assert.equal(calls[2].input.planType, 'pro');
|
||||
assert.match(
|
||||
calls[2].input.generatedAt,
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Finish hook preserves refresh, sync, delivery readiness, memory, and lock lifecycle', async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
injectMindSpaceAnalytics,
|
||||
resolveAnalyticsOwnerLabel,
|
||||
resolveAnalyticsOwnerSegment,
|
||||
resolveAnalyticsPlan,
|
||||
} from '../mindspace-analytics.mjs';
|
||||
import {
|
||||
injectMindSpaceRybbit,
|
||||
@@ -303,6 +304,8 @@ export function createPortalWorkspacePublicationDelivery({
|
||||
ownerLabel: resolveAnalyticsOwnerLabel(
|
||||
pageOwner ?? {},
|
||||
),
|
||||
planType: resolveAnalyticsPlan(pageOwner ?? {}),
|
||||
generatedAt: pageDataContext?.generatedAt ?? '',
|
||||
pageId:
|
||||
pageDataContext?.pageId ?? '',
|
||||
publicationId:
|
||||
|
||||
@@ -13,6 +13,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
|
||||
export const EXCEL_ANALYST_SKILL_NAME = 'excel-analyst';
|
||||
export const IMAGE_GENERATION_SKILL_NAME = 'image-generation';
|
||||
export const AIDER_DEVELOPMENT_SKILL_NAME = 'aider-development';
|
||||
|
||||
export const DEFAULT_USER_SKILLS = {
|
||||
web: true,
|
||||
@@ -28,6 +29,7 @@ export const DEFAULT_USER_SKILLS = {
|
||||
'long-image-download': true,
|
||||
[IMAGE_GENERATION_SKILL_NAME]: true,
|
||||
[PAGE_DATA_COLLECT_SKILL_NAME]: true,
|
||||
[AIDER_DEVELOPMENT_SKILL_NAME]: false,
|
||||
[PUBLISH_SKILL_NAME]: false,
|
||||
};
|
||||
|
||||
@@ -42,6 +44,7 @@ export const USER_ROLE_SKILL_PRESETS = {
|
||||
},
|
||||
developer: {
|
||||
...DEFAULT_USER_SKILLS,
|
||||
[AIDER_DEVELOPMENT_SKILL_NAME]: true,
|
||||
git: true,
|
||||
'diff-viewer': true,
|
||||
'code-playground': true,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
listPlatformSkillCatalog,
|
||||
normalizeSkillPatch,
|
||||
resolveSkillMap,
|
||||
USER_ROLE_SKILL_PRESETS,
|
||||
} from './skills-registry.mjs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -25,6 +26,9 @@ test('lists static-page-publish in platform catalog', () => {
|
||||
assert.ok(imageGeneration.manifest.trigger.keywords.includes('AI配图'));
|
||||
assert.equal(imageGeneration.manifest.router.promptKey, 'image-generation');
|
||||
assert.ok(catalog.some((item) => item.name === 'excel-analyst'));
|
||||
const aiderDevelopment = catalog.find((item) => item.name === 'aider-development');
|
||||
assert.ok(aiderDevelopment);
|
||||
assert.deepEqual(aiderDevelopment.executors, ['aider']);
|
||||
});
|
||||
|
||||
test('granting page-data-collect enables static_publish capability', () => {
|
||||
@@ -73,4 +77,6 @@ test('DEFAULT_USER_SKILLS enables common platform skills', () => {
|
||||
assert.equal(DEFAULT_USER_SKILLS['page-data-collect'], true);
|
||||
assert.equal(DEFAULT_USER_SKILLS['static-page-publish'], false);
|
||||
assert.equal(DEFAULT_USER_SKILLS['excel-analyst'], false);
|
||||
assert.equal(DEFAULT_USER_SKILLS['aider-development'], false);
|
||||
assert.equal(USER_ROLE_SKILL_PRESETS.developer['aider-development'], true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: aider-development
|
||||
description: Force development, debugging, refactoring, and code validation tasks in the current MindSpace workspace to run through the Aider executor. Use only when the user explicitly selects the Aider 开发 skill or asks to continue a task already started with that selected skill; fail closed when Aider or the code-run gateway is unavailable.
|
||||
---
|
||||
|
||||
# Aider 开发
|
||||
|
||||
必须通过平台注入的 Aider code run 执行当前工作区内的开发任务。不得切换到 Goose、OpenHands 或普通聊天执行器。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 先检查任务相关文件、现有实现和验证入口,限制修改范围。
|
||||
2. 实现用户要求的代码或页面修改,保留无关文件和现有数据。
|
||||
3. 执行与改动对应的最小测试、构建或静态验证。
|
||||
4. 按运行时注入的 `[Memind code-run validation]` 要求写入验收 receipt。
|
||||
5. 只有产物与验证都成功后才报告完成;失败时返回真实错误和未完成项。
|
||||
|
||||
## 边界
|
||||
|
||||
- 只操作当前用户被授权的工作区,不越界访问其它用户或平台生产目录。
|
||||
- 不执行发布、推送、合并主线或生产变更,除非用户另行明确授权且平台闸门允许。
|
||||
- Aider 没有 `private_data_*` 能力时,不得伪造建表、dataset 注册或页面绑定结果;应明确报告需转交 `page-data-collect`/Goose 的步骤。
|
||||
- Aider、模型绑定或 Tool Gateway 不可用时必须失败关闭,不得退回普通 Agent 冒充 Aider 完成。
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Aider 开发"
|
||||
short_description: "强制使用 Aider 在用户工作区内开发、修复并验证代码"
|
||||
default_prompt: "使用 $aider-development 在当前工作区实现并验证这项开发任务。"
|
||||
@@ -0,0 +1,6 @@
|
||||
name: aider-development
|
||||
version: 1.0.0
|
||||
label: Aider 开发
|
||||
description: 强制使用 Aider 在当前用户工作区开发、修复并验证代码
|
||||
executors:
|
||||
- aider
|
||||
@@ -10,6 +10,7 @@ import { ChatProvider } from './context/ChatProvider';
|
||||
import { PREVIEW_USER } from './dev/mindspacePreviewData';
|
||||
import { MindSpaceRoute } from './routes/MindSpaceRoute';
|
||||
import { FeedbackRoutes } from './routes/FeedbackRoute';
|
||||
import { useProductAnalytics } from './analytics/productAnalytics';
|
||||
import type { CapabilityMap, PortalUser } from './types';
|
||||
|
||||
function isMindSpacePreview() {
|
||||
@@ -124,6 +125,7 @@ export function App() {
|
||||
const [grantedSkills, setGrantedSkills] = useState<string[] | undefined>();
|
||||
const [legacyMode, setLegacyMode] = useState(false);
|
||||
const [authUnavailable, setAuthUnavailable] = useState<string | null>(null);
|
||||
useProductAnalytics(user?.id);
|
||||
|
||||
useEffect(() => {
|
||||
if (mindSpacePreview) return;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
type AnalyticsIdentity = {
|
||||
distinctId: string;
|
||||
username: string;
|
||||
ownerSegment: string;
|
||||
planType: string;
|
||||
channel: string;
|
||||
surface: 'product';
|
||||
identityMode: 'raw' | 'pseudonymous';
|
||||
};
|
||||
|
||||
type ProductAnalyticsContext = {
|
||||
enabled: boolean;
|
||||
websiteId?: string;
|
||||
scriptPath?: string;
|
||||
hostPath?: string;
|
||||
domains?: string;
|
||||
identity?: AnalyticsIdentity | null;
|
||||
};
|
||||
|
||||
type PageViewProperties = Record<string, unknown>;
|
||||
type UmamiTracker = {
|
||||
track: (
|
||||
value?: string | ((properties: PageViewProperties) => PageViewProperties),
|
||||
data?: Record<string, unknown>,
|
||||
) => Promise<unknown> | unknown;
|
||||
identify: (id: string, data?: Record<string, unknown>) => Promise<unknown> | unknown;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: UmamiTracker;
|
||||
}
|
||||
}
|
||||
|
||||
const SAFE_QUERY_KEYS = new Set(['category', 'utm_source', 'preview']);
|
||||
let trackerLoad: Promise<void> | null = null;
|
||||
let lastPageViewKey = '';
|
||||
let lastIdentityKey = '';
|
||||
|
||||
export function normalizeProductRoute(pathname: string, search = '') {
|
||||
const params = new URLSearchParams(search);
|
||||
const safe = new URLSearchParams();
|
||||
for (const key of SAFE_QUERY_KEYS) {
|
||||
const value = params.get(key);
|
||||
if (value) safe.set(key, value.slice(0, 100));
|
||||
}
|
||||
const query = safe.toString();
|
||||
return `${pathname || '/'}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function resolveProductRouteName(pathname: string) {
|
||||
if (pathname === '/') return 'chat';
|
||||
if (pathname === '/space') return 'mindspace_home';
|
||||
if (pathname.startsWith('/space/page/')) return 'mindspace_page';
|
||||
if (pathname.startsWith('/feedback/')) return 'feedback_detail';
|
||||
if (pathname === '/feedback') return 'feedback';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function safeTargetPath(element: Element) {
|
||||
if (!(element instanceof HTMLAnchorElement) || !element.href) return '';
|
||||
try {
|
||||
const target = new URL(element.href, window.location.href);
|
||||
return target.origin === window.location.origin
|
||||
? target.pathname
|
||||
: `external:${target.hostname}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function actionName(element: Element) {
|
||||
const stableClasses = Array.from(element.classList)
|
||||
.filter((name) => /^[a-z][a-z0-9_-]{1,80}$/i.test(name))
|
||||
.slice(0, 3)
|
||||
.join('.');
|
||||
return String(
|
||||
element.getAttribute('data-analytics-action') ||
|
||||
element.getAttribute('data-umami-event') ||
|
||||
element.getAttribute('aria-label') ||
|
||||
element.getAttribute('title') ||
|
||||
element.id ||
|
||||
stableClasses ||
|
||||
element.tagName.toLowerCase(),
|
||||
).slice(0, 100);
|
||||
}
|
||||
|
||||
function waitForTracker() {
|
||||
if (window.umami) return Promise.resolve();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
if (window.umami) {
|
||||
window.clearInterval(timer);
|
||||
resolve();
|
||||
} else if (Date.now() - startedAt > 5000) {
|
||||
window.clearInterval(timer);
|
||||
reject(new Error('Umami tracker did not initialize'));
|
||||
}
|
||||
}, 25);
|
||||
});
|
||||
}
|
||||
|
||||
function loadTracker(context: ProductAnalyticsContext) {
|
||||
if (window.umami) return Promise.resolve();
|
||||
if (trackerLoad) return trackerLoad;
|
||||
trackerLoad = new Promise<void>((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>('#memind-product-analytics');
|
||||
if (existing) {
|
||||
void waitForTracker().then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.id = 'memind-product-analytics';
|
||||
script.defer = true;
|
||||
script.src = context.scriptPath || '/analytics/script.js';
|
||||
script.dataset.websiteId = context.websiteId || '';
|
||||
script.dataset.hostUrl = context.hostPath || '/analytics';
|
||||
script.dataset.autoTrack = 'false';
|
||||
if (context.domains) script.dataset.domains = context.domains;
|
||||
script.addEventListener('load', () => void waitForTracker().then(resolve, reject), { once: true });
|
||||
script.addEventListener('error', () => reject(new Error('Unable to load Umami tracker')), {
|
||||
once: true,
|
||||
});
|
||||
document.head.appendChild(script);
|
||||
}).catch((error) => {
|
||||
trackerLoad = null;
|
||||
throw error;
|
||||
});
|
||||
return trackerLoad;
|
||||
}
|
||||
|
||||
function absoluteRoute(route: string) {
|
||||
return new URL(route, window.location.origin).toString();
|
||||
}
|
||||
|
||||
function trackPageView(route: string) {
|
||||
return window.umami?.track((properties) => ({
|
||||
...properties,
|
||||
url: absoluteRoute(route),
|
||||
title: document.title,
|
||||
}));
|
||||
}
|
||||
|
||||
function trackProductEvent(
|
||||
eventName: string,
|
||||
route: string,
|
||||
identity: AnalyticsIdentity | null | undefined,
|
||||
data: Record<string, unknown> = {},
|
||||
) {
|
||||
return window.umami?.track((properties) => ({
|
||||
...properties,
|
||||
name: eventName,
|
||||
url: absoluteRoute(route),
|
||||
title: document.title,
|
||||
data: {
|
||||
surface: 'product',
|
||||
route,
|
||||
route_name: resolveProductRouteName(window.location.pathname),
|
||||
channel: identity?.channel || 'h5',
|
||||
owner_segment: identity?.ownerSegment || 'anonymous',
|
||||
plan_type: identity?.planType || 'anonymous',
|
||||
identity_mode: identity?.identityMode || 'anonymous',
|
||||
...data,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function useProductAnalytics(userId?: string | null) {
|
||||
const location = useLocation();
|
||||
const [context, setContext] = useState<ProductAnalyticsContext>({ enabled: false });
|
||||
const [ready, setReady] = useState(false);
|
||||
const contextRef = useRef(context);
|
||||
const route = normalizeProductRoute(location.pathname, location.search);
|
||||
const routeRef = useRef(route);
|
||||
|
||||
contextRef.current = context;
|
||||
routeRef.current = route;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetch('/api/analytics/context', { credentials: 'same-origin', cache: 'no-store' })
|
||||
.then(async (response) => (response.ok ? ((await response.json()) as ProductAnalyticsContext) : null))
|
||||
.then((next) => {
|
||||
if (!cancelled && next) setContext(next);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!context.enabled || !context.websiteId) return;
|
||||
let cancelled = false;
|
||||
void loadTracker(context)
|
||||
.then(() => {
|
||||
if (cancelled) return;
|
||||
const identity = context.identity;
|
||||
if (identity?.distinctId) {
|
||||
const identityKey = `${context.websiteId}:${identity.distinctId}`;
|
||||
if (lastIdentityKey !== identityKey) {
|
||||
lastIdentityKey = identityKey;
|
||||
void window.umami?.identify(identity.distinctId, {
|
||||
username: identity.username,
|
||||
owner_segment: identity.ownerSegment,
|
||||
plan_type: identity.planType,
|
||||
channel: identity.channel,
|
||||
surface: identity.surface,
|
||||
identity_mode: identity.identityMode,
|
||||
});
|
||||
}
|
||||
} else if (lastIdentityKey) {
|
||||
lastIdentityKey = '';
|
||||
void window.umami?.identify('', {
|
||||
auth_state: 'anonymous',
|
||||
channel: 'h5',
|
||||
surface: 'product',
|
||||
});
|
||||
}
|
||||
setReady(true);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [context]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !context.enabled || !context.websiteId) return;
|
||||
const pageViewKey = `${context.websiteId}:${route}`;
|
||||
if (lastPageViewKey === pageViewKey) return;
|
||||
lastPageViewKey = pageViewKey;
|
||||
void trackPageView(route);
|
||||
|
||||
const tenSecond = window.setTimeout(() => {
|
||||
void trackProductEvent('product_engaged_10s', route, context.identity);
|
||||
}, 10_000);
|
||||
const thirtySecond = window.setTimeout(() => {
|
||||
void trackProductEvent('product_engaged_30s', route, context.identity);
|
||||
}, 30_000);
|
||||
return () => {
|
||||
window.clearTimeout(tenSecond);
|
||||
window.clearTimeout(thirtySecond);
|
||||
};
|
||||
}, [context, ready, route]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !context.enabled) return;
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target instanceof Element
|
||||
? event.target.closest('a,button,[role="button"],[data-analytics-action],[data-umami-event]')
|
||||
: null;
|
||||
if (!target) return;
|
||||
void trackProductEvent('product_click', routeRef.current, contextRef.current.identity, {
|
||||
action: actionName(target),
|
||||
element: target.tagName.toLowerCase(),
|
||||
element_id: target.id.slice(0, 100),
|
||||
element_class: Array.from(target.classList).slice(0, 3).join(' ').slice(0, 200),
|
||||
target_path: safeTargetPath(target),
|
||||
});
|
||||
};
|
||||
document.addEventListener('click', handleClick, { capture: true, passive: true });
|
||||
return () => document.removeEventListener('click', handleClick, { capture: true });
|
||||
}, [context.enabled, ready]);
|
||||
}
|
||||
+3
-2
@@ -174,7 +174,7 @@ function withAgentRunValidationMetadata(
|
||||
function prepareAgentRunUserMessage(message: Message, options: AgentRunCreateOptions): Message {
|
||||
const normalized = normalizeUserMessageForApi(message);
|
||||
const withValidation = withAgentRunValidationMetadata(normalized, options.validation);
|
||||
const withRunMetadata = options.forceDeepReasoning
|
||||
const withRunMetadata = options.forceDeepReasoning || options.executor
|
||||
? {
|
||||
...withValidation,
|
||||
metadata: {
|
||||
@@ -186,7 +186,8 @@ function prepareAgentRunUserMessage(message: Message, options: AgentRunCreateOpt
|
||||
? withValidation.metadata.memindRun
|
||||
: {}
|
||||
),
|
||||
forceDeepReasoning: true,
|
||||
...(options.forceDeepReasoning ? { forceDeepReasoning: true } : {}),
|
||||
...(options.executor ? { executor: options.executor } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState,
|
||||
import { BrainCircuit, Database, Image, ImageOff, ImagePlus } from 'lucide-react';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
import { openAvatarPicker } from '../utils/userAvatar';
|
||||
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
|
||||
import {
|
||||
CHAT_SKILL_OPTIONS,
|
||||
filterChatSkills,
|
||||
mergeChatSkillPromptWithInput,
|
||||
} from '../utils/chatSkills';
|
||||
import { getMessageSaveActions } from '../utils/messageSave';
|
||||
import { getDisplayText } from '../utils/message';
|
||||
import {
|
||||
@@ -1242,7 +1246,7 @@ export function ChatPanel({
|
||||
onSelect={submitText}
|
||||
onPrefill={(prompt, skillId) => {
|
||||
pendingSkillRef.current = skillId ?? null;
|
||||
setInput(prompt);
|
||||
setInput((current) => mergeChatSkillPromptWithInput(prompt, current));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -372,6 +372,7 @@ export function ChatView({
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
data-analytics-action="chat_new"
|
||||
aria-label="新聊天"
|
||||
title="新聊天"
|
||||
onClick={() => void handleNewSession()}
|
||||
@@ -386,7 +387,7 @@ export function ChatView({
|
||||
</svg>
|
||||
</button>
|
||||
{onOpenAdmin && (
|
||||
<button type="button" className="ghost-btn" onClick={onOpenAdmin}>
|
||||
<button type="button" className="ghost-btn" data-analytics-action="open_admin" onClick={onOpenAdmin}>
|
||||
管理
|
||||
</button>
|
||||
)}
|
||||
@@ -395,6 +396,7 @@ export function ChatView({
|
||||
ref={spaceButtonRef}
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
data-analytics-action="open_mindspace"
|
||||
onClick={() => onOpenSpace?.()}
|
||||
>
|
||||
我的空间
|
||||
@@ -402,7 +404,7 @@ export function ChatView({
|
||||
)}
|
||||
{user && <WechatAccountButton returnTo={window.location.pathname} />}
|
||||
{onLogout && (
|
||||
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
|
||||
<button type="button" className="ghost-btn logout-btn" data-analytics-action="logout" onClick={onLogout}>
|
||||
登出
|
||||
</button>
|
||||
)}
|
||||
@@ -429,6 +431,7 @@ export function ChatView({
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
data-analytics-action="chat_new"
|
||||
aria-label="新聊天"
|
||||
title="新聊天"
|
||||
onClick={() => void handleNewSession()}
|
||||
@@ -446,6 +449,7 @@ export function ChatView({
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
data-analytics-action="open_mindspace"
|
||||
aria-label="我的空间"
|
||||
title="我的空间"
|
||||
onClick={() => onOpenSpace?.()}
|
||||
|
||||
@@ -51,7 +51,10 @@ import type {
|
||||
} from '../types';
|
||||
import { buildContextPrefix } from '../utils/mindspaceChatContext';
|
||||
import { buildUserAddressPrefix } from '../utils/userAddress';
|
||||
import { buildAutoChatSkillPrefix } from '../../chat-skills.mjs';
|
||||
import {
|
||||
AIDER_DEVELOPMENT_SKILL_NAME,
|
||||
buildAutoChatSkillPrefix,
|
||||
} from '../../chat-skills.mjs';
|
||||
import {
|
||||
reconcileSessionEventRequestContext,
|
||||
resolvePostAgentRunChatState,
|
||||
@@ -114,6 +117,7 @@ async function waitForAgentRun(runId: string): Promise<AgentRun> {
|
||||
}
|
||||
|
||||
const DIRECT_CHAT_SESSION_POLL_MS = 600;
|
||||
const AGENT_RUN_STATUS_POLL_MS = 1_500;
|
||||
const AGENT_RUN_WAIT_TIMEOUT_MS = 16 * 60 * 1000;
|
||||
|
||||
async function waitForAgentRunWithDirectChatPreview(
|
||||
@@ -126,6 +130,8 @@ async function waitForAgentRunWithDirectChatPreview(
|
||||
): Promise<AgentRun> {
|
||||
return await new Promise<AgentRun>((resolve, reject) => {
|
||||
let pollTimer: number | null = null;
|
||||
let runStatusPollTimer: number | null = null;
|
||||
let runStatusPollInFlight = false;
|
||||
let waitTimer: number | null = null;
|
||||
let pollingSessionId: string | null = null;
|
||||
let settled = false;
|
||||
@@ -135,6 +141,7 @@ async function waitForAgentRunWithDirectChatPreview(
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
stopPoll();
|
||||
stopRunStatusPoll();
|
||||
if (waitTimer != null) {
|
||||
window.clearTimeout(waitTimer);
|
||||
waitTimer = null;
|
||||
@@ -150,6 +157,13 @@ async function waitForAgentRunWithDirectChatPreview(
|
||||
}
|
||||
};
|
||||
|
||||
const stopRunStatusPoll = () => {
|
||||
if (runStatusPollTimer != null) {
|
||||
window.clearInterval(runStatusPollTimer);
|
||||
runStatusPollTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = (sessionId: string) => {
|
||||
if (pollingSessionId === sessionId && pollTimer != null) return;
|
||||
pollingSessionId = sessionId;
|
||||
@@ -172,9 +186,7 @@ async function waitForAgentRunWithDirectChatPreview(
|
||||
}, DIRECT_CHAT_SESSION_POLL_MS);
|
||||
};
|
||||
|
||||
unsubscribe = subscribeAgentRunEvents(
|
||||
runId,
|
||||
(run) => {
|
||||
const handleRunStatus = (run: AgentRun) => {
|
||||
if (run.sessionId) {
|
||||
handlers.onSessionId?.(run.sessionId);
|
||||
if (isDirectChatSessionId(run.sessionId)) {
|
||||
@@ -188,11 +200,32 @@ async function waitForAgentRunWithDirectChatPreview(
|
||||
if (run.status === 'failed') {
|
||||
settle(() => reject(new Error(run.error || '后台任务失败,请稍后重试')));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const pollRunStatus = () => {
|
||||
if (settled || runStatusPollInFlight || handlers.isCancelled?.()) return;
|
||||
runStatusPollInFlight = true;
|
||||
void getAgentRun(runId)
|
||||
.then(handleRunStatus)
|
||||
.catch(() => {
|
||||
// SSE remains primary; polling only closes terminal-state gaps.
|
||||
})
|
||||
.finally(() => {
|
||||
runStatusPollInFlight = false;
|
||||
});
|
||||
};
|
||||
|
||||
unsubscribe = subscribeAgentRunEvents(
|
||||
runId,
|
||||
handleRunStatus,
|
||||
(error) => {
|
||||
settle(() => reject(error));
|
||||
void getAgentRun(runId)
|
||||
.then(handleRunStatus)
|
||||
.catch(() => settle(() => reject(error)));
|
||||
},
|
||||
);
|
||||
runStatusPollTimer = window.setInterval(pollRunStatus, AGENT_RUN_STATUS_POLL_MS);
|
||||
pollRunStatus();
|
||||
|
||||
waitTimer = window.setTimeout(() => {
|
||||
void getAgentRun(runId)
|
||||
@@ -1473,8 +1506,11 @@ export function useTKMindChat(
|
||||
|
||||
try {
|
||||
const pageDataDevTaskType = resolvePageDataDevTaskType(trimmed);
|
||||
const requiresAider = options?.selectedChatSkill === AIDER_DEVELOPMENT_SKILL_NAME;
|
||||
const runOptions = resolveAgentRunOptions(trimmed, {
|
||||
taskType: pageDataDevTaskType ?? 'h5_chat_code_task',
|
||||
forceCode: requiresAider,
|
||||
requiredExecutor: requiresAider ? 'aider' : undefined,
|
||||
userId: userRef.current?.id ?? null,
|
||||
requestId,
|
||||
mindspaceContext: options?.mindspaceContext ?? null,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MindSpaceChatContext, AgentCodeRunClientPolicy } from '../types';
|
||||
export type AgentRunCreateOptions = {
|
||||
toolMode?: 'chat' | 'code';
|
||||
taskType?: string | null;
|
||||
executor?: 'aider' | 'openhands';
|
||||
forceDeepReasoning?: boolean;
|
||||
validation?: AgentRunValidation | null;
|
||||
validationInstruction?: string | null;
|
||||
@@ -290,6 +291,7 @@ export function resolveAgentRunOptions(
|
||||
{
|
||||
taskType = 'code_task',
|
||||
forceCode = false,
|
||||
requiredExecutor,
|
||||
allowAutodetect = clientCodeRunsAutodetectEnabled(),
|
||||
allowPageDataDevAutodetect = clientPageDataDevAutodetectEnabled(),
|
||||
userId = null,
|
||||
@@ -299,6 +301,7 @@ export function resolveAgentRunOptions(
|
||||
}: {
|
||||
taskType?: string;
|
||||
forceCode?: boolean;
|
||||
requiredExecutor?: 'aider' | 'openhands';
|
||||
allowAutodetect?: boolean;
|
||||
allowPageDataDevAutodetect?: boolean;
|
||||
userId?: string | null;
|
||||
@@ -308,20 +311,23 @@ export function resolveAgentRunOptions(
|
||||
} = {},
|
||||
): AgentRunCreateOptions {
|
||||
const normalizedText = String(text ?? '').trim();
|
||||
const pageDataDevTaskType =
|
||||
allowPageDataDevAutodetect && resolvePageDataDevTaskType(normalizedText);
|
||||
const pageDataDevTaskType = allowPageDataDevAutodetect
|
||||
? resolvePageDataDevTaskType(normalizedText)
|
||||
: null;
|
||||
const effectiveTaskType = pageDataDevTaskType ?? taskType;
|
||||
const shouldUseDeepReasoning =
|
||||
forceCode ||
|
||||
Boolean(requiredExecutor) ||
|
||||
Boolean(pageDataDevTaskType) ||
|
||||
DEEP_REASONING_TASK_PATTERNS.some((pattern) => pattern.test(normalizedText));
|
||||
|
||||
if (!agentCodeRunsEnabledForUser(userId)) {
|
||||
if (!agentCodeRunsEnabledForUser(userId) && !requiredExecutor) {
|
||||
return shouldUseDeepReasoning ? { forceDeepReasoning: true, taskType: effectiveTaskType } : {};
|
||||
}
|
||||
|
||||
const shouldUseCode =
|
||||
forceCode ||
|
||||
Boolean(requiredExecutor) ||
|
||||
Boolean(pageDataDevTaskType) ||
|
||||
(allowAutodetect && CODE_TASK_PATTERNS.some((pattern) => pattern.test(normalizedText)));
|
||||
if (!shouldUseCode) {
|
||||
@@ -339,6 +345,7 @@ export function resolveAgentRunOptions(
|
||||
return {
|
||||
toolMode: 'code',
|
||||
taskType: effectiveTaskType,
|
||||
...(requiredExecutor ? { executor: requiredExecutor } : {}),
|
||||
forceDeepReasoning: true,
|
||||
validation: mergeAgentRunValidationFiles(receipt.validation, taskValidation.validation),
|
||||
validationInstruction: `${receipt.instruction}${taskValidation.instruction}`,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
buildChatSkillPrompt,
|
||||
CHAT_SKILL_DEFINITIONS,
|
||||
filterChatSkills as filterChatSkillDefinitions,
|
||||
mergeChatSkillPromptWithInput,
|
||||
} from '../../chat-skills.mjs';
|
||||
import { buildPublishSkillPrompt, PUBLISH_SKILL_NAME } from './publishSkill';
|
||||
|
||||
@@ -45,3 +46,4 @@ export function filterChatSkills(
|
||||
}
|
||||
|
||||
export { buildPublishSkillPrompt, PUBLISH_SKILL_NAME };
|
||||
export { mergeChatSkillPromptWithInput };
|
||||
|
||||
+103
-2
@@ -1,5 +1,7 @@
|
||||
import { spawn as nodeSpawn } from 'node:child_process';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const CODE_EXECUTORS = new Set(['aider', 'openhands']);
|
||||
const DEFAULT_STDIO_LIMIT = 64 * 1024;
|
||||
@@ -55,6 +57,92 @@ export function extractToolInstruction(userMessage) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function runValidation(userMessage) {
|
||||
const metadata = userMessage?.metadata ?? {};
|
||||
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
|
||||
const validation = runMetadata.validation ?? metadata.toolGatewayValidation;
|
||||
return validation && typeof validation === 'object' && !Array.isArray(validation)
|
||||
? validation
|
||||
: null;
|
||||
}
|
||||
|
||||
function validationFilePaths(userMessage) {
|
||||
const validation = runValidation(userMessage);
|
||||
if (!validation) return [];
|
||||
const candidates = [
|
||||
validation.expectedFile ?? validation.expectedPath,
|
||||
...(Array.isArray(validation.expectedFiles) ? validation.expectedFiles : []),
|
||||
];
|
||||
return candidates
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item.trim();
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return '';
|
||||
return String(item.path ?? item.file ?? item.relativePath ?? '').trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function resolveAiderReceiptPath(userMessage, requestId) {
|
||||
const expected = `.memind/agent-runs/${String(requestId ?? '').trim()}.json`;
|
||||
return validationFilePaths(userMessage).find((item) => item === expected) ?? null;
|
||||
}
|
||||
|
||||
export async function prepareAiderReceiptFile(cwd, relativePath) {
|
||||
if (!cwd || !relativePath) return null;
|
||||
const root = path.resolve(String(cwd));
|
||||
const target = path.resolve(root, relativePath);
|
||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
||||
throw new Error(`Aider receipt path escapes working directory: ${relativePath}`);
|
||||
}
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
try {
|
||||
await fs.access(target);
|
||||
} catch {
|
||||
await fs.writeFile(
|
||||
target,
|
||||
`${JSON.stringify({ status: 'pending', executor: 'aider' }, null, 2)}\n`,
|
||||
{ flag: 'wx' },
|
||||
);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
async function resolveAiderContextFiles(userMessage, cwd) {
|
||||
const metadata = userMessage?.metadata ?? {};
|
||||
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
|
||||
const candidates = Array.isArray(runMetadata.aiderContextFiles)
|
||||
? runMetadata.aiderContextFiles
|
||||
: [];
|
||||
if (!cwd || candidates.length === 0) return [];
|
||||
const root = path.resolve(String(cwd));
|
||||
const resolved = [];
|
||||
for (const relativePath of candidates.slice(0, 40)) {
|
||||
const target = path.resolve(root, String(relativePath ?? ''));
|
||||
if (target === root || !target.startsWith(`${root}${path.sep}`)) continue;
|
||||
try {
|
||||
const stat = await fs.stat(target);
|
||||
if (stat.isFile()) resolved.push(target);
|
||||
} catch {
|
||||
// Review context is best-effort; delivery validation remains authoritative.
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function hardenAiderLaunchPlan(plan, receiptPath = null, contextFiles = []) {
|
||||
const args = [...(plan?.args ?? [])];
|
||||
for (const flag of ['--no-git', '--no-auto-commits', '--no-dirty-commits']) {
|
||||
if (!args.includes(flag)) args.push(flag);
|
||||
}
|
||||
for (const contextFile of contextFiles) {
|
||||
if (!args.includes(contextFile)) args.push('--file', contextFile);
|
||||
}
|
||||
if (receiptPath && !args.includes(receiptPath)) {
|
||||
args.push('--file', receiptPath);
|
||||
}
|
||||
return { ...plan, args };
|
||||
}
|
||||
|
||||
export function createToolGateway({
|
||||
llmProviderService,
|
||||
env = process.env,
|
||||
@@ -110,16 +198,29 @@ export function createToolGateway({
|
||||
throw new Error('Tool Gateway job missing instruction');
|
||||
}
|
||||
const executor = selectExecutor({ userMessage, taskType });
|
||||
const plan = await llmProviderService.getExecutorLaunchPlan(executor, {
|
||||
const receiptPath = executor === 'aider'
|
||||
? resolveAiderReceiptPath(userMessage, requestId)
|
||||
: null;
|
||||
const executorInstruction = receiptPath
|
||||
? `${instruction}\n\nThe validation receipt is already included in the Aider chat. Edit it directly; do not ask the user to add it.`
|
||||
: instruction;
|
||||
let plan = await llmProviderService.getExecutorLaunchPlan(executor, {
|
||||
cwd,
|
||||
mode: 'headless',
|
||||
instruction,
|
||||
instruction: executorInstruction,
|
||||
purpose: 'default',
|
||||
includeSecret: true,
|
||||
});
|
||||
if (!plan?.ok) {
|
||||
throw new Error(plan?.message ?? `Tool Gateway launch plan unavailable for ${executor}`);
|
||||
}
|
||||
if (executor === 'aider') {
|
||||
const preparedReceipt = dryRun
|
||||
? (receiptPath ? path.resolve(String(cwd), receiptPath) : null)
|
||||
: await prepareAiderReceiptFile(cwd, receiptPath);
|
||||
const contextFiles = await resolveAiderContextFiles(userMessage, cwd);
|
||||
plan = hardenAiderLaunchPlan(plan, preparedReceipt, contextFiles);
|
||||
}
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
+65
-1
@@ -1,6 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createToolGateway, extractToolInstruction } from './tool-gateway.mjs';
|
||||
import {
|
||||
createToolGateway,
|
||||
extractToolInstruction,
|
||||
hardenAiderLaunchPlan,
|
||||
prepareAiderReceiptFile,
|
||||
resolveAiderReceiptPath,
|
||||
} from './tool-gateway.mjs';
|
||||
|
||||
test('tool gateway extracts text instructions from H5 message content', () => {
|
||||
assert.equal(
|
||||
@@ -43,6 +52,61 @@ test('tool gateway selects openhands for page_data_dev_complex by default', () =
|
||||
assert.equal(gateway.selectExecutor({ taskType: 'page_data_dev_complex' }), 'openhands');
|
||||
});
|
||||
|
||||
test('tool gateway honors an explicitly required Aider executor over task defaults', () => {
|
||||
const gateway = createToolGateway({ env: {} });
|
||||
assert.equal(gateway.selectExecutor({
|
||||
taskType: 'page_data_dev_complex',
|
||||
userMessage: {
|
||||
metadata: {
|
||||
memindRun: {
|
||||
executor: 'aider',
|
||||
selectedChatSkill: 'aider-development',
|
||||
},
|
||||
},
|
||||
},
|
||||
}), 'aider');
|
||||
});
|
||||
|
||||
test('tool gateway resolves and prepares the required Aider receipt without pre-validating it', async () => {
|
||||
const requestId = 'req-receipt';
|
||||
const relativePath = `.memind/agent-runs/${requestId}.json`;
|
||||
const userMessage = {
|
||||
metadata: {
|
||||
memindRun: {
|
||||
validation: {
|
||||
expectedFile: { path: relativePath, contains: requestId },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(resolveAiderReceiptPath(userMessage, requestId), relativePath);
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-aider-receipt-'));
|
||||
try {
|
||||
const target = await prepareAiderReceiptFile(cwd, relativePath);
|
||||
const pending = JSON.parse(await fs.readFile(target, 'utf8'));
|
||||
assert.deepEqual(pending, { status: 'pending', executor: 'aider' });
|
||||
assert.equal((await fs.readFile(target, 'utf8')).includes(requestId), false);
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('tool gateway hardens Aider against repository commits and includes the receipt', () => {
|
||||
const plan = hardenAiderLaunchPlan(
|
||||
{ ok: true, args: ['--model', 'test-model'] },
|
||||
'/tmp/work/.memind/agent-runs/req.json',
|
||||
['/tmp/work/public/order.html'],
|
||||
);
|
||||
assert.ok(plan.args.includes('--no-git'));
|
||||
assert.ok(plan.args.includes('--no-auto-commits'));
|
||||
assert.ok(plan.args.includes('--no-dirty-commits'));
|
||||
assert.deepEqual(
|
||||
plan.args.slice(-2),
|
||||
['--file', '/tmp/work/.memind/agent-runs/req.json'],
|
||||
);
|
||||
assert.ok(plan.args.includes('/tmp/work/public/order.html'));
|
||||
});
|
||||
|
||||
test('tool gateway dry run builds executor launch plan without spawning', async () => {
|
||||
const plans = [];
|
||||
const gateway = createToolGateway({
|
||||
|
||||
Reference in New Issue
Block a user