Add page data delivery and publication guards

This commit is contained in:
john
2026-07-08 21:10:47 +08:00
parent 8d629e7a4e
commit eea14c1855
66 changed files with 3035 additions and 413 deletions
+74
View File
@@ -0,0 +1,74 @@
export const RECOVERABLE_FINISH_ERROR_CODES = new Set([
'AGENT_RUN_TIMEOUT',
'SESSION_REPLY_TIMEOUT',
'SESSION_REPLY_INCOMPLETE',
]);
export function isRecoverableFinishError(error) {
const code = String(error?.code ?? '').trim();
return RECOVERABLE_FINISH_ERROR_CODES.has(code);
}
export function hasRecoverableSessionDeliverables(summary) {
const pageCount = Number(summary?.pageCount ?? 0);
const publicationCount = Number(summary?.publicationCount ?? 0);
return pageCount > 0 || publicationCount > 0;
}
export async function detectSessionDeliverables(pool, userId, sessionId) {
if (!pool?.query || !userId || !sessionId) {
return { pageCount: 0, publicationCount: 0, pages: [] };
}
const [rows] = await pool.query(
`SELECT p.id AS page_id,
p.title,
pr.id AS publication_id,
pr.status AS publication_status,
pr.public_url
FROM h5_page_records p
LEFT JOIN h5_publish_records pr
ON pr.page_id = p.id
AND pr.user_id = p.user_id
AND pr.status = 'online'
WHERE p.user_id = ?
AND p.source_session_id = ?
AND p.status <> 'deleted'
ORDER BY p.created_at ASC`,
[userId, sessionId],
);
const pages = (rows ?? []).map((row) => ({
pageId: String(row.page_id ?? '').trim(),
title: String(row.title ?? '').trim(),
publicationId: row.publication_id ? String(row.publication_id) : null,
publicationStatus: row.publication_status ? String(row.publication_status) : null,
publicUrl: row.public_url ? String(row.public_url) : null,
})).filter((row) => row.pageId);
const publicationCount = pages.filter((row) => row.publicationId).length;
return {
pageCount: pages.length,
publicationCount,
pages,
};
}
export async function tryRecoverRunFromSessionDeliverables({
pool,
userId,
sessionId,
error,
} = {}) {
if (!isRecoverableFinishError(error) || !sessionId) {
return null;
}
const deliverables = await detectSessionDeliverables(pool, userId, sessionId);
if (!hasRecoverableSessionDeliverables(deliverables)) {
return null;
}
return {
deliverables,
originalError: {
code: error?.code ?? null,
message: error instanceof Error ? error.message : String(error ?? ''),
},
};
}
+77
View File
@@ -0,0 +1,77 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
detectSessionDeliverables,
hasRecoverableSessionDeliverables,
isRecoverableFinishError,
tryRecoverRunFromSessionDeliverables,
} from './agent-run-deliverable-check.mjs';
test('isRecoverableFinishError recognizes finish and run timeout codes', () => {
assert.equal(isRecoverableFinishError({ code: 'SESSION_REPLY_INCOMPLETE' }), true);
assert.equal(isRecoverableFinishError({ code: 'SESSION_REPLY_TIMEOUT' }), true);
assert.equal(isRecoverableFinishError({ code: 'AGENT_RUN_TIMEOUT' }), true);
assert.equal(isRecoverableFinishError({ code: 'TOOL_GATEWAY_VALIDATION_FAILED' }), false);
});
test('detectSessionDeliverables counts pages and online publications for a session', async () => {
const pool = {
async query(sql, params) {
assert.match(sql, /source_session_id/);
assert.deepEqual(params, ['user-1', 'session-1']);
return [[
{
page_id: 'page-front',
title: '前台',
publication_id: 'pub-front',
publication_status: 'online',
public_url: 'https://example.com/u/john/pages/page-front',
},
{
page_id: 'page-admin',
title: '后台',
publication_id: null,
publication_status: null,
public_url: null,
},
]];
},
};
const summary = await detectSessionDeliverables(pool, 'user-1', 'session-1');
assert.equal(summary.pageCount, 2);
assert.equal(summary.publicationCount, 1);
assert.equal(summary.pages[0].publicUrl, 'https://example.com/u/john/pages/page-front');
});
test('tryRecoverRunFromSessionDeliverables succeeds when pages exist despite finish timeout', async () => {
const pool = {
async query() {
return [[{ page_id: 'page-1', title: 'Survey', publication_id: 'pub-1', public_url: '/u/j/x' }]];
},
};
const recovered = await tryRecoverRunFromSessionDeliverables({
pool,
userId: 'user-1',
sessionId: 'session-1',
error: Object.assign(new Error('session reply timed out'), { code: 'SESSION_REPLY_TIMEOUT' }),
});
assert.ok(recovered);
assert.equal(recovered.deliverables.pageCount, 1);
assert.equal(recovered.originalError.code, 'SESSION_REPLY_TIMEOUT');
});
test('tryRecoverRunFromSessionDeliverables returns null when no deliverables exist', async () => {
const pool = { async query() { return [[]]; } };
const recovered = await tryRecoverRunFromSessionDeliverables({
pool,
userId: 'user-1',
sessionId: 'session-1',
error: Object.assign(new Error('stream ended'), { code: 'SESSION_REPLY_INCOMPLETE' }),
});
assert.equal(recovered, null);
});
test('hasRecoverableSessionDeliverables accepts pages even without online publication', () => {
assert.equal(hasRecoverableSessionDeliverables({ pageCount: 1, publicationCount: 0 }), true);
assert.equal(hasRecoverableSessionDeliverables({ pageCount: 0, publicationCount: 0 }), false);
});
+77 -19
View File
@@ -11,6 +11,7 @@ import {
persistSessionTranscriptMessages,
} from './conversation-transcript-persist.mjs';
import { ensureGooseUserMessageMetadata } from './goose-message.mjs';
import { tryRecoverRunFromSessionDeliverables } from './agent-run-deliverable-check.mjs';
const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000];
const TERMINAL_STATUSES = new Set(['succeeded', 'failed']);
@@ -242,6 +243,7 @@ export function createAgentRunGateway({
chatIntentRouter = null,
sessionSnapshotService = null,
conversationMemoryService = null,
syncUserPagesOnSuccess = null,
retryDelaysMs = DEFAULT_RUN_RETRY_DELAYS_MS,
autoDispatch = envFlag(process.env.MEMIND_AGENT_RUN_AUTODISPATCH, true),
maxConcurrentRuns = positiveInteger(
@@ -389,6 +391,22 @@ export function createAgentRunGateway({
return projectRun(await getRunById(runId));
}
async function recoverRunFromDeliverables({ runId, userId, sessionId, err }) {
const recovered = await tryRecoverRunFromSessionDeliverables({
pool,
userId,
sessionId,
error: err,
});
if (!recovered) return false;
await appendEvent(runId, 'run_recovered_from_deliverables', {
sessionId,
deliverables: recovered.deliverables,
originalError: recovered.originalError,
});
return true;
}
async function markRun(runId, status, fields = {}) {
const updates = ['status = ?', 'updated_at = ?'];
const values = [status, nowMs()];
@@ -675,21 +693,33 @@ export function createAgentRunGateway({
&& runOptions.toolMode === 'chat'
&& typeof tkmindProxy.submitSessionReplyAndAwaitFinishForUser === 'function';
if (awaitSessionFinish) {
const finish = await tkmindProxy.submitSessionReplyAndAwaitFinishForUser(
row.user_id,
sessionId,
row.request_id,
ensureGooseUserMessageMetadata(userMessage),
{
toolMode: runOptions.toolMode,
forceDeepReasoning: runOptions.forceDeepReasoning,
timeoutMs: runTimeoutMs,
},
);
await appendEvent(runId, 'session_finished', {
sessionId,
tokenState: finish.tokenState ?? null,
});
try {
const finish = await tkmindProxy.submitSessionReplyAndAwaitFinishForUser(
row.user_id,
sessionId,
row.request_id,
ensureGooseUserMessageMetadata(userMessage),
{
toolMode: runOptions.toolMode,
forceDeepReasoning: runOptions.forceDeepReasoning,
timeoutMs: runTimeoutMs,
},
);
await appendEvent(runId, 'session_finished', {
sessionId,
tokenState: finish.tokenState ?? null,
});
} catch (err) {
if (await recoverRunFromDeliverables({
runId,
userId: row.user_id,
sessionId,
err,
})) {
return { sessionId };
}
throw err;
}
} else {
await tkmindProxy.submitSessionReplyForUser(
row.user_id,
@@ -705,6 +735,26 @@ export function createAgentRunGateway({
return { sessionId };
}
async function finalizeSuccessfulRun(runId, row, sessionId) {
await markRun(runId, 'succeeded', {
agent_session_id: sessionId,
completed_at: nowMs(),
error_message: null,
});
if (typeof syncUserPagesOnSuccess === 'function') {
await syncUserPagesOnSuccess({
userId: row.user_id,
sessionId,
runId,
}).catch((err) => {
console.warn(
'[AgentRun] workspace page deliver failed:',
err instanceof Error ? err.message : err,
);
});
}
}
async function processRun(runId) {
const row = await getRunById(runId);
if (!row || TERMINAL_STATUSES.has(row.status)) return;
@@ -721,11 +771,19 @@ export function createAgentRunGateway({
try {
const { sessionId } = await runWithTimeout(runId, () => executeRun(row, runId));
await markRun(runId, 'succeeded', {
agent_session_id: sessionId,
completed_at: nowMs(),
});
await finalizeSuccessfulRun(runId, row, sessionId);
} catch (err) {
const latest = await getRunById(runId);
const recoverySessionId = latest?.agent_session_id ?? row.agent_session_id ?? null;
if (await recoverRunFromDeliverables({
runId,
userId: row.user_id,
sessionId: recoverySessionId,
err,
})) {
await finalizeSuccessfulRun(runId, row, recoverySessionId);
return;
}
const message = err instanceof Error ? err.message : String(err);
const timedOut = err?.code === 'AGENT_RUN_TIMEOUT';
if (timedOut) {
+52 -1
View File
@@ -6,7 +6,7 @@ import path from 'node:path';
import test from 'node:test';
import { createAgentRunGateway } from './agent-run-gateway.mjs';
function createFakePool() {
function createFakePool({ sessionDeliverables = {} } = {}) {
const runs = new Map();
const events = [];
@@ -191,6 +191,10 @@ function createFakePool() {
});
return [{ affectedRows: 1 }];
}
if (sql.includes('FROM h5_page_records p') && sql.includes('source_session_id')) {
const [userId, sessionId] = params;
return [sessionDeliverables[`${userId}:${sessionId}`] ?? []];
}
if (sql.includes('UPDATE h5_agent_runs SET')) {
const id = params.at(-1);
const row = runs.get(id);
@@ -322,6 +326,53 @@ test('agent run awaits session Finish before succeeding when proxy supports it',
assert.equal(finishEvents.length, 1);
});
test('agent run succeeds when Finish is missing but session pages were already created', async () => {
const pool = createFakePool({
sessionDeliverables: {
'user-1:session-deliverable-1': [{
page_id: 'page-front',
title: '供应商数据上报',
publication_id: 'pub-front',
publication_status: 'online',
public_url: 'http://127.0.0.1:5173/u/john/pages/page-front',
}],
},
});
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {
async startSessionForUser() {
return { id: 'session-deliverable-1' };
},
async submitSessionReplyAndAwaitFinishForUser() {
const err = new Error('session event stream ended before Finish');
err.code = 'SESSION_REPLY_INCOMPLETE';
throw err;
},
},
retryDelaysMs: [],
});
const run = await gateway.createRun('user-1', {
requestId: 'req-deliverable-recover',
userMessage: {
role: 'user',
content: [{ type: 'text', text: '生成填报系统' }],
},
});
await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded');
assert.equal(
pool.events.some((event) => event.runId === run.id && event.eventType === 'run_recovered_from_deliverables'),
true,
);
assert.equal(
pool.events.some((event) => event.runId === run.id && event.eventType === 'session_finished'),
false,
);
});
test('agent run uses direct chat service for eligible chat messages', async () => {
const pool = createFakePool();
const directRuns = [];
+4
View File
@@ -250,6 +250,10 @@ export function sandboxMcpTools(capabilities) {
'private_data_schema',
'private_data_query',
'private_data_execute',
'private_data_register_dataset',
'private_data_set_page_policy',
'private_data_close_page_dataset',
'private_data_bind_workspace_page',
'schedule_create_item',
'schedule_create_reminder',
'schedule_list_items',
+12
View File
@@ -227,6 +227,10 @@ test('sandboxMcpTools returns correct tool list based on capabilities', () => {
'private_data_schema',
'private_data_query',
'private_data_execute',
'private_data_register_dataset',
'private_data_set_page_policy',
'private_data_close_page_dataset',
'private_data_bind_workspace_page',
'schedule_create_item',
'schedule_create_reminder',
'schedule_list_items',
@@ -246,6 +250,10 @@ test('private_data_space alone exposes private data tools through sandbox MCP',
'private_data_schema',
'private_data_query',
'private_data_execute',
'private_data_register_dataset',
'private_data_set_page_policy',
'private_data_close_page_dataset',
'private_data_bind_workspace_page',
'schedule_create_item',
'schedule_create_reminder',
'schedule_list_items',
@@ -266,6 +274,10 @@ test('private_data_space alone exposes private data tools through sandbox MCP',
'private_data_schema',
'private_data_query',
'private_data_execute',
'private_data_register_dataset',
'private_data_set_page_policy',
'private_data_close_page_dataset',
'private_data_bind_workspace_page',
'schedule_create_item',
'schedule_create_reminder',
'schedule_list_items',
+110 -106
View File
@@ -3,14 +3,17 @@ import {
buildWebNewsSkillPrompt,
extractSelectedChatSkillName,
hasExplicitChatSkillPrompt,
isPageDataIntent,
isPageGenerationIntent,
isProductCampaignIntent,
PAGE_DATA_COLLECT_SKILL_NAME,
} from './chat-skills.mjs';
import { isDirectChatSessionId } from './direct-chat-service.mjs';
import {
memoryLimitForIntervention,
resolveMemoryInterventionMode,
} from './memory-intervention.mjs';
import { filterMemoriesByQuery } from './memory-legacy-fallback.mjs';
export const CHAT_INTENT_ROUTE = {
DIRECT_CHAT: 'direct_chat',
@@ -46,6 +49,7 @@ const SKILL_PROMPT_KEYS = {
web: 'web',
search: 'search',
'static-page-publish': 'generate-page',
'page-data-collect': 'page-data-collect',
'form-builder': 'form-builder',
'table-viewer': 'table-viewer',
'product-campaign-page': 'product-campaign-page',
@@ -66,6 +70,12 @@ const OBVIOUS_DIRECT_PATTERNS = [
/^[?]+$/u,
];
/** Short confirmations in an ongoing agent session must stay on Agent (not direct chat). */
const AGENT_SESSION_CONTINUE_PATTERNS = [
/^(?:可以|好的|好|行|确认|没问题|是的|对|嗯|OK|ok)[!!。.\s]*$/iu,
/^(?:开始吧|按默认做|默认方案|继续|就这样|就这样吧)[!!。.\s]*$/iu,
];
/** Pure text chat/creative prompts — fast-path even when LLM router is enabled. */
const OBVIOUS_DIRECT_CHAT_PATTERNS = [
/(?:讲|说|来|编).{0,10}(?:个|一段|一首|一个)?(?:睡前故事|故事|笑话|段子)/u,
@@ -97,6 +107,8 @@ const MEMORY_RECALL_PATTERNS = [
/(?:我|之前).{0,16}(?:说过|提到|聊过|告诉)/u,
/(?:我的|之前的)(?:记忆|偏好|计划|目标|想法)/u,
/之前(?:说|提|聊)(?:过|的)/u,
/之前.{0,24}记住/u,
/记住.{0,16}(?:是什么|叫什么|多少|哪个)/u,
];
export function isMemoryRecallQuestion(text) {
@@ -105,6 +117,12 @@ export function isMemoryRecallQuestion(text) {
return MEMORY_RECALL_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function isAgentSessionContinueText(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return AGENT_SESSION_CONTINUE_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function isRealtimeInfoQuestion(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
@@ -135,8 +153,25 @@ function buildRealtimeInfoClassification() {
}, { source: 'rule' });
}
export function coercePageDataSkill(classification, text, { grantedSkills = [] } = {}) {
if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification;
if (!isPageDataIntent(text)) return classification;
if (grantedSkills.length > 0 && !grantedSkills.includes(PAGE_DATA_COLLECT_SKILL_NAME)) {
return classification;
}
return {
...classification,
suggestedSkill: PAGE_DATA_COLLECT_SKILL_NAME,
agentBrief:
'在 MindSpace 页面实现数据收集与持久化:建表、注册 dataset、配置 page policy、HTML 使用 page-data-client.js;禁止自建后端服务。',
reason: `${classification.reason}(页面数据交互意图)`,
};
}
export function coercePageGenerationSkill(classification, text, { grantedSkills = [] } = {}) {
if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification;
if (isPageDataIntent(text)) return classification;
if (!isPageGenerationIntent(text) || isProductCampaignIntent(text)) return classification;
if (grantedSkills.length > 0 && !grantedSkills.includes('static-page-publish')) return classification;
@@ -321,7 +356,8 @@ function buildRouterSystemPrompt(grantedSkills = []) {
: 'suggested_skill 通常填 null。',
'',
'skill 选择补充:',
'- static-page-publish:攻略/游记/主题内容页、城市介绍、活动介绍、任何「做页面/生成链接」且无商品购买跳转需求。',
'- page-data-collect:页面需要问卷/表单/报名/提交记录/后台查看/数据交互/SQLite 持久化;必须用 Page Data API,禁止自建 Express 或独立端口。',
'- static-page-publish:攻略/游记/主题内容页、城市介绍、活动介绍、任何「做页面/生成链接」且无数据持久化需求。',
'- product-campaign-page:仅当用户明确提供商品链接或要求购买按钮/电商转化时使用,不要用于旅游攻略或纯内容页。',
'',
'只输出 JSON,不要 markdown,不要解释:',
@@ -740,10 +776,10 @@ export function classifyWithRules({
}, { source: 'rule' }), decisionContext);
}
if (
!llmRouterEnabled &&
includeIntentPatterns &&
normalized &&
isMemoryRecallQuestion(normalized)
isMemoryRecallQuestion(normalized) &&
!hasPriorAgentConversation(sessionId, sessionMessageCount)
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
@@ -751,11 +787,28 @@ export function classifyWithRules({
reason: '用户在询问个人记忆或历史对话',
}, { source: 'rule' }), decisionContext);
}
if (!llmRouterEnabled && hasPriorAgentConversation(sessionId, sessionMessageCount)) {
if (hasPriorAgentConversation(sessionId, sessionMessageCount)) {
const reason = isAgentSessionContinueText(normalized)
? 'Agent 会话确认/续聊'
: '延续已有 Agent 会话';
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason: '延续已有 Agent 会话',
reason,
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
isPageDataIntent(normalized)
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 0.96,
reason: '页面需要数据交互与持久化',
suggested_skill: PAGE_DATA_COLLECT_SKILL_NAME,
agent_brief:
'使用 Page Data API 实现页面表单提交与后台查看;建表、注册 dataset、配置 policyHTML 引入 page-data-client.js。',
}, { source: 'rule' }), decisionContext);
}
if (
@@ -813,6 +866,7 @@ export function createChatIntentRouter(options = {}) {
const {
llmProviderService,
memoryV2 = null,
conversationMemoryService = null,
env = process.env,
logger = console,
} = options;
@@ -849,7 +903,8 @@ export function createChatIntentRouter(options = {}) {
}
function isEnabled() {
return Boolean(policy.enabled && llmProviderService?.createChatCompletion);
// Skill + rule routing only; LLM intent router is intentionally disabled.
return false;
}
async function resolveRouterContext({ userId, sessionId, text, forceDeepReasoning = false }) {
@@ -861,32 +916,50 @@ export function createChatIntentRouter(options = {}) {
if (
limit <= 0 ||
!policy.memoryResolveEnabled ||
!memoryV2?.resolve ||
!userId
!userId ||
(!memoryV2?.resolve && !conversationMemoryService?.listMemories)
) {
return buildRouterContext(null);
}
try {
const resolved = await withTimeout(
memoryV2.resolve({
userId,
sessionId,
query: text,
limit,
}),
policy.timeoutMs,
'Memory V2 router resolve',
);
return buildRouterContext(resolved);
} catch (err) {
logger?.warn?.(
`[chat-intent-router] memory resolve skipped: ${err instanceof Error ? err.message : err}`,
);
let primaryFailed = false;
let memories = [];
const recallQuestion = isMemoryRecallQuestion(text);
if (recallQuestion && conversationMemoryService?.listMemories) {
const legacy = await conversationMemoryService.listMemories(userId, { limit }).catch(() => []);
memories = filterMemoriesByQuery(legacy, text);
}
if (!memories.length && memoryV2?.resolve) {
try {
const resolved = await withTimeout(
memoryV2.resolve({
userId,
sessionId,
query: text,
limit,
}),
policy.timeoutMs,
'Memory V2 router resolve',
);
memories = Array.isArray(resolved?.memories) ? resolved.memories : [];
} catch (err) {
primaryFailed = true;
logger?.warn?.(
`[chat-intent-router] memory resolve skipped: ${err instanceof Error ? err.message : err}`,
);
}
}
if (!memories.length && conversationMemoryService?.listMemories) {
const legacy = await conversationMemoryService.listMemories(userId, { limit }).catch(() => []);
memories = filterMemoriesByQuery(legacy, text);
if (memories.length) primaryFailed = false;
}
if (primaryFailed && !memories.length) {
return buildRouterContext({
degraded: true,
reason: 'memory_resolve_failed',
});
}
return buildRouterContext({ memories, source: 'router-resolve' });
}
async function classify({
@@ -912,7 +985,11 @@ export function createChatIntentRouter(options = {}) {
delete base.decision;
return finalizeRouterClassification(
coercePageGenerationSkill(
coerceRealtimeWebSkill(base, text, { grantedSkills }),
coercePageDataSkill(
coerceRealtimeWebSkill(base, text, { grantedSkills }),
text,
{ grantedSkills },
),
text,
{ grantedSkills },
),
@@ -927,89 +1004,14 @@ export function createChatIntentRouter(options = {}) {
sessionMessageCount,
userMessage,
includeIntentPatterns: true,
llmRouterEnabled: Boolean(policy.enabled),
llmRouterEnabled: false,
});
if (ruleResult) return finalizeWithCoercion(ruleResult);
if (!isEnabled()) {
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0.5,
reason: '意图路由未启用,走默认通道',
}, { source: 'fallback' }));
}
const routerContext = await resolveRouterContext({
userId,
sessionId,
text,
forceDeepReasoning,
});
let completion = null;
try {
completion = await withTimeout(
llmProviderService.createChatCompletion({
providerKeyId: policy.modelProviderKeyId || undefined,
model: policy.model || undefined,
modelApiType: policy.modelApiType || undefined,
temperature: policy.temperature,
messages: [
{ role: 'system', content: buildRouterSystemPrompt(grantedSkills) },
{
role: 'user',
content: buildRouterUserPrompt({
text,
routerContext: routerContext.content,
}),
},
],
}),
policy.timeoutMs,
'Chat intent router',
);
} catch (err) {
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: err instanceof Error ? err.message : '意图路由失败,走默认通道',
memory: routerContext,
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
}
if (!completion?.ok) {
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: completion?.message ?? '意图路由失败,走默认通道',
memory: routerContext,
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
}
const parsed = parseRouterJson(completion.reply);
if (!parsed) {
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0,
reason: '意图路由响应无法解析,走默认通道',
memory: routerContext,
}, { source: 'fallback', fallbackRoute: policy.fallbackRoute }));
}
const classification = finalizeWithCoercion({
...normalizeClassification(parsed, { source: 'llm', fallbackRoute: policy.fallbackRoute }),
providerKeyId: completion.providerKeyId ?? policy.modelProviderKeyId ?? null,
model: completion.model ?? policy.model ?? null,
memory: routerContext,
});
if (
classification.route === CHAT_INTENT_ROUTE.DIRECT_CHAT &&
classification.confidence < policy.minConfidence
) {
return finalizeWithCoercion(normalizeClassification({
...classification,
route: policy.fallbackRoute,
reason: `${classification.reason}(置信度 ${classification.confidence} 低于阈值,走默认通道)`,
}, { source: 'threshold', fallbackRoute: policy.fallbackRoute }));
}
return classification;
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0.5,
reason: '规则未命中,走 skill 默认 Agent 通道',
}, { source: 'fallback' }));
}
return {
@@ -1023,6 +1025,7 @@ export function createChatIntentRouter(options = {}) {
export function createManagedChatIntentRouter({
llmProviderService,
memoryV2 = null,
conversationMemoryService = null,
configService = null,
env = process.env,
logger = console,
@@ -1076,6 +1079,7 @@ export function createManagedChatIntentRouter({
activeRouter = createChatIntentRouter({
llmProviderService,
memoryV2,
conversationMemoryService,
env: state.effectiveEnv,
logger,
});
+107 -99
View File
@@ -7,6 +7,7 @@ import {
buildRouterNormalizedDecision,
CHAT_INTENT_ROUTE,
classifyWithRules,
coercePageDataSkill,
coercePageGenerationSkill,
createChatIntentRouter,
createManagedChatIntentRouter,
@@ -52,6 +53,51 @@ test('classifyWithRules routes page generation to agent orchestration', () => {
assert.equal(result.suggestedSkill, 'static-page-publish');
});
test('classifyWithRules routes page data collection to page-data-collect', () => {
const text = '帮我在页面增加调查问卷,密码 888 后台查看每个用户提交记录,要用 sqlite';
const result = classifyWithRules({
text,
userMessage: {
role: 'user',
content: [{ type: 'text', text }],
metadata: { displayText: text },
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.suggestedSkill, 'page-data-collect');
});
test('coercePageDataSkill overrides static-page-publish when data intent is present', () => {
const text = '帮我在这个页面增加调查问卷,密码 888 查看提交记录';
const coerced = coercePageDataSkill(
{
route: CHAT_INTENT_ROUTE.AGENT,
suggestedSkill: 'static-page-publish',
reason: '明确需要生成或发布页面/文件',
agentBrief: '生成页面',
},
text,
{ grantedSkills: ['page-data-collect', 'static-page-publish'] },
);
assert.equal(coerced.suggestedSkill, 'page-data-collect');
assert.match(coerced.reason, /页面数据交互意图/);
});
test('coercePageGenerationSkill skips when page data intent is present', () => {
const text = '帮我在页面增加调查问卷和后台入口';
const coerced = coercePageGenerationSkill(
{
route: CHAT_INTENT_ROUTE.AGENT,
suggestedSkill: null,
reason: '任务编排',
agentBrief: '',
},
text,
{ grantedSkills: ['static-page-publish', 'page-data-collect'] },
);
assert.notEqual(coerced.suggestedSkill, 'static-page-publish');
});
test('classifyWithRules routes implicit travel guide page requests to static-page-publish', () => {
const result = classifyWithRules({
text: '苏州攻略页面',
@@ -220,14 +266,15 @@ test('classifyWithRules honors forceDeepReasoning bypass', () => {
assert.match(result.reason, /深度推理/);
});
test('classifyWithRules skips session continuation when llm router is enabled', () => {
test('classifyWithRules keeps agent session continuation even when llm router flag is true', () => {
const continued = classifyWithRules({
text: '我想去日本玩',
sessionId: '20260704_10',
sessionMessageCount: 2,
text: '可以',
sessionId: '20260708_64',
sessionMessageCount: 4,
llmRouterEnabled: true,
});
assert.equal(continued, null);
assert.equal(continued.route, CHAT_INTENT_ROUTE.AGENT);
assert.match(continued.reason, /Agent 会话确认|延续已有 Agent 会话/);
});
test('classifyWithRules keeps empty pre-created sessions eligible for llm routing', () => {
@@ -248,7 +295,7 @@ test('classifyWithRules keeps empty pre-created sessions eligible for llm routin
assert.match(fresh.reason, /记忆/);
});
test('classifyWithRules routes memory recall to direct chat even in agent session', () => {
test('classifyWithRules keeps memory recall on agent when session already active', () => {
const result = classifyWithRules({
text: '你记得我说想去哪儿吗',
sessionId: '20260704_9',
@@ -259,8 +306,8 @@ test('classifyWithRules routes memory recall to direct chat even in agent sessio
metadata: { displayText: '你记得我说想去哪儿吗' },
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.match(result.reason, /记忆/);
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.match(result.reason, /延续已有 Agent 会话/);
});
test('classifyWithRules bypasses llm router when user selected summarize skill', async () => {
@@ -327,7 +374,7 @@ test('classifyWithRules bypasses llm router for explicit web skill prompt', asyn
assert.equal(llmCalls.length, 0);
});
test('createChatIntentRouter classifies continued sessions through llm when enabled', async () => {
test('createChatIntentRouter keeps continued sessions on agent without llm router', async () => {
const llmCalls = [];
const router = createChatIntentRouter({
enabled: true,
@@ -336,16 +383,7 @@ test('createChatIntentRouter classifies continued sessions through llm when enab
llmProviderService: {
async createChatCompletion(payload) {
llmCalls.push(payload);
return {
ok: true,
reply: JSON.stringify({
route: 'agent_orchestration',
confidence: 0.88,
reason: '用户要规划日本旅行,需要搜索和生成内容',
suggested_skill: 'static-page-publish',
agent_brief: '整理日本旅行攻略',
}),
};
return { ok: false, message: 'router disabled' };
},
},
memoryV2: {
@@ -366,14 +404,13 @@ test('createChatIntentRouter classifies continued sessions through llm when enab
},
});
assert.equal(llmCalls.length, 1);
assert.equal(llmCalls.length, 0);
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'llm');
assert.match(result.reason, /日本旅行/);
assert.equal(result.memory?.itemsUsed ?? 0, 0);
assert.equal(result.source, 'rule');
assert.match(result.reason, /延续已有 Agent 会话/);
});
test('createChatIntentRouter skips memory resolve for general conversational analysis', async () => {
test('createChatIntentRouter uses agent fallback for general conversational analysis (skill-only)', async () => {
const llmCalls = [];
const resolveCalls = [];
const router = createChatIntentRouter({
@@ -383,18 +420,7 @@ test('createChatIntentRouter skips memory resolve for general conversational ana
llmProviderService: {
async createChatCompletion({ messages, providerKeyId, model, temperature }) {
llmCalls.push({ messages, providerKeyId, model, temperature });
return {
ok: true,
providerKeyId,
model,
reply: JSON.stringify({
route: 'direct_chat',
confidence: 0.91,
reason: '用户在咨询概念,不需要执行工具',
suggested_skill: null,
agent_brief: '',
}),
};
return { ok: false, message: 'router disabled' };
},
},
memoryV2: {
@@ -419,37 +445,19 @@ test('createChatIntentRouter skips memory resolve for general conversational ana
grantedSkills: ['web'],
});
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'llm');
assert.equal(result.providerKeyId, 'key-router');
assert.equal(result.model, 'deepseek-chat');
assert.equal(result.memory?.itemsUsed ?? 0, 0);
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'fallback');
assert.equal(resolveCalls.length, 0);
assert.equal(llmCalls.length, 1);
assert.equal(llmCalls[0].providerKeyId, 'key-router');
assert.equal(llmCalls[0].model, 'deepseek-chat');
assert.equal(llmCalls[0].temperature, 0);
assert.doesNotMatch(llmCalls[0].messages[1].content, /相关记忆/);
assert.equal(llmCalls.length, 0);
});
test('createChatIntentRouter resolves light memory for recall questions', async () => {
test('createChatIntentRouter routes memory recall through rules on fresh session', async () => {
const resolveCalls = [];
const router = createChatIntentRouter({
enabled: true,
modelProviderKeyId: 'key-router',
model: 'deepseek-chat',
llmProviderService: {
async createChatCompletion() {
return {
ok: true,
reply: JSON.stringify({
route: 'direct_chat',
confidence: 0.95,
reason: '用户在询问个人记忆',
suggested_skill: null,
agent_brief: '',
}),
};
return { ok: false, message: 'router disabled' };
},
},
memoryV2: {
@@ -473,9 +481,8 @@ test('createChatIntentRouter resolves light memory for recall questions', async
});
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(resolveCalls.length, 1);
assert.equal(resolveCalls[0].limit, 3);
assert.equal(result.memory?.itemsUsed, 1);
assert.equal(result.source, 'rule');
assert.equal(resolveCalls.length, 0);
});
test('buildRouterContext trims rich Memory V2 resolve payload for routing', () => {
@@ -499,22 +506,12 @@ test('buildRouterContext trims rich Memory V2 resolve payload for routing', () =
assert.doesNotMatch(context.content, /ignored/);
});
test('createChatIntentRouter fails open when Memory V2 resolve fails', async () => {
test('createChatIntentRouter routes memory recall through rules when memory resolve fails', async () => {
const router = createChatIntentRouter({
enabled: true,
llmProviderService: {
async createChatCompletion({ messages }) {
assert.match(messages[1].content, /\[Router Context\]\n无/);
return {
ok: true,
reply: JSON.stringify({
route: 'agent_orchestration',
confidence: 0.82,
reason: '需要工具执行',
suggested_skill: 'web',
agent_brief: '搜索并整理来源',
}),
};
async createChatCompletion() {
return { ok: false, message: 'router disabled' };
},
},
memoryV2: {
@@ -535,11 +532,11 @@ test('createChatIntentRouter fails open when Memory V2 resolve fails', async ()
grantedSkills: ['web'],
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.memory.degraded, true);
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'rule');
});
test('createManagedChatIntentRouter hot-loads admin config and stays closed by default', async () => {
test('createManagedChatIntentRouter hot-loads admin config and stays skill-only', async () => {
const states = [
{
fingerprint: 'off',
@@ -584,7 +581,7 @@ test('createManagedChatIntentRouter hot-loads admin config and stays closed by d
assert.equal(await router.isEnabled(), false);
states.shift();
assert.equal(await router.isEnabled(), true);
assert.equal(await router.isEnabled(), false);
const result = await router.classify({
userMessage: {
role: 'user',
@@ -592,16 +589,19 @@ test('createManagedChatIntentRouter hot-loads admin config and stays closed by d
metadata: { displayText: '帮我概括一下这段材料的要点' },
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(llmCalls[0].providerKeyId, 'key-router');
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'fallback');
assert.equal(llmCalls.length, 0);
});
test('createChatIntentRouter escalates low-confidence direct chat to agent', async () => {
test('createChatIntentRouter uses agent fallback when rules miss (skill-only)', async () => {
const llmCalls = [];
const router = createChatIntentRouter({
enabled: true,
minConfidence: 0.8,
llmProviderService: {
async createChatCompletion() {
llmCalls.push(1);
return {
ok: true,
reply: JSON.stringify({
@@ -625,14 +625,17 @@ test('createChatIntentRouter escalates low-confidence direct chat to agent', asy
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'threshold');
assert.equal(result.source, 'fallback');
assert.equal(llmCalls.length, 0);
});
test('createChatIntentRouter falls back to agent when LLM fails', async () => {
test('createChatIntentRouter falls back to agent when rules miss (skill-only)', async () => {
const llmCalls = [];
const router = createChatIntentRouter({
enabled: true,
llmProviderService: {
async createChatCompletion() {
llmCalls.push(1);
return { ok: false, message: 'router model unavailable' };
},
},
@@ -649,6 +652,17 @@ test('createChatIntentRouter falls back to agent when LLM fails', async () => {
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'fallback');
assert.equal(llmCalls.length, 0);
});
test('classifyWithRules routes memory recall to direct chat on fresh session', () => {
const result = classifyWithRules({
text: '你记得我说想去哪儿吗',
sessionId: '20260704_3',
sessionMessageCount: 0,
});
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.match(result.reason, /记忆/);
});
test('createChatIntentRouter fast-paths news lookup to agent without LLM', async () => {
@@ -867,12 +881,12 @@ test('shadow mode keeps legacy route for gateway decisions', () => {
}
});
test('classifyWithRules fast-paths bedtime story when llm router is enabled', () => {
test('classifyWithRules fast-paths bedtime story on fresh session', () => {
const result = classifyWithRules({
text: '讲一个睡前故事吧',
llmRouterEnabled: true,
sessionId: '20260706_2',
sessionMessageCount: 3,
sessionMessageCount: 0,
userMessage: {
role: 'user',
content: [{ type: 'text', text: '讲一个睡前故事吧' }],
@@ -1066,21 +1080,14 @@ test('logRouterDecisionShadow emits payload only in shadow mode', () => {
}
});
test('createChatIntentRouter accepts chat and agent route synonyms from llm', async () => {
test('createChatIntentRouter uses agent fallback for unmatched general questions (skill-only)', async () => {
const llmCalls = [];
const router = createChatIntentRouter({
enabled: true,
llmProviderService: {
async createChatCompletion() {
return {
ok: true,
reply: JSON.stringify({
route: 'chat',
confidence: 0.92,
reason: '纯问答',
suggested_skill: null,
agent_brief: '',
}),
};
llmCalls.push(1);
return { ok: false, message: 'router disabled' };
},
},
});
@@ -1093,6 +1100,7 @@ test('createChatIntentRouter accepts chat and agent route synonyms from llm', as
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.decision.route, ROUTER_DECISION_ROUTE.CHAT);
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'fallback');
assert.equal(llmCalls.length, 0);
});
+45 -1
View File
@@ -1,5 +1,6 @@
// Keep browser-safe: do not import user-publish.mjs (uses node:fs/path/url).
const PUBLISH_SKILL_NAME = 'static-page-publish';
export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
const WEB_INTENT_PATTERNS = [
/(?:今天|今日|最新|热点|热搜|新闻|头条|头条新闻|最新消息|发生了什么|最近发生)/u,
@@ -27,6 +28,22 @@ const PRODUCT_CAMPAIGN_INTENT_PATTERNS = [
/(?:https?:\/\/|taobao|tmall|jd\.com|商品链接|购买链接|购买按钮)/iu,
];
const PAGE_DATA_INTENT_PATTERNS = [
/(?:问卷|调查|签到表|签到登记|签到收集|投票|意见反馈|数据上报)/u,
/(?:报名表|报名登记|在线报名|收集报名)/u,
/(?:表单|数据采集|数据交互|存数据|保存提交|提交记录)/u,
/(?:后台|管理入口|管理后台).{0,20}(?:查看|记录|数据|提交)/u,
/(?:密码|口令).{0,12}(?:查看|后台|管理|进入)/u,
/(?:sqlite|数据库|page[\s-]?data)/i,
/(?:每个用户|各用户).{0,12}(?:提交|记录)/u,
];
export function isPageDataIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return PAGE_DATA_INTENT_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function isPageGenerationIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
@@ -76,6 +93,15 @@ export const CHAT_SKILL_DEFINITIONS = [
prefillOnly: true,
promptKey: 'form-builder',
},
{
id: 'page-data-collect',
label: '数据页面',
icon: 'form',
skillName: PAGE_DATA_COLLECT_SKILL_NAME,
requiresSkill: PAGE_DATA_COLLECT_SKILL_NAME,
requiresPublish: true,
promptKey: 'page-data-collect',
},
{
id: 'table-view',
label: '数据表格',
@@ -125,6 +151,13 @@ export function buildChatSkillPrompt(promptKey, skillName) {
return `请使用 ${skillName ?? 'search'} 技能:帮我在工作区中查找代码或文件。我要找的是:`;
case 'form-builder':
return `请使用 ${skillName ?? 'form-builder'} 技能:请用交互式表单收集以下场景所需的结构化字段(字段不超过 8 个):`;
case 'page-data-collect':
return (
`请使用 ${skillName ?? PAGE_DATA_COLLECT_SKILL_NAME} 技能:在 MindSpace 页面中实现可提交、可持久化的数据收集(问卷/报名/台账等),必须使用 Page Data API。` +
'先匹配技能内能力分支(默认 A:匿名前台 public insert + 独立后台 password read,口令默认 88888888);展示方案摘要确认后再开工。' +
'流程:load_skill → private_data_execute 建表 → private_data_register_dataset → write_file/edit_file 写 public/*.html(含 /assets/page-data-client.js)→ private_data_bind_workspace_page 发布并写策略。' +
'禁止自建 Express/独立端口(如 8899)、禁止 HTML 硬编码 127.0.0.1 API、禁止连续空转不调用工具。HTML 视觉规范参照 static-page-publish。完成后返回 workspaceUrl,并说明后台入口与口令。'
);
case 'service-integration-smoke':
return `请使用 ${skillName ?? 'service-integration-smoke'} 技能:按标准联调流程检查当前服务,覆盖身份、普通聊天、记忆读取,以及我本轮明确要求验证的技能/发布链路,并输出通过项、失败项、待确认项:`;
case 'table-viewer':
@@ -160,7 +193,12 @@ export { buildWebNewsSkillPrompt };
export function filterChatSkills(options, ctx) {
return options.filter((skill) => {
if (skill.requiresPublish && !ctx.canPublish) return false;
if (skill.requiresPublish && !ctx.canPublish) {
const pageDataGranted =
skill.skillName === PAGE_DATA_COLLECT_SKILL_NAME &&
ctx.grantedSkills?.includes(PAGE_DATA_COLLECT_SKILL_NAME);
if (!pageDataGranted) return false;
}
if (skill.requiresSkill && !ctx.grantedSkills?.includes(skill.requiresSkill)) return false;
return true;
});
@@ -169,6 +207,12 @@ export function filterChatSkills(options, ctx) {
export function buildAutoChatSkillPrefix(text, grantedSkills = []) {
const trimmed = String(text ?? '').trim();
if (!trimmed) return '';
if (
grantedSkills.includes(PAGE_DATA_COLLECT_SKILL_NAME) &&
isPageDataIntent(trimmed)
) {
return buildChatSkillPrompt('page-data-collect', PAGE_DATA_COLLECT_SKILL_NAME);
}
if (grantedSkills.includes(PUBLISH_SKILL_NAME) && isPageGenerationIntent(trimmed)) {
return buildChatSkillPrompt('generate-page', PUBLISH_SKILL_NAME);
}
+23
View File
@@ -5,6 +5,7 @@ import {
buildChatSkillPrompt,
CHAT_SKILL_DEFINITIONS,
filterChatSkills,
isPageDataIntent,
isPageGenerationIntent,
} from './chat-skills.mjs';
@@ -43,6 +44,14 @@ test('filterChatSkills shows service integration smoke when granted', () => {
assert.ok(visible.some((item) => item.id === 'service-integration-smoke'));
});
test('filterChatSkills shows page-data-collect when granted without static publish', () => {
const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, {
canPublish: false,
grantedSkills: ['page-data-collect'],
});
assert.ok(visible.some((item) => item.id === 'page-data-collect'));
});
test('filterChatSkills shows generate-page when publish is allowed', () => {
const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { canPublish: true });
assert.ok(visible.some((item) => item.id === 'generate-page'));
@@ -57,6 +66,8 @@ test('buildChatSkillPrompt includes skill name for platform skills', () => {
assert.match(buildChatSkillPrompt('generate-page'), /public\/\*\.docx/);
assert.match(buildChatSkillPrompt('generate-page'), /long-image-download/);
assert.match(buildChatSkillPrompt('generate-page'), /generate_long_image/);
assert.match(buildChatSkillPrompt('page-data-collect'), /Page Data API/);
assert.match(buildChatSkillPrompt('page-data-collect'), /禁止自建 Express/);
});
test('prefillOnly is set for open-ended chat skills', () => {
@@ -87,6 +98,18 @@ test('isPageGenerationIntent matches implicit travel guide page requests', () =>
assert.equal(isPageGenerationIntent('你好'), false);
});
test('buildAutoChatSkillPrefix prefers page-data-collect for survey requests', () => {
const text = '帮我在这个页面增加一个调查问卷,出三个问题,密码 888 查看提交记录';
const prefix = buildAutoChatSkillPrefix(text, ['page-data-collect', 'static-page-publish']);
assert.match(prefix, /page-data-collect/);
assert.doesNotMatch(prefix, /static-page-publish 技能:在我的专属 MindSpace 发布目录生成静态 HTML/);
});
test('isPageDataIntent matches survey and backend keywords', () => {
assert.equal(isPageDataIntent('增加调查问卷和后台入口,密码 888'), true);
assert.equal(isPageDataIntent('帮我做一个苏州攻略页面'), false);
});
test('buildAutoChatSkillPrefix enables publish for implicit page requests', () => {
const prefix = buildAutoChatSkillPrefix('苏州攻略页面', ['static-page-publish']);
assert.match(prefix, /static-page-publish/);
+26 -8
View File
@@ -139,6 +139,7 @@ function fallbackMemoriesFromMessages(messages) {
{ label: 'goal', re: /(?:我的目标是|我想要|我希望|我打算|我计划|我想去|打算去|准备去)(.+)/ },
{ label: 'habit', re: /(?:我通常|我习惯|我一般)(.+)/ },
{ label: 'fact', re: /(?:我是|我叫|我来自|我在)(.{2,40})/ },
{ label: 'fact', re: /(?:请记住|记住).{0,12}(?:别名|叫做|是)([^。!?\s]{2,40})/ },
{ label: 'experience', re: /(?:我们|我).{0,8}(?:去|到|在).{2,40}(?:玩|旅游|旅行|出差|度假)/ },
];
for (const message of messages) {
@@ -369,10 +370,10 @@ export function createConversationMemoryService(pool, options = {}) {
return rows.length;
}
async function analyzeUser(userId) {
if (!isEnabled() || !pool || !userId) return { ok: false, reason: 'disabled' };
const messages = await loadUnanalyzedUserMessages(userId);
if (!messages.length) return { ok: true, analyzed: 0, memories: 0 };
async function analyzeMessageBatch(userId, messages) {
if (!isEnabled() || !pool || !userId || !messages.length) {
return { ok: true, analyzed: 0, memories: 0 };
}
let memories = null;
if (llmEnabled()) {
try {
@@ -381,18 +382,35 @@ export function createConversationMemoryService(pool, options = {}) {
warnLlmExtractionFailed(err);
}
}
if (!memories) memories = fallbackMemoriesFromMessages(messages);
if (!memories?.length) memories = fallbackMemoriesFromMessages(messages);
const stored = await storeMemories(userId, messages, memories);
// Always mark the batch processed after one pass so transient LLM failures
// do not leave messages permanently stuck in the analyze queue.
await markAnalyzed(messages.map((message) => message.id));
return { ok: true, analyzed: messages.length, memories: stored };
}
async function analyzeUser(userId) {
if (!isEnabled() || !pool || !userId) return { ok: false, reason: 'disabled' };
const messages = await loadUnanalyzedUserMessages(userId);
if (!messages.length) return { ok: true, analyzed: 0, memories: 0 };
return analyzeMessageBatch(userId, messages);
}
async function saveAndAnalyze(sessionId, userId, messages = []) {
const saved = await saveConversationMessages(sessionId, userId, messages);
if (!saved.length) return { saved: 0, analyzed: 0, memories: 0 };
const result = await analyzeUser(userId);
const userSaved = saved
.filter((message) => message.role === 'user')
.map((message) => ({
id: message.id,
user_id: message.userId,
agent_session_id: message.sessionId,
message_key: message.messageKey,
sequence_no: message.sequenceNo,
role: message.role,
text: message.text,
created_at: message.createdAt,
}));
const result = await analyzeMessageBatch(userId, userSaved);
return {
saved: saved.length,
analyzed: result.analyzed ?? 0,
+26
View File
@@ -152,6 +152,32 @@ test('saveAndAnalyze stores messages and fallback memories', async () => {
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze re-analyzes saved session messages even when already marked analyzed', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '0';
const pool = createPool();
const service = createConversationMemoryService(pool, { now: () => 1500 });
const messages = [{
id: 'm-remember',
role: 'user',
content: [{ type: 'text', text: '请记住:我的测试别名是蓝鲸42。只回复已记住。' }],
metadata: { userVisible: true },
}];
const first = await service.saveAndAnalyze('session-remember', 'user-remember', messages);
assert.equal(first.analyzed, 1);
assert.equal(first.memories, 1);
assert.match(pool.state.memories.at(-1).memory_text, /蓝鲸42/);
pool.state.messages[0].analyzed_at = 1500;
const second = await service.saveAndAnalyze('session-remember', 'user-remember', messages);
assert.equal(second.analyzed, 1);
assert.equal(second.memories, 1);
if (previous == null) delete process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
else process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = previous;
});
test('saveAndAnalyze marks messages analyzed when llm extraction fails and no memory is stored', async () => {
const previous = process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED;
process.env.USER_CONVERSATION_MEMORY_LLM_ENABLED = '1';
+5
View File
@@ -42,6 +42,11 @@ export function parseStoredConversationRow(row) {
};
}
export function filterUserVisibleConversation(messages) {
if (!Array.isArray(messages)) return [];
return messages.filter((message) => message?.metadata?.userVisible !== false);
}
export function countNonEmptyConversationMessages(messages) {
if (!Array.isArray(messages)) return 0;
return messages.filter((message) => {
+13
View File
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
import {
buildConversationFromDbRows,
countNonEmptyConversationMessages,
filterUserVisibleConversation,
parseStoredConversationRow,
repairConversationFromDbRows,
shouldRepairConversationFromDb,
@@ -84,3 +85,15 @@ test('buildConversationFromDbRows dedupes by message_key', () => {
assert.equal(built.length, 1);
assert.equal(built[0].content[0].text, 'second');
});
test('filterUserVisibleConversation keeps messages without explicit userVisible flag', () => {
const messages = [
{ role: 'user', content: [{ type: 'text', text: '请记住别名' }] },
{ role: 'assistant', metadata: { userVisible: true }, content: [{ type: 'text', text: '已记住' }] },
{ role: 'system', metadata: { userVisible: false }, content: [{ type: 'text', text: 'hidden' }] },
];
const visible = filterUserVisibleConversation(messages);
assert.equal(visible.length, 2);
assert.equal(visible[0].role, 'user');
assert.equal(visible[1].role, 'assistant');
});
+13 -9
View File
@@ -1,5 +1,7 @@
import crypto from 'node:crypto';
import { MEMORY_INTERVENTION_LIMIT } from './memory-intervention.mjs';
import { resolveMemoriesWithLegacyFallback } from './memory-legacy-fallback.mjs';
import { isMemoryRecallQuestion } from './chat-intent-router.mjs';
import { resolveSessionAccess } from './session-broker.mjs';
export const DIRECT_CHAT_SESSION_PREFIX = 'h5direct_';
@@ -294,15 +296,15 @@ export function createDirectChatService({
}
async function resolveMemories(userId, sessionId, query, { limit = MEMORY_INTERVENTION_LIMIT.LIGHT_DIRECT_CHAT } = {}) {
if (!userId || limit <= 0) return [];
if (memoryV2?.resolve) {
const result = await memoryV2.resolve({ userId, sessionId, query, limit }).catch(() => null);
return Array.isArray(result?.memories) ? result.memories : [];
}
if (conversationMemoryService?.listMemories) {
return conversationMemoryService.listMemories(userId, { limit }).catch(() => []);
}
return [];
return resolveMemoriesWithLegacyFallback({
memoryV2,
conversationMemoryService,
userId,
sessionId,
query,
limit,
recallQuestion: isMemoryRecallQuestion(query),
});
}
async function run({
@@ -348,7 +350,9 @@ export function createDirectChatService({
},
pendingMessages,
);
const recallQuestion = isMemoryRecallQuestion(messageText(userMessage));
const routedMemoryContent =
!recallQuestion &&
routingMemory && !routingMemory.skipped && !routingMemory.degraded
? String(routingMemory.content ?? '').trim()
: '';
+75
View File
@@ -0,0 +1,75 @@
# Page Data 问卷测试案例(TKMind 功能偏好调查)
本地演示:john 用户工作区中的 `tkmind-survey.html` + `tkmind-survey-admin.html`
## 一次性准备
确保 Portal 已启动(`pnpm dev`,默认 `http://localhost:8081`),然后执行:
```bash
node scripts/setup-page-data-survey-demo.mjs
```
脚本会:
- **问卷页**以 `public` 发布(访客可直接提交,无需口令)
- **后台页**以 `password` 发布(口令 **88888888**,平台要求至少 8 位)
## 测试入口
| 页面 | URL |
|------|-----|
| 问卷(访客提交,无需登录) | http://localhost:8081/MindSpace/1c99b83b-0454-474f-a5d2-129d34506a32/public/tkmind-survey.html |
| 后台(查看记录,需口令) | http://localhost:8081/MindSpace/1c99b83b-0454-474f-a5d2-129d34506a32/public/tkmind-survey-admin.html |
后台登录密码:**88888888**
## 测试步骤
### 1. 验证 pageId 自动注入
打开问卷页 → 浏览器开发者工具 → Console:
```javascript
window.__MINDSPACE_PAGE_DATA__
// 问卷页应返回 { pageId: "...", accessMode: "public" }
// 后台页应返回 { pageId: "...", accessMode: "password" }
```
### 2. 提交问卷
1. 打开问卷链接
2. 逐步完成 3 道题并提交
3. 应看到「感谢你的反馈」成功页
### 3. 后台查看
1. 打开后台链接
2. 输入密码 `88888888`
3. 表格中应出现刚提交的记录
4. 可点「导出 CSV」验证
### 4. 确认无旁路 API
```bash
lsof -i :8899 # 应无 survey-api 进程
```
数据读写应全部走同域 `/api/public/pages/:pageId/data/...`
## Agent 复现流程(供对照)
```text
load_skill → page-data-collect
private_data_execute # CREATE TABLE survey_responses ...
private_data_register_dataset
write_file # public/tkmind-survey.html + admin.html
private_data_bind_workspace_page # 问卷页 + 后台页各 bind 一次
```
HTML 中只需:
```javascript
MindSpacePageData.createClient({ apiBase: '/api' });
```
无需手写 `pageId`
+4 -3
View File
@@ -4,9 +4,10 @@
## 快速路径
1. **Agent** `private_data_execute` 建表,用 `private_data_register_dataset` 注册 dataset
1. **Agent** 加载 `page-data-collect` 技能(或按该技能流程执行)
2. **Agent**`private_data_execute` 建表,用 `private_data_register_dataset` 注册 dataset。
2. **发布页面** 时在发布面板勾选「启用页面数据」,选择 dataset 与公开能力;或发布后让 Agent 调用 `private_data_set_page_policy`
3. **HTML 页面** 引入 `/assets/page-data-client.js`,用 `MindSpacePageData.createClient({ pageId })` 读写数据
3. **HTML 页面** 引入 `/assets/page-data-client.js`,用 `MindSpacePageData.createClient({ apiBase: '/api' })` 读写数据(`pageId` 可由平台在访问时自动注入)
4. **运维** 在 MindSpace 页面详情「页面数据」面板查看日志、导出、撤销令牌、恢复软删除、关闭 dataset。
## 访问模式与默认能力
@@ -59,8 +60,8 @@ GET /api/public/pages/:pageId/data/:dataset/stats
```html
<script src="/assets/page-data-client.js"></script>
<script>
// pageId 可省略:已绑定/已发布的页面访问时会注入 window.__MINDSPACE_PAGE_DATA__
const client = MindSpacePageData.createClient({
pageId: '你的页面 UUID',
apiBase: '/api',
});
+30
View File
@@ -0,0 +1,30 @@
/**
* Insert markup before the document's closing </body> or </head>, ignoring
* occurrences inside inline scripts, styles, or string literals.
*/
function maskHtmlLiteralRegions(html) {
return String(html ?? '').replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, (match) =>
' '.repeat(match.length),
).replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, (match) => ' '.repeat(match.length));
}
export function injectBeforeDocumentClosingBody(html, markup) {
const source = String(html ?? '');
const injection = String(markup ?? '');
if (!injection) return source;
const masked = maskHtmlLiteralRegions(source).toLowerCase();
const lastIndex = masked.lastIndexOf('</body>');
if (lastIndex < 0) return `${source}${injection}`;
return `${source.slice(0, lastIndex)}${injection}${source.slice(lastIndex)}`;
}
export function injectBeforeDocumentClosingHead(html, markup) {
const source = String(html ?? '');
const injection = String(markup ?? '');
if (!injection) return source;
const masked = maskHtmlLiteralRegions(source).toLowerCase();
const index = masked.indexOf('</head>');
if (index < 0) return `${injection}${source}`;
return `${source.slice(0, index)}${injection}${source.slice(index)}`;
}
+28
View File
@@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
injectBeforeDocumentClosingBody,
injectBeforeDocumentClosingHead,
} from './html-document-injection.mjs';
test('injectBeforeDocumentClosingBody skips </body> inside inline scripts', () => {
const html = `<html><body>
<script>
var x='<html><body>'+tb+'</body></html>';
</script>
<p>content</p>
</body></html>`;
const result = injectBeforeDocumentClosingBody(html, '<footer>brand</footer>\n');
assert.match(result, /\+tb\+'<\/body><\/html>'/);
assert.match(result, /<footer>brand<\/footer>\s*<\/body><\/html>$/);
assert.equal((result.match(/<\/body>/gi) ?? []).length, 2);
});
test('injectBeforeDocumentClosingHead uses the last </head>', () => {
const html = `<html><head><title>x</title></head><body>
<script>var h='</head>';</script>
</body></html>`;
const result = injectBeforeDocumentClosingHead(html, '<style>body{}</style>\n');
assert.match(result, /var h='<\/head>'/);
assert.match(result, /<style>body\{\}<\/style>\s*<\/head><body>/);
});
+78
View File
@@ -0,0 +1,78 @@
const CJK_QUERY_STOP_WORDS = new Set([
'之前', '什么', '哪个', '多少', '记得', '记住', '有没有', '是否', '我的', '你是',
'我们', '一下', '可以', '这个', '那个', '请问', '告诉', '说过', '提到', '聊过',
]);
function extractQueryTokens(query) {
const q = String(query ?? '').trim();
if (!q) return [];
const tokens = new Set();
for (const part of q.replace(/[^\p{L}\p{N}]+/gu, ' ').split(/\s+/)) {
const token = part.trim();
if (token.length >= 2 && !CJK_QUERY_STOP_WORDS.has(token)) tokens.add(token);
}
for (const run of q.match(/[\p{Script=Han}]{2,}/gu) ?? []) {
if (run.length <= 6 && !CJK_QUERY_STOP_WORDS.has(run)) tokens.add(run);
for (let i = 0; i < run.length - 1; i += 1) {
const bigram = run.slice(i, i + 2);
if (!CJK_QUERY_STOP_WORDS.has(bigram)) tokens.add(bigram);
}
}
return [...tokens];
}
function scoreMemoryForRecall(text, tokens) {
let score = 0;
for (const token of tokens) {
if (text.includes(token)) score += 1;
}
if (/别名.{0,8}是/u.test(text)) score += 5;
if (/[?]\s*$/.test(text)) score -= 3;
if (/^用户记住/u.test(text)) score -= 2;
return score;
}
export function filterMemoriesByQuery(memories, query) {
const items = Array.isArray(memories) ? memories : [];
const tokens = extractQueryTokens(query);
if (!tokens.length || !items.length) return items.slice(0, Math.min(items.length, 5));
const matched = items
.map((item) => {
const text = String(item?.text ?? item?.memory_text ?? '').trim();
return { item, text, score: scoreMemoryForRecall(text, tokens) };
})
.filter(({ text, score }) => text && score > 0)
.sort((a, b) => b.score - a.score)
.map(({ item }) => item);
return matched.length ? matched.slice(0, 5) : items.slice(0, 5);
}
export async function resolveMemoriesWithLegacyFallback({
memoryV2 = null,
conversationMemoryService = null,
userId,
sessionId = null,
query = null,
limit = 20,
recallQuestion = false,
} = {}) {
if (!userId || limit <= 0) return [];
if (recallQuestion && conversationMemoryService?.listMemories) {
const legacy = await conversationMemoryService.listMemories(userId, { limit }).catch(() => []);
const filtered = filterMemoriesByQuery(legacy, query);
if (filtered.length) return filtered;
}
if (memoryV2?.resolve) {
const resolved = await memoryV2.resolve({
userId,
sessionId,
query,
limit,
}).catch(() => null);
const memories = Array.isArray(resolved?.memories) ? resolved.memories : [];
if (memories.length) return memories;
}
if (!conversationMemoryService?.listMemories) return [];
const legacy = await conversationMemoryService.listMemories(userId, { limit }).catch(() => []);
return filterMemoriesByQuery(legacy, query);
}
+41
View File
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { filterMemoriesByQuery } from './memory-legacy-fallback.mjs';
import { isMemoryRecallQuestion } from './chat-intent-router.mjs';
test('filterMemoriesByQuery matches query tokens in memory text', () => {
const memories = [
{ text: '用户的测试别名是蓝鲸42' },
{ text: 'TKMind H5 聊天助手默认回复' },
{ text: '无关记忆条目' },
];
const matched = filterMemoriesByQuery(memories, '我之前让你记住的测试别名是什么');
assert.equal(matched.length, 1);
assert.match(matched[0].text, /蓝鲸42/);
});
test('isMemoryRecallQuestion matches remember-alias recall prompts', () => {
assert.equal(isMemoryRecallQuestion('我之前让你记住的测试别名是什么'), true);
assert.equal(isMemoryRecallQuestion('记住的测试别名是什么'), true);
});
test('resolveMemoriesWithLegacyFallback prefers legacy on recall questions', async () => {
const { resolveMemoriesWithLegacyFallback } = await import('./memory-legacy-fallback.mjs');
const memories = await resolveMemoriesWithLegacyFallback({
memoryV2: {
async resolve() {
return { memories: [{ text: '用户当前登录称呼为John' }] };
},
},
conversationMemoryService: {
async listMemories() {
return [{ text: '用户的测试别名是蓝鲸42' }];
},
},
userId: 'user-1',
query: '我之前让你记住的测试别名是什么?',
limit: 5,
recallQuestion: true,
});
assert.match(memories[0].text, /蓝鲸42/);
});
+3
View File
@@ -209,6 +209,9 @@ function applyEnv(config, secrets, env = process.env) {
if (!normalizeString(config.chatIntentRouter.timeoutMs)) {
config.chatIntentRouter.timeoutMs = '2500';
}
if (config.chatIntentRouter.enabled === undefined) {
config.chatIntentRouter.enabled = false;
}
const fallbackRoute = normalizeString(config.chatIntentRouter.fallbackRoute);
if (!fallbackRoute || fallbackRoute === 'direct_chat') {
config.chatIntentRouter.fallbackRoute = 'agent_orchestration';
+1
View File
@@ -151,6 +151,7 @@ const HTML_RULES = [
const ACKNOWLEDGEABLE_HTML_ACTIVE_TYPES = new Set([
'html_script',
'html_inline_handler',
'html_form_action',
]);
const TRUSTED_EXTERNAL_HOSTS = new Set([
+16
View File
@@ -51,6 +51,22 @@ test('scanContent can allow active html as acknowledgeable publication warnings'
);
});
test('scanContent can allow interactive html forms as acknowledgeable warnings', () => {
const result = scanContent(
'<form id="survey"><input name="q1"></form><script src="/assets/page-data-client.js"></script>',
{ format: 'html', allowHtmlActiveContent: true },
);
assert.equal(result.status, 'warned');
assert.equal(result.allowed, true);
assert.deepEqual(
result.findings.map((finding) => [finding.type, finding.blocking]),
[
['html_script', false],
['html_form_action', false],
],
);
});
test('redactContent masks secrets and strips unsafe html', () => {
const result = redactContent(
'手机号 13800138000\n邮箱 john@example.com\n<script>alert(1)</script>\nkey=sk_test_1234567890abcdef',
+22 -5
View File
@@ -31,7 +31,7 @@ function extractHtmlSummary(content) {
async function findPageByWorkspaceRelativePath(pool, userId, relativePath) {
const [rows] = await pool.query(
`SELECT p.id, p.updated_at
`SELECT p.id, p.updated_at, pv.version_no
FROM h5_page_records p
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
WHERE p.user_id = ?
@@ -49,7 +49,11 @@ async function findPageByWorkspaceRelativePath(pool, userId, relativePath) {
);
const row = rows[0];
if (!row) return null;
return { id: row.id, updatedAt: Number(row.updated_at ?? 0) };
return {
id: row.id,
updatedAt: Number(row.updated_at ?? 0),
versionNo: Number(row.version_no ?? 0) || null,
};
}
function parseJsonColumn(value, fallback = {}) {
@@ -64,7 +68,8 @@ function parseJsonColumn(value, fallback = {}) {
async function loadIndexedWorkspacePages(pool, userId) {
const [rows] = await pool.query(
`SELECT p.id, p.updated_at, p.source_asset_id, p.workspace_relative_path, pv.source_snapshot_json
`SELECT p.id, p.updated_at, p.source_asset_id, p.workspace_relative_path,
pv.version_no, pv.source_snapshot_json
FROM h5_page_records p
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
WHERE p.user_id = ? AND p.status <> 'deleted'`,
@@ -80,10 +85,15 @@ async function loadIndexedWorkspacePages(pool, userId) {
byPath.set(relativePath, {
id: row.id,
updatedAt: Number(row.updated_at ?? 0),
versionNo: Number(row.version_no ?? 0) || null,
});
}
if (row.source_asset_id && relativePath) {
byPath.set(`asset:${row.source_asset_id}`, { id: row.id, updatedAt: Number(row.updated_at ?? 0) });
byPath.set(`asset:${row.source_asset_id}`, {
id: row.id,
updatedAt: Number(row.updated_at ?? 0),
versionNo: Number(row.version_no ?? 0) || null,
});
}
}
return byPath;
@@ -169,9 +179,16 @@ async function upsertWorkspaceHtmlPage({
}
await pageService.updatePage(userId, existing.id, {
...pageInput,
expectedVersion: existing.versionNo ?? undefined,
changeNote: '同步工作区页面更新',
}, {
snapshot,
});
indexedPages.set(relativePath, {
id: existing.id,
updatedAt: Date.now(),
versionNo: existing.versionNo != null ? existing.versionNo + 1 : null,
});
indexedPages.set(relativePath, { id: existing.id, updatedAt: Date.now() });
return 'updated';
}
+4 -3
View File
@@ -145,7 +145,7 @@ test('syncGeneratedPagesFromPublicAssets reuses db page when memory index is emp
const pool = {
async query(sql) {
if (sql.includes('JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json')) {
return [[{ id: 'page-existing', updated_at: 100 }]];
return [[{ id: 'page-existing', updated_at: 100, version_no: 3 }]];
}
if (sql.includes('FROM h5_page_records p')) return [[]];
if (sql.includes('FROM h5_assets a')) return [[]];
@@ -157,8 +157,8 @@ test('syncGeneratedPagesFromPublicAssets reuses db page when memory index is emp
created.push(true);
return { id: 'page-new' };
},
async updatePage(userId, pageId) {
updated.push({ userId, pageId });
async updatePage(userId, pageId, input) {
updated.push({ userId, pageId, input });
return { id: pageId };
},
};
@@ -175,4 +175,5 @@ test('syncGeneratedPagesFromPublicAssets reuses db page when memory index is emp
assert.equal(created.length, 0);
assert.equal(updated.length, 1);
assert.equal(updated[0].pageId, 'page-existing');
assert.equal(updated[0].input.expectedVersion, 3);
});
+4 -2
View File
@@ -1,3 +1,5 @@
import { injectBeforeDocumentClosingBody, injectBeforeDocumentClosingHead } from './html-document-injection.mjs';
export const MINDSPACE_PAGE_TAG_ATTR = 'data-mindspace-page-tag';
export const MINDSPACE_PAGE_TAG_PLATFORM_BRAND = 'platform-brand';
export const PLATFORM_BRAND_TEXT = 'TKMind · 智趣';
@@ -61,7 +63,7 @@ export function ensurePlatformBrandFooter(html) {
if (hasPlatformBrandMarker(normalized)) return normalized;
const footer = `<p ${MINDSPACE_PAGE_TAG_ATTR}="${MINDSPACE_PAGE_TAG_PLATFORM_BRAND}">${PLATFORM_BRAND_TEXT}</p>`;
if (/<\/body>/i.test(normalized)) {
return normalized.replace(/<\/body>/i, `${footer}\n</body>`);
return injectBeforeDocumentClosingBody(normalized, `${footer}\n`);
}
return `${normalized}\n${footer}`;
}
@@ -71,7 +73,7 @@ export function injectPlatformBrandVisibilityStyle(html) {
const source = String(html ?? '');
if (!hasPlatformBrandMarker(source) || source.includes(PLATFORM_BRAND_STYLE_ID)) return source;
if (/<\/head>/i.test(source)) {
return source.replace(/<\/head>/i, `${PLATFORM_BRAND_VISIBILITY_STYLE}\n</head>`);
return injectBeforeDocumentClosingHead(source, `${PLATFORM_BRAND_VISIBILITY_STYLE}\n`);
}
return `${PLATFORM_BRAND_VISIBILITY_STYLE}${source}`;
}
+8 -4
View File
@@ -670,9 +670,13 @@ export function createPageService(pool, options = {}) {
AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
)
)
ORDER BY p.updated_at DESC, p.id DESC
ORDER BY
CASE WHEN p.workspace_relative_path = ? THEN 0 ELSE 1 END,
CASE WHEN pr.id IS NOT NULL THEN 0 ELSE 1 END,
p.updated_at DESC,
p.id DESC
LIMIT 1`,
[userId, normalized, normalized],
[userId, normalized, normalized, normalized],
);
return rows[0] ? pageResponse(rows[0]) : null;
};
@@ -1481,8 +1485,8 @@ export function createPageService(pool, options = {}) {
},
}),
createPage: (userId, input) => createVersion(userId, input, { type: 'template' }),
updatePage: (userId, pageId, input) =>
createVersion(userId, { ...input, pageId }, { type: 'generated' }),
updatePage: (userId, pageId, input, source = {}) =>
createVersion(userId, { ...input, pageId }, { type: 'generated', ...source }),
localizePrivateResources,
redactPage,
createRedactedCopy: redactPage,
+5
View File
@@ -1,6 +1,7 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { injectMindSpacePageDataContext } from './mindspace-public-page-context.mjs';
import { preparePublishedPlatformBrand } from './mindspace-page-tag.mjs';
import { injectPublicImageRetryScript } from './mindspace-public-image-retry.mjs';
@@ -50,6 +51,7 @@ export function decorateMindSpacePublishedHtml({
html,
embed = false,
isOwner = true,
pageDataContext = null,
context,
htmlFilePath = '',
fileExists = fs.existsSync,
@@ -92,6 +94,9 @@ export function decorateMindSpacePublishedHtml({
if (!embed) {
nextHtml = injectPublicImageRetryScript(nextHtml).html;
}
if (pageDataContext?.pageId) {
nextHtml = injectMindSpacePageDataContext(nextHtml, pageDataContext);
}
const scriptHashes = collectInlineScriptHashes(nextHtml);
return {
html: nextHtml,
+24 -1
View File
@@ -101,6 +101,7 @@ test('decorateMindSpacePublishedHtml returns decorated html and csp', () => {
const result = decorateMindSpacePublishedHtml({
html: `<html><body><script>${pageScript}</script>demo</body></html>`,
embed: false,
pageDataContext: { pageId: 'page-abc', accessMode: 'password' },
context: {
origin: 'https://m.tkmind.cn',
pageUrl: 'https://m.tkmind.cn/MindSpace/u/public/report.html',
@@ -126,13 +127,14 @@ test('decorateMindSpacePublishedHtml returns decorated html and csp', () => {
assert.match(result.html, /wechat:/);
assert.match(result.html, /dataset\.share/);
assert.match(result.html, /data-mindspace-image-retry="1"/);
assert.match(result.html, /__MINDSPACE_PAGE_DATA__/);
assert.equal(result.allowEmbedFrame, false);
assert.equal(sharedIsOwner, true);
const options = JSON.parse(result.csp);
assert.equal(options.embed, false);
assert.equal(options.wechatShare, true);
assert.deepEqual(options.scriptHashes, collectInlineScriptHashes(result.html));
assert.equal(options.scriptHashes.length, 3);
assert.equal(options.scriptHashes.length, 4);
});
test('decorateMindSpacePublishedHtml forwards isOwner=false to the share button injector', () => {
@@ -172,3 +174,24 @@ test('decorateMindSpacePublishedHtml supports embed mode without share injection
assert.equal(result.allowEmbedFrame, true);
assert.equal(result.csp, JSON.stringify({ embed: true, wechatShare: false, scriptHashes: [] }));
});
test('decorateMindSpacePublishedHtml does not break inline scripts containing </body>', () => {
const pageScript = "var x='<html><body>'+tb+'</body></html>';";
const result = decorateMindSpacePublishedHtml({
html: `<html><body><script>${pageScript}</script><p>demo</p></body></html>`,
embed: false,
isOwner: true,
context: { origin: '', pageUrl: '', pageDirUrl: '', fallbackImageUrl: '' },
preparePublicationHtmlForEmbed: (value) => value,
injectOgTags: (value) => value,
injectWechatShareBridge: (value) => value,
injectPublicFileShareButton: (value) => ({ html: value, scriptHashes: [] }),
publishedPageCsp: () => '',
isWechatUserAgent: () => false,
});
assert.ok(result.html.includes(pageScript));
assert.doesNotThrow(() => {
const inline = [...result.html.matchAll(/<script(?![^>]*\bsrc)[^>]*>([\s\S]*?)<\/script>/gi)].pop()[1];
new Function(inline);
});
});
+2 -1
View File
@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import { injectBeforeDocumentClosingBody } from './html-document-injection.mjs';
/**
* Public MindSpace pages (workspace `/MindSpace/<userId>/public/*.html`) reference images via
@@ -51,7 +52,7 @@ export function injectPublicImageRetryScript(html) {
const markup = `<script ${MARKER_ATTR}="1">${IMAGE_RETRY_SCRIPT}</script>`;
if (/<\/body>/i.test(source)) {
return {
html: source.replace(/<\/body>/i, `${markup}</body>`),
html: injectBeforeDocumentClosingBody(source, markup),
scriptHashes: [IMAGE_RETRY_SCRIPT_HASH],
};
}
+54
View File
@@ -1,5 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { PUBLISH_KEY_UUID, PUBLISH_ROOT_DIR } from './user-publish.mjs';
import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
const LOCAL_HOST_PATTERN = /^(localhost|127\.0\.0\.1|\[::1\]|192\.168\.|10\.|100\.)/i;
@@ -48,6 +50,58 @@ export function buildPublishedHtmlViewContext({
};
}
export function parseMindSpacePublishFilePath(absoluteFilePath, h5Root) {
const resolved = path.resolve(String(absoluteFilePath ?? ''));
const publishRoot = path.resolve(h5Root, PUBLISH_ROOT_DIR);
if (resolved !== publishRoot && !resolved.startsWith(`${publishRoot}${path.sep}`)) {
return null;
}
const rest = resolved.slice(publishRoot.length + 1).split(path.sep).filter(Boolean);
if (rest.length < 2) return null;
const ownerKey = String(rest[0] ?? '').trim().toLowerCase();
if (!PUBLISH_KEY_UUID.test(ownerKey)) return null;
const relativePath = normalizeWorkspaceRelativePath(rest.slice(1).join('/'));
if (!relativePath) return null;
return { userId: ownerKey, relativePath };
}
export function buildMindSpacePageDataPayload({ pageId, accessMode = null } = {}) {
const id = String(pageId ?? '').trim();
if (!id) return null;
const payload = { pageId: id };
const mode = String(accessMode ?? '').trim();
if (mode) payload.accessMode = mode;
return payload;
}
export function injectMindSpacePageDataContext(html, context = {}) {
const payload = buildMindSpacePageDataPayload(context);
if (!payload) return String(html ?? '');
let nextHtml = String(html ?? '');
const serialized = JSON.stringify(payload).replace(/</g, '\\u003c');
const escapedPageId = String(payload.pageId).replace(/"/g, '&quot;');
const injection = [
`<meta name="mindspace-page-data-page-id" content="${escapedPageId}">`,
`<script>window.__MINDSPACE_PAGE_DATA__=${serialized};</script>`,
].join('\n');
if (/<\/head>/i.test(nextHtml)) {
return nextHtml.replace(/<\/head>/i, `${injection}\n</head>`);
}
if (/<body\b/i.test(nextHtml)) {
return nextHtml.replace(/<body\b/i, `${injection}\n<body`);
}
return `${injection}\n${nextHtml}`;
}
export function injectPublishedPageDataContext(html, { pageSource = null, publication = null } = {}) {
const pageId = String(pageSource?.pageId ?? publication?.pageId ?? '').trim();
if (!pageId) return String(html ?? '');
return injectMindSpacePageDataContext(html, {
pageId,
accessMode: publication?.accessMode ?? null,
});
}
export const mindspacePublicPageContextInternals = {
LOCAL_HOST_PATTERN,
};
+39 -36
View File
@@ -1,46 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildPublishedHtmlViewContext, resolvePublicRequestOrigin } from './mindspace-public-page-context.mjs';
import path from 'node:path';
import {
buildMindSpacePageDataPayload,
injectMindSpacePageDataContext,
injectPublishedPageDataContext,
parseMindSpacePublishFilePath,
} from './mindspace-public-page-context.mjs';
test('resolvePublicRequestOrigin prefers https for public hosts and forwarded proto for local hosts', () => {
assert.equal(
resolvePublicRequestOrigin({ hostHeader: 'm.tkmind.cn', forwardedProto: 'http', protocol: 'http' }),
'https://m.tkmind.cn',
);
assert.equal(
resolvePublicRequestOrigin({ hostHeader: '127.0.0.1:8081', forwardedProto: 'http', protocol: 'https' }),
'http://127.0.0.1:8081',
test('parseMindSpacePublishFilePath extracts userId and relative path', () => {
const h5Root = '/srv/memind';
const filePath = path.join(
h5Root,
'MindSpace',
'1c99b83b-0454-474f-a5d2-129d34506a32',
'public',
'survey.html',
);
const parsed = parseMindSpacePublishFilePath(filePath, h5Root);
assert.deepEqual(parsed, {
userId: '1c99b83b-0454-474f-a5d2-129d34506a32',
relativePath: 'public/survey.html',
});
});
test('buildPublishedHtmlViewContext builds file and directory page urls', () => {
const direct = buildPublishedHtmlViewContext({
origin: 'https://m.tkmind.cn',
requestPath: '/MindSpace/user-1/public/report.html?x=1',
filePath: '/tmp/MindSpace/user-1/public/report.html',
test('injectMindSpacePageDataContext adds meta and script before head close', () => {
const html = '<html><head><title>demo</title></head><body>ok</body></html>';
const next = injectMindSpacePageDataContext(html, {
pageId: 'page-123',
accessMode: 'password',
});
assert.equal(direct.pageUrl, 'https://m.tkmind.cn/MindSpace/user-1/public/report.html');
assert.equal(direct.pageDirUrl, 'https://m.tkmind.cn/MindSpace/user-1/public/');
const implicit = buildPublishedHtmlViewContext({
origin: 'https://m.tkmind.cn',
requestPath: '/MindSpace/user-1/public/report',
filePath: '/tmp/MindSpace/user-1/public/index.html',
});
assert.equal(implicit.pageUrl, 'https://m.tkmind.cn/MindSpace/user-1/public/report/');
assert.equal(implicit.pageDirUrl, 'https://m.tkmind.cn/MindSpace/user-1/public/report/');
assert.match(next, /meta name="mindspace-page-data-page-id" content="page-123"/);
assert.match(next, /window\.__MINDSPACE_PAGE_DATA__=\{"pageId":"page-123","accessMode":"password"\}/);
});
test('buildPublishedHtmlViewContext adds thumbnail png fallback when svg sibling exists', () => {
const context = buildPublishedHtmlViewContext({
origin: 'https://m.tkmind.cn',
requestPath: '/MindSpace/user-1/public/report.html',
filePath: '/tmp/MindSpace/user-1/public/report.html',
fileExists: (value) => value.endsWith('.thumbnail.svg'),
thumbnailPngPathForSvg: (value) => value.replace('.svg', '.png'),
});
assert.equal(
context.fallbackImageUrl,
'https://m.tkmind.cn/MindSpace/user-1/public/report.thumbnail.png',
);
test('buildMindSpacePageDataPayload returns null without pageId', () => {
assert.equal(buildMindSpacePageDataPayload({}), null);
});
test('injectPublishedPageDataContext injects pageId from publication resolve result', () => {
const html = '<html><head></head><body><script src="/assets/page-data-client.js"></script></body></html>';
const next = injectPublishedPageDataContext(html, {
pageSource: { pageId: '2b201736-03ca-4172-affd-00f2a0f700b5' },
publication: { accessMode: 'public' },
});
assert.match(next, /meta name="mindspace-page-data-page-id" content="2b201736-03ca-4172-affd-00f2a0f700b5"/);
assert.match(next, /window\.__MINDSPACE_PAGE_DATA__=\{"pageId":"2b201736-03ca-4172-affd-00f2a0f700b5","accessMode":"public"\}/);
});
+2 -1
View File
@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import { injectBeforeDocumentClosingBody } from './html-document-injection.mjs';
const PUBLIC_FILE_SHARE_SCRIPT = `(function(){
var root=document.querySelector('[data-mindspace-public-share]');
@@ -199,7 +200,7 @@ ${plazaButtonMarkup} <button type="button" data-action="capture">保存长图
<script>${PUBLIC_FILE_SHARE_SCRIPT}</script>`;
if (/<\/body>/i.test(source)) {
return {
html: source.replace(/<\/body>/i, `${markup}</body>`),
html: injectBeforeDocumentClosingBody(source, markup),
scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH],
};
}
+69 -9
View File
@@ -3,7 +3,8 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import { localizeGoogleFontsCss } from './mindspace-html-localize.mjs';
import { replacePrivateResourceReferences, scanContent } from './mindspace-content-scan.mjs';
import { pageInternals } from './mindspace-pages.mjs';
import { pageInternals, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
import { readWorkspacePublishHtml } from './mindspace-workspace-path.mjs';
import { loadMindSpaceConfig } from './mindspace-config.mjs';
import { buildPublicUrl, resolvePublicBaseUrl, resolvePublishDir } from './user-publish.mjs';
import {
@@ -465,6 +466,7 @@ export function createPublicationService(pool, options = {}) {
const loadVersion = async (userId, pageId, pageVersionId) => {
const [rows] = await pool.query(
`SELECT p.id AS page_id, p.title, p.summary, p.page_type, p.template_id, p.current_version_id,
p.workspace_relative_path,
p.source_session_id, p.source_message_id,
p.user_id, p.space_id, pv.id AS page_version_id, pv.version_no,
pv.bundle_asset_id, av.storage_key,
@@ -501,6 +503,19 @@ export function createPublicationService(pool, options = {}) {
const preparePublishContent = async (page, ownerSlug, urlSlug) => {
let publishContent = page.content;
if (page.page_type !== 'html') return publishContent;
const workspaceRelativePath = normalizeWorkspaceRelativePath(
page.workspace_relative_path ?? page.source_relative_path,
);
if (h5Root && workspaceRelativePath) {
const workspaceHtml = await readWorkspacePublishHtml(
h5Root,
page.user_id,
workspaceRelativePath,
);
if (workspaceHtml && workspaceHtml.length > String(publishContent ?? '').length) {
publishContent = workspaceHtml;
}
}
const publishDir = h5Root ? resolvePublishDir(h5Root, { id: page.user_id }) : null;
return prepareHtmlPublishContent({
pool,
@@ -969,7 +984,7 @@ export function createPublicationService(pool, options = {}) {
});
};
const updatePublicationStatus = async (userId, publicationId, { accessMode, expiresAt }) => {
const updatePublicationAccess = async (userId, publicationId, { accessMode, password, expiresAt } = {}) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
@@ -981,15 +996,30 @@ export function createPublicationService(pool, options = {}) {
const publication = rows[0];
if (!publication) throw publicationError('发布记录不存在', 'publication_not_found');
const normalizedMode = normalizeAccessMode(accessMode);
const normalizedExpiresAt = normalizeExpiresAt(expiresAt, normalizedMode === 'time_limited');
const normalizedMode =
accessMode != null ? normalizeAccessMode(accessMode) : publication.access_mode;
const normalizedExpiresAt =
expiresAt !== undefined
? normalizeExpiresAt(expiresAt, normalizedMode === 'time_limited')
: publication.expires_at;
let passwordHash = null;
if (normalizedMode === 'password') {
const explicit = String(password ?? '').trim();
if (explicit) {
passwordHash = hashPassword(normalizePassword(explicit, true));
} else if (publication.password_hash) {
passwordHash = publication.password_hash;
} else {
throw publicationError('访问密码长度必须为 8 到 128 个字符', 'invalid_publish_input');
}
}
const now = Date.now();
await conn.query(
`UPDATE h5_publish_records
SET access_mode = ?, expires_at = ?, user_confirmed_at = ?, updated_at = ?
SET access_mode = ?, expires_at = ?, password_hash = ?, user_confirmed_at = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[normalizedMode, normalizedExpiresAt, now, now, publicationId, userId],
[normalizedMode, normalizedExpiresAt, passwordHash, now, now, publicationId, userId],
);
await conn.commit();
@@ -997,6 +1027,7 @@ export function createPublicationService(pool, options = {}) {
...publication,
access_mode: normalizedMode,
expires_at: normalizedExpiresAt,
password_hash: passwordHash,
user_confirmed_at: now,
updated_at: now,
};
@@ -1009,6 +1040,10 @@ export function createPublicationService(pool, options = {}) {
}
};
const updatePublicationStatus = async (userId, publicationId, { accessMode, expiresAt }) => {
return updatePublicationAccess(userId, publicationId, { accessMode, expiresAt });
};
const offline = async (userId, publicationId) => {
const conn = await pool.getConnection();
try {
@@ -1097,7 +1132,7 @@ export function createPublicationService(pool, options = {}) {
now,
],
);
const html = await fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8');
const html = await readPublicationHtmlWithWorkspaceFallback(row);
return {
html: await refreshPublishedHtmlDownloadLinks(row, html),
publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }),
@@ -1111,6 +1146,24 @@ export function createPublicationService(pool, options = {}) {
};
};
// REGRESSION GUARD: mindspace-page-sync-thumbnail — publication delivery must fall back to
// workspace HTML when publication storage snapshot is missing (isolated env / migration).
async function readPublicationHtmlWithWorkspaceFallback(row) {
try {
return await fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8');
} catch (error) {
const storageMissing = error?.code === 'ENOENT' || error?.code === 'storage_not_found';
if (!storageMissing || !h5Root) throw error;
const workspaceRelativePath = normalizeWorkspaceRelativePath(
row.workspace_relative_path ?? row.source_relative_path,
);
if (!workspaceRelativePath) throw error;
const workspaceHtml = await readWorkspacePublishHtml(h5Root, row.owner_id, workspaceRelativePath);
if (!workspaceHtml) throw error;
return workspaceHtml;
}
}
const refreshPublishedHtmlDownloadLinks = async (row, html) => {
if (!h5Root || !row?.owner_id) return html;
const source = String(html ?? '');
@@ -1133,7 +1186,10 @@ export function createPublicationService(pool, options = {}) {
const resolvePublic = async (ownerSlug, urlSlug, viewerId, password, requestMeta) => {
const [rows] = await pool.query(
`SELECT pr.*, u.id AS owner_id, p.title, p.source_session_id, p.source_message_id, av.storage_key
`SELECT pr.*, u.id AS owner_id, p.title, p.source_session_id, p.source_message_id,
p.workspace_relative_path,
av.storage_key,
JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) AS source_relative_path
FROM h5_publish_records pr
JOIN h5_users u ON u.id = pr.user_id
JOIN h5_page_records p ON p.id = pr.page_id
@@ -1150,7 +1206,10 @@ export function createPublicationService(pool, options = {}) {
const resolvePrivateLink = async (token, viewerId, requestMeta) => {
const tokenHash = crypto.createHash('sha256').update(String(token)).digest('hex');
const [rows] = await pool.query(
`SELECT pr.*, u.id AS owner_id, p.title, p.source_session_id, p.source_message_id, av.storage_key
`SELECT pr.*, u.id AS owner_id, p.title, p.source_session_id, p.source_message_id,
p.workspace_relative_path,
av.storage_key,
JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) AS source_relative_path
FROM h5_publish_records pr
JOIN h5_users u ON u.id = pr.user_id
JOIN h5_page_records p ON p.id = pr.page_id
@@ -1251,6 +1310,7 @@ export function createPublicationService(pool, options = {}) {
getPublicHomepage,
getStats,
offline,
updatePublicationAccess,
updatePublicationStatus,
resolvePublic,
resolvePrivateLink,
+40
View File
@@ -0,0 +1,40 @@
import { publishedPageCspForEmbed } from './plaza-embed.mjs';
export function scriptSrcDirective({ inline = false, urls = [], hashes = [] } = {}) {
const parts = [];
if (inline) parts.push("'unsafe-inline'");
for (const hash of hashes) parts.push(`'sha256-${hash}'`);
for (const url of urls) parts.push(url);
return parts.length ? `script-src ${parts.join(' ')}` : "script-src 'none'";
}
export function htmlUsesExternalScriptSrc(html) {
return /<script\b[^>]*\bsrc\s*=/i.test(String(html ?? ''));
}
export function publishedPageCsp(
html,
{ embed = false, raw = false, wechatShare = false, scriptHashes = [] } = {},
) {
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
if (embed && isFullHtml) {
return publishedPageCspForEmbed(true);
}
if (wechatShare && isFullHtml) {
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline' https://res.wx.qq.com";
}
if (raw && isFullHtml) {
const scriptSrc = scriptSrcDirective({
inline: true,
urls: htmlUsesExternalScriptSrc(html) ? ["'self'"] : [],
});
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrc}`;
}
if (isFullHtml) {
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({
hashes: scriptHashes,
urls: htmlUsesExternalScriptSrc(html) ? ["'self'"] : [],
})}`;
}
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
}
+25
View File
@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { publishedPageCsp } from './mindspace-published-page-csp.mjs';
const PAGE_DATA_HTML = `<!doctype html><html><head></head><body>
<script src="/assets/page-data-client.js"></script>
<script>var client = MindSpacePageData.createClient({ apiBase: '/api' });</script>
</body></html>`;
test('publishedPageCsp raw mode allows same-origin external scripts for page-data-client.js', () => {
const csp = publishedPageCsp(PAGE_DATA_HTML, { raw: true });
assert.match(csp, /script-src 'unsafe-inline' 'self'/);
});
test('publishedPageCsp raw mode keeps inline-only pages restricted', () => {
const html = '<!doctype html><html><body><script>console.log(1)</script></body></html>';
const csp = publishedPageCsp(html, { raw: true });
assert.match(csp, /script-src 'unsafe-inline'/);
assert.doesNotMatch(csp, /script-src 'unsafe-inline' 'self'/);
});
test('publishedPageCsp non-raw full html allows self when external scripts are present', () => {
const csp = publishedPageCsp(PAGE_DATA_HTML, { raw: false });
assert.match(csp, /script-src 'self'/);
});
+68
View File
@@ -21,6 +21,8 @@ import { createUserDataSpaceService } from './user-data-space-service.mjs';
import { writePageAccessPolicy, readPageAccessPolicy } from './page-data-policy-store.mjs';
import { closePolicyDataset, normalizePageAccessPolicy } from './page-access-policy.mjs';
import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs';
import { bindWorkspaceHtmlForPageData } from './page-data-workspace-bind.mjs';
import { resolveMindSpaceStorageRoot } from './mindspace-runtime-config.mjs';
const SANDBOX_ROOT = process.argv[2]?.trim() || process.env.SANDBOX_ROOT?.trim();
if (!SANDBOX_ROOT) {
@@ -320,6 +322,37 @@ const ALL_TOOLS = [
required: ['pageId', 'dataset'],
},
},
{
name: 'private_data_bind_workspace_page',
description:
'将工作区 public/*.html 绑定为 MindSpace 页面记录、发布并配置 Page Data 策略。返回 pageId 与可访问 URLHTML 无需手写 pageId。',
inputSchema: {
type: 'object',
properties: {
relativePath: {
type: 'string',
description: '工作区相对路径,例如 public/survey.html',
},
title: { type: 'string', description: '页面标题,可选,默认从 HTML <title> 读取' },
accessMode: {
type: 'string',
description: '发布访问模式:public、password、login_required,默认 password',
},
password: {
type: 'string',
description:
'口令访问密码,accessMode=password 时可选;未提供则默认 88888888(至少 8 位)',
},
urlSlug: { type: 'string', description: '发布 URL slug,可选' },
datasets: {
type: 'object',
description:
'Page Data dataset 授权,例如 { survey_responses: { insert: true, read: true, columns: { insert: ["name"], read: ["id","name"] } } }',
},
},
required: ['relativePath'],
},
},
];
let quotaPool = null;
@@ -446,6 +479,12 @@ function getUserDataSpaceService() {
return userDataSpaceService;
}
function resolveH5RootFromSandbox() {
const parent = path.basename(path.dirname(SANDBOX));
if (parent === 'MindSpace') return path.dirname(path.dirname(SANDBOX));
return SANDBOX_MODULE_DIR;
}
async function callTool(name, args) {
if (ALLOWED_TOOLS && !ALLOWED_TOOLS.has(name)) {
throw new Error(`工具 ${name} 未授权`);
@@ -606,6 +645,35 @@ async function callTool(name, args) {
}
return [{ type: 'text', text: JSON.stringify(saved.datasets[args.dataset], null, 2) }];
}
case 'private_data_bind_workspace_page': {
const ownerUserId = String(PRIVATE_DATA_USER_ID ?? '').trim();
if (!ownerUserId) throw new Error('缺少用户上下文,无法绑定页面');
const pool = getQuotaPool();
if (!pool) throw new Error('当前环境未配置数据库,无法绑定页面');
const accessMode = String(args.accessMode ?? 'password').trim() || 'password';
const h5Root = resolveH5RootFromSandbox();
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId: ownerUserId,
workspaceRoot: SANDBOX,
relativePath: args.relativePath,
title: args.title,
accessMode,
password: args.password,
urlSlug: args.urlSlug,
pageDataPolicy: args.datasets
? {
ownerUserId,
accessMode,
datasets: args.datasets,
}
: null,
});
return [{ type: 'text', text: JSON.stringify(result, null, 2) }];
}
case 'schedule_create_item': {
const timezone = args.timezone ?? process.env.H5_DEFAULT_TIMEZONE ?? 'Asia/Shanghai';
const startAt = resolveScheduleTimestamp({
+98
View File
@@ -0,0 +1,98 @@
import { slugFromPageTitle } from './mindspace-chat-plaza.mjs';
import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
function parseJsonColumn(value, fallback = {}) {
if (value == null || value === '') return { ...fallback };
if (typeof value === 'object' && !Buffer.isBuffer(value)) return value;
try {
return JSON.parse(String(value));
} catch {
return { ...fallback };
}
}
function isPublicWorkspaceHtmlPath(relativePath) {
const normalized = normalizeWorkspaceRelativePath(relativePath);
return Boolean(normalized?.startsWith('public/') && normalized.toLowerCase().endsWith('.html'));
}
export function createWorkspacePageDeliverService({
pool,
pageService,
publicationService,
pageSyncService,
logger = console,
} = {}) {
async function listUnpublishedAutoSyncedWorkspacePages(userId) {
if (!pool || !userId) return [];
const [rows] = await pool.query(
`SELECT p.id AS page_id, p.title, p.current_version_id, p.workspace_relative_path,
pv.source_snapshot_json
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_publish_records pr
ON pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online'
WHERE p.user_id = ?
AND p.status <> 'deleted'
AND pr.id IS NULL`,
[userId],
);
return (rows ?? []).filter((row) => {
const snapshot = parseJsonColumn(row.source_snapshot_json);
const relativePath = row.workspace_relative_path ?? snapshot.relative_path ?? null;
if (!isPublicWorkspaceHtmlPath(relativePath)) return false;
return snapshot.auto_synced === true || snapshot.content_mode === 'static_html';
});
}
async function ensureWorkspaceHtmlPublications(userId) {
if (!publicationService?.publish || !pageService?.getPage || !userId) {
return { published: 0, skipped: 0, errors: [] };
}
const candidates = await listUnpublishedAutoSyncedWorkspacePages(userId);
let published = 0;
let skipped = 0;
const errors = [];
for (const candidate of candidates) {
try {
const page = await pageService.getPage(userId, candidate.page_id);
const current = await publicationService.getCurrent?.(userId, candidate.page_id);
if (current?.status === 'online') {
skipped += 1;
continue;
}
await publicationService.publish(userId, candidate.page_id, {
pageVersionId: page.currentVersionId,
accessMode: 'public',
urlSlug: slugFromPageTitle(page.title, candidate.page_id),
autoAcknowledgeFindings: true,
});
published += 1;
} catch (error) {
errors.push({
pageId: candidate.page_id,
message: error instanceof Error ? error.message : String(error),
});
logger?.warn?.(
`[MindSpace] auto publish failed for page ${candidate.page_id}:`,
error instanceof Error ? error.message : error,
);
}
}
return { published, skipped, errors };
}
async function syncAndDeliver(userId) {
let syncResult = { created: 0, updated: 0, skipped: 0 };
if (pageSyncService?.syncUserGeneratedPages) {
syncResult = await pageSyncService.syncUserGeneratedPages(userId);
}
const publishResult = await ensureWorkspaceHtmlPublications(userId);
return { sync: syncResult, publish: publishResult };
}
return {
syncAndDeliver,
ensureWorkspaceHtmlPublications,
};
}
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createWorkspacePageDeliverService } from './mindspace-workspace-page-deliver.mjs';
test('ensureWorkspaceHtmlPublications publishes auto-synced workspace html pages', async () => {
const published = [];
const service = createWorkspacePageDeliverService({
pool: {
async query() {
return [[{
page_id: 'page-1',
title: '隔离验证页',
current_version_id: 'ver-1',
workspace_relative_path: 'public/demo.html',
source_snapshot_json: JSON.stringify({
auto_synced: true,
relative_path: 'public/demo.html',
content_mode: 'static_html',
}),
}]];
},
},
pageService: {
async getPage() {
return { id: 'page-1', title: '隔离验证页', currentVersionId: 'ver-1' };
},
},
publicationService: {
async getCurrent() {
return null;
},
async publish(userId, pageId, input) {
published.push({ userId, pageId, input });
return { id: 'pub-1', publicUrl: 'http://127.0.0.1:9181/u/john/pages/page-demo' };
},
},
});
const result = await service.ensureWorkspaceHtmlPublications('user-1');
assert.equal(result.published, 1);
assert.equal(published.length, 1);
assert.equal(published[0].input.accessMode, 'public');
assert.equal(published[0].input.autoAcknowledgeFindings, true);
});
+14
View File
@@ -1,7 +1,21 @@
import fs from 'node:fs';
import path from 'node:path';
import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
import { resolvePublishDir } from './user-publish.mjs';
export { normalizeWorkspaceRelativePath };
export async function readWorkspacePublishHtml(h5Root, userId, relativePath) {
const normalized = normalizeWorkspaceRelativePath(relativePath);
if (!h5Root || !userId || !normalized) return null;
const filePath = path.join(resolvePublishDir(h5Root, { id: userId }), ...normalized.split('/'));
try {
return await fs.promises.readFile(filePath, 'utf8');
} catch {
return null;
}
}
export function resolvePageWorkspaceRelativePath(snapshot) {
if (!snapshot || typeof snapshot !== 'object') return null;
return normalizeWorkspaceRelativePath(snapshot.relative_path) || null;
+1 -1
View File
@@ -55,7 +55,7 @@
"mock:memory-v2-services": "node scripts/mock-memory-v2-services.mjs",
"test:memind": "node scripts/run-memind-tests.mjs",
"test:scenario": "node scripts/run-scenario-test.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-local-runtime-services.test.mjs mindspace-local-server-adapter.test.mjs mindspace-public-asset-token.test.mjs mindspace-remote-server-adapter.test.mjs mindspace-server-adapter.test.mjs mindspace-pages.test.mjs mindspace-page-sync-service.test.mjs public-site-bases.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs",
"test:mindspace-service": "node --test mindspace-service/mindspace-rpc-server.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
"verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs",
+89
View File
@@ -0,0 +1,89 @@
/**
* MindSpace 公开 HTML 中解析 Page Data API 引用的 dataset 与读写意图
*/
export function detectPageDataDatasetUsageFromHtml(html) {
const text = String(html ?? '');
const datasets = new Map();
for (const match of text.matchAll(/\.insertRow\(\s*['"]([^'"]+)['"]/g)) {
const name = match[1];
const prev = datasets.get(name) ?? {};
datasets.set(name, { ...prev, insert: true });
}
for (const match of text.matchAll(/\.listRows\(\s*['"]([^'"]+)['"]/g)) {
const name = match[1];
const prev = datasets.get(name) ?? {};
datasets.set(name, { ...prev, read: true });
}
return datasets;
}
export function assertPolicyMatchesHtmlDatasets(html, policyDatasets) {
const usage = detectPageDataDatasetUsageFromHtml(html);
const htmlNames = [...usage.keys()].sort();
const policyNames = Object.keys(policyDatasets ?? {}).sort();
if (!htmlNames.length) return usage;
if (htmlNames.join(',') !== policyNames.join(',')) {
throw Object.assign(
new Error(
`Page Data 策略 dataset${policyNames.join(', ') || '无'})与 HTML 中 insertRow/listRows 引用的 dataset${htmlNames.join(', ')})不一致。请使用与页面脚本相同的 dataset 名称。`,
),
{
code: 'dataset_policy_html_mismatch',
htmlDatasets: htmlNames,
policyDatasets: policyNames,
},
);
}
return usage;
}
export function buildPageDataPolicyDatasetsFromRegistry({ html, registryDatasets, usage }) {
const detected = usage ?? detectPageDataDatasetUsageFromHtml(html);
if (!detected.size) return null;
const registryMap = new Map(registryDatasets.map((dataset) => [dataset.name, dataset]));
const datasets = {};
for (const [name, perms] of detected) {
const registered = registryMap.get(name);
if (!registered) {
throw Object.assign(
new Error(`HTML 引用的 dataset「${name}」尚未注册,请先执行 private_data_register_dataset`),
{ code: 'dataset_not_registered', datasetName: name },
);
}
const entry = {
insert: false,
read: false,
update: false,
softDelete: false,
hardDelete: false,
columns: {},
};
if (perms.insert && !perms.read) {
entry.insert = true;
entry.columns.insert = registered.columns?.insert ?? [];
} else if (perms.read && !perms.insert) {
entry.read = true;
entry.columns.read = registered.columns?.read ?? [];
} else {
if (perms.insert) {
entry.insert = true;
entry.columns.insert = registered.columns?.insert ?? [];
}
if (perms.read) {
entry.read = true;
entry.columns.read = registered.columns?.read ?? [];
}
}
datasets[name] = entry;
}
return datasets;
}
+52
View File
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
assertPolicyMatchesHtmlDatasets,
buildPageDataPolicyDatasetsFromRegistry,
detectPageDataDatasetUsageFromHtml,
} from './page-data-html-detect.mjs';
test('detectPageDataDatasetUsageFromHtml finds insert and read datasets', () => {
const html = `
await c.insertRow('tkmind_exp_survey', { satisfaction: '满意' });
await c.listRows('tkmind_exp_survey', { limit: 10 });
`;
const usage = detectPageDataDatasetUsageFromHtml(html);
assert.equal(usage.size, 1);
assert.deepEqual(usage.get('tkmind_exp_survey'), { insert: true, read: true });
});
test('assertPolicyMatchesHtmlDatasets rejects mismatched dataset names', () => {
const html = `await c.insertRow('tkmind_exp_survey', {});`;
assert.throws(
() =>
assertPolicyMatchesHtmlDatasets(html, {
experience_survey_responses: { insert: true },
}),
/不一致/,
);
});
test('buildPageDataPolicyDatasetsFromRegistry infers insert-only policy', () => {
const html = `await c.insertRow('survey_responses', { q1: 'a' });`;
const datasets = buildPageDataPolicyDatasetsFromRegistry({
html,
registryDatasets: [
{
name: 'survey_responses',
columns: {
insert: ['q1'],
read: ['id', 'q1', 'created_at'],
},
},
],
});
assert.deepEqual(datasets.survey_responses, {
insert: true,
read: false,
update: false,
softDelete: false,
hardDelete: false,
columns: { insert: ['q1'] },
});
});
+239
View File
@@ -0,0 +1,239 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { createPageService, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
import { createPublicationService, publicationInternals } from './mindspace-publications.mjs';
import { slugFromPageTitle } from './mindspace-chat-plaza.mjs';
import { writePageAccessPolicy } from './page-data-policy-store.mjs';
import { normalizePageAccessPolicy } from './page-access-policy.mjs';
import { upsertPageDataPolicyIndex } from './page-data-policy-index.mjs';
import { syncPageDataPolicyAccessMode } from './page-data-publish-sync.mjs';
import { createMindSpacePublicUrl } from './mindspace-canonical-url.mjs';
import { resolvePublicBaseUrl } from './user-publish.mjs';
import { createUserDataSpaceService } from './user-data-space-service.mjs';
import {
assertPolicyMatchesHtmlDatasets,
buildPageDataPolicyDatasetsFromRegistry,
detectPageDataDatasetUsageFromHtml,
} from './page-data-html-detect.mjs';
export const DEFAULT_PAGE_DATA_ADMIN_PASSWORD = '88888888';
export function resolvePageDataBindAccess(accessMode = 'public') {
return publicationInternals.normalizeAccessMode(accessMode);
}
/** password 模式:用户未传口令时使用 DEFAULT_PAGE_DATA_ADMIN_PASSWORD<8 位抛错 */
export function resolvePageDataBindPassword(accessMode, password = null) {
const mode = resolvePageDataBindAccess(accessMode);
if (mode !== 'password') return null;
const explicit = String(password ?? '').trim();
if (explicit) {
return publicationInternals.normalizePassword(explicit, true);
}
return DEFAULT_PAGE_DATA_ADMIN_PASSWORD;
}
function titleFromPublicHtml(content, relativePath) {
const match = String(content ?? '').match(/<title[^>]*>([^<]+)<\/title>/i);
const fromTitle = match?.[1]?.replace(/\s+/g, ' ').trim();
if (fromTitle) return fromTitle;
const basename = path.basename(String(relativePath ?? ''), '.html');
return basename || 'MindSpace 页面';
}
function normalizePublicHtmlRelativePath(relativePath) {
const normalized = normalizeWorkspaceRelativePath(relativePath);
if (!normalized || !normalized.startsWith('public/') || !normalized.toLowerCase().endsWith('.html')) {
throw Object.assign(new Error('仅支持 workspace 公开 HTML 页面(public/*.html'), {
code: 'invalid_public_html_path',
});
}
return normalized;
}
async function ensureWorkspaceHtmlPage({ userId, relativePath, content, title, mindSpacePages }) {
const normalized = normalizePublicHtmlRelativePath(relativePath);
let page = await mindSpacePages.findPageByRelativePath(userId, normalized);
const pageInput = {
title: title ?? page?.title ?? titleFromPublicHtml(content, normalized),
content,
contentFormat: 'html',
pageType: 'html',
categoryCode: 'draft',
};
if (page) {
return mindSpacePages.updatePage(
userId,
page.id,
{
...pageInput,
expectedVersion: page.versionNo,
},
{
snapshot: {
content_mode: 'static_html',
relative_path: normalized,
},
},
);
}
return mindSpacePages.createFromChat(userId, pageInput, {
snapshot: {
content_mode: 'static_html',
relative_path: normalized,
},
});
}
async function ensureWorkspaceHtmlPublished({
userId,
page,
mindSpacePublications,
accessMode,
password,
urlSlug,
}) {
const normalizedAccessMode = resolvePageDataBindAccess(accessMode);
const resolvedPassword = resolvePageDataBindPassword(normalizedAccessMode, password);
const existing = await mindSpacePublications.getCurrent(userId, page.id);
if (existing?.id) {
const accessChanged = existing.accessMode !== normalizedAccessMode;
const needsPassword =
normalizedAccessMode === 'password' && Boolean(resolvedPassword);
if (accessChanged || needsPassword) {
const updated = await mindSpacePublications.updatePublicationAccess(userId, existing.id, {
accessMode: normalizedAccessMode,
password: resolvedPassword,
});
existing.accessMode = updated.accessMode;
existing.id = updated.id;
existing.publicUrl = updated.publicUrl;
}
if (page.pageType === 'html' && mindSpacePublications.refreshOnlinePublicationHtml) {
return (
(await mindSpacePublications.refreshOnlinePublicationHtml(userId, page.id)) ?? existing
);
}
return existing;
}
const preferredSlug = urlSlug || slugFromPageTitle(page.title, page.id);
return mindSpacePublications.publish(userId, page.id, {
pageVersionId: page.currentVersionId,
accessMode: normalizedAccessMode,
password: resolvedPassword,
urlSlug: preferredSlug,
autoAcknowledgeFindings: true,
});
}
function buildWorkspacePublicUrl(userId, relativePath) {
const publicBaseUrl = resolvePublicBaseUrl();
return createMindSpacePublicUrl({
publicBaseUrl,
ownerKey: userId,
relativePath,
});
}
export async function bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId,
workspaceRoot,
relativePath,
title = null,
accessMode = 'password',
password = null,
pageDataPolicy = null,
urlSlug = null,
publicPageLimit,
}) {
if (!pool) throw new Error('数据库未配置,无法绑定页面');
if (!userId) throw new Error('缺少 userId');
if (!workspaceRoot) throw new Error('缺少 workspaceRoot');
const normalized = normalizePublicHtmlRelativePath(relativePath);
const absoluteHtmlPath = path.join(workspaceRoot, ...normalized.split('/'));
const content = await fs.readFile(absoluteHtmlPath, 'utf8');
const normalizedAccessMode = resolvePageDataBindAccess(accessMode);
const resolvedPassword = resolvePageDataBindPassword(normalizedAccessMode, password);
const mindSpacePages = createPageService(pool, { h5Root, storageRoot });
const mindSpacePublications = createPublicationService(pool, {
h5Root,
storageRoot,
publicPageLimit,
});
const page = await ensureWorkspaceHtmlPage({
userId,
relativePath: normalized,
content,
title,
mindSpacePages,
});
const publication = await ensureWorkspaceHtmlPublished({
userId,
page,
mindSpacePublications,
accessMode: normalizedAccessMode,
password: resolvedPassword,
urlSlug,
});
let policy = null;
const htmlDatasetUsage = detectPageDataDatasetUsageFromHtml(content);
let resolvedPageDataPolicy = pageDataPolicy;
if (htmlDatasetUsage.size) {
if (pageDataPolicy?.datasets) {
assertPolicyMatchesHtmlDatasets(content, pageDataPolicy.datasets);
} else {
const userDataSpace = createUserDataSpaceService({ workspaceRoot });
const autoDatasets = buildPageDataPolicyDatasetsFromRegistry({
html: content,
registryDatasets: userDataSpace.listDatasets(),
usage: htmlDatasetUsage,
});
resolvedPageDataPolicy = {
ownerUserId: userId,
accessMode: normalizedAccessMode,
datasets: autoDatasets,
};
}
}
if (resolvedPageDataPolicy) {
const ownerUserId = String(resolvedPageDataPolicy.ownerUserId ?? userId).trim();
policy = normalizePageAccessPolicy(
{
pageId: page.id,
ownerUserId,
accessMode: resolvedPageDataPolicy.accessMode ?? normalizedAccessMode,
datasets: resolvedPageDataPolicy.datasets,
defaultVisitorRole: resolvedPageDataPolicy.defaultVisitorRole,
visitors: resolvedPageDataPolicy.visitors,
roles: resolvedPageDataPolicy.roles,
},
{ fallbackPageId: page.id, fallbackOwnerUserId: ownerUserId },
);
policy = writePageAccessPolicy(workspaceRoot, policy);
await upsertPageDataPolicyIndex(pool, policy).catch(() => null);
policy =
syncPageDataPolicyAccessMode(workspaceRoot, page.id, publication.accessMode, ownerUserId) ??
policy;
}
return {
pageId: page.id,
pageTitle: page.title,
relativePath: normalized,
publicationId: publication.id,
publicationAccessMode: publication.accessMode,
publicationPasswordApplied: normalizedAccessMode === 'password',
publicationUrl: publication.publicUrl,
workspaceUrl: buildWorkspacePublicUrl(userId, normalized),
policy,
};
}
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_PAGE_DATA_ADMIN_PASSWORD,
resolvePageDataBindAccess,
resolvePageDataBindPassword,
} from './page-data-workspace-bind.mjs';
test('resolvePageDataBindPassword defaults for password mode', () => {
assert.equal(resolvePageDataBindPassword('password', null), DEFAULT_PAGE_DATA_ADMIN_PASSWORD);
assert.equal(resolvePageDataBindPassword('password', ''), DEFAULT_PAGE_DATA_ADMIN_PASSWORD);
assert.equal(resolvePageDataBindPassword('password', ' '), DEFAULT_PAGE_DATA_ADMIN_PASSWORD);
});
test('resolvePageDataBindPassword accepts explicit valid password', () => {
assert.equal(resolvePageDataBindPassword('password', 'MyPass1234'), 'MyPass1234');
});
test('resolvePageDataBindPassword rejects short passwords', () => {
assert.throws(
() => resolvePageDataBindPassword('password', '888'),
(error) => error.code === 'invalid_publish_input',
);
});
test('resolvePageDataBindPassword returns null for non-password modes', () => {
assert.equal(resolvePageDataBindPassword('public', null), null);
assert.equal(resolvePageDataBindPassword('login_required', 'secret123'), null);
});
test('resolvePageDataBindAccess normalizes access mode', () => {
assert.equal(resolvePageDataBindAccess('public'), 'public');
assert.equal(resolvePageDataBindAccess('password'), 'password');
});
+22 -2
View File
@@ -6,6 +6,23 @@
return base.replace(/\/$/, '');
}
function resolvePageId(options) {
options = options || {};
var explicit = options.pageId != null ? String(options.pageId).trim() : '';
if (explicit && explicit !== 'PLACEHOLDER_PAGE_ID') return explicit;
if (typeof document !== 'undefined') {
var meta = document.querySelector('meta[name="mindspace-page-data-page-id"]');
if (meta && meta.content) {
var fromMeta = String(meta.content).trim();
if (fromMeta) return fromMeta;
}
}
if (global.__MINDSPACE_PAGE_DATA__ && global.__MINDSPACE_PAGE_DATA__.pageId) {
return String(global.__MINDSPACE_PAGE_DATA__.pageId).trim();
}
return explicit || null;
}
function buildAuthPath(apiBase, pageId) {
return normalizeApiBase(apiBase) + '/public/pages/' + encodeURIComponent(String(pageId || '').trim()) + '/data-auth';
}
@@ -41,11 +58,13 @@
function createClient(options) {
options = options || {};
var pageId = options.pageId;
var pageId = resolvePageId(options);
var apiBase = options.apiBase || '/api';
var fetchImpl = options.fetchImpl || global.fetch;
var sessionToken = options.token || null;
if (!pageId) throw new Error('pageId 不能为空');
if (!pageId) {
throw new Error('pageId 未配置:请先发布页面,平台会在访问时注入 __MINDSPACE_PAGE_DATA__');
}
if (typeof fetchImpl !== 'function') throw new Error('fetch 不可用');
async function request(method, path, opts) {
@@ -111,6 +130,7 @@
global.MindSpacePageData = {
createClient: createClient,
resolvePageId: resolvePageId,
buildAuthPath: buildAuthPath,
buildDataPath: buildDataPath,
};
+43
View File
@@ -0,0 +1,43 @@
{
"id": "ai-usage-survey",
"name": "AI 使用场景调查(Page Data · skill-only 回归)",
"description": "John 实测通过的话术:新建问卷+后台、不用旧页、口令 admin→admin888、3 题、确认后续聊走 Agent",
"account": {
"username": "john",
"password": "888888"
},
"steps": [
{
"action": "login",
"label": "登录 john 账户"
},
{
"action": "chat",
"label": "发起 AI 使用场景问卷(page-data-collect",
"message": "帮我设计一个问卷调查页面,不要用旧的,并增加一个后台,大概是关于 AI 使用场景的调查,给 3 个问题,后台密码admin开始吧",
"selectedChatSkill": "page-data-collect",
"expect": {
"assistantMinChars": 80,
"timeoutMs": 600000,
"replyKeywords": ["方案", "确认"],
"forbidReplyPatterns": ["8899", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "survey-api"]
}
},
{
"action": "chat",
"label": "确认口令与风格,完成交付",
"message": "admin888,科技感深色",
"expect": {
"assistantMinChars": 120,
"timeoutMs": 600000,
"replyKeywords": ["admin888", "问卷", "后台"],
"forbidReplyPatterns": ["8899", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "survey-api"],
"survey": {
"requirePolicy": true,
"requireDataset": true,
"forbidHtmlPatterns": ["onclick=", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "8899"]
}
}
}
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"id": "supplier-data-report",
"name": "供应商数据上报(Page Data",
"description": "普通用户话术 → Agent 生成上报页 + 口令后台 → 验证 Page Data 交付",
"account": {
"username": "john",
"password": "888888"
},
"steps": [
{
"action": "login",
"label": "登录 john 账户"
},
{
"action": "chat",
"label": "用户话术:供应商数据上报 + 分析后台",
"message": "帮我做一个供应商数据上报系统:\n\n1. 做一个上报页面,供应商填写:公司名称、产品品类、上报数量、预计交货日期、联系人电话、备注(选填)。填完能提交保存。\n2. 再做一个后台页面,密码 88888888,能看所有上报记录,按品类做个简单统计,能导出 CSV。\n3. 页面简洁好用,做完把上报链接和后台链接发给我。",
"selectedChatSkill": "page-data-collect",
"expect": {
"assistantMinChars": 80,
"timeoutMs": 600000,
"replyKeywords": ["上报", "后台"],
"forbidReplyPatterns": ["8899", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "survey-api"]
}
}
]
}
+32
View File
@@ -0,0 +1,32 @@
{
"id": "tkmind-feature-survey",
"name": "TKMind 功能偏好调查(Page Data",
"description": "普通用户话术 → Agent 生成问卷页 + 口令后台 → 验证 Page Data 交付",
"account": {
"username": "john",
"password": "888888"
},
"steps": [
{
"action": "login",
"label": "登录 john 账户"
},
{
"action": "chat",
"label": "用户话术:功能偏好调查 + 后台",
"message": "帮我做一个 TKMind 功能偏好调查:\n\n1. 做一个问卷页面,3 道题:最喜欢哪些功能(多选)、主要在什么场景用(多选)、有什么建议或期待的功能(填空)。\n2. 填完能提交保存,我再有一个后台页面,密码 88888888,能查看所有提交记录,能导出。\n3. 页面要好看一点,做完把问卷链接和后台链接发给我。",
"selectedChatSkill": "page-data-collect",
"expect": {
"assistantMinChars": 80,
"timeoutMs": 600000,
"replyKeywords": ["问卷", "后台"],
"forbidReplyPatterns": ["8899", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "survey-api"],
"survey": {
"requirePolicy": true,
"requireDataset": true,
"forbidHtmlPatterns": ["onclick=", "127.0.0.1:", "PLACEHOLDER_PAGE_ID", "8899"]
}
}
}
]
}
+62 -39
View File
@@ -112,49 +112,63 @@ function buildTempUser() {
};
}
async function waitForSessionFinish(fetchImpl, baseUrl, sessionId, cookie, timeoutMs, runTrigger) {
const response = await fetchImpl(`${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}/events`, {
async function waitForAgentRunCompletion(fetchImpl, baseUrl, cookie, timeoutMs, createRun) {
const { runId, sessionId } = await createRun();
const deadline = Date.now() + timeoutMs;
const seen = [];
const response = await fetchImpl(`${baseUrl}/api/agent/runs/${encodeURIComponent(runId)}/events`, {
headers: {
Accept: 'text/event-stream',
Cookie: cookie,
},
});
if (!response.ok || !response.body) {
const payload = await parseResponseBody(response);
throw new Error(`session events failed: ${response.status} ${payload.text}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const seen = [];
const runId = await runTrigger();
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const trimmed = chunk.trim();
if (!trimmed) continue;
seen.push(trimmed);
if (trimmed.includes('type":"Error"')) {
throw new Error(`session stream error: ${trimmed}`);
}
if (trimmed.includes('type":"Finish"')) {
await reader.cancel().catch(() => {});
return { runId, seen };
if (response.ok && response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split('\n\n');
buffer = chunks.pop() ?? '';
for (const chunk of chunks) {
const trimmed = chunk.trim();
if (!trimmed || trimmed.startsWith(':')) continue;
seen.push(trimmed);
if (trimmed.includes('"status":"failed"')) {
await reader.cancel().catch(() => {});
return { runId, sessionId, seen, terminalStatus: 'failed' };
}
if (trimmed.includes('"status":"succeeded"')) {
await reader.cancel().catch(() => {});
return { runId, sessionId, seen, terminalStatus: 'succeeded' };
}
}
}
await reader.cancel().catch(() => {});
}
await reader.cancel().catch(() => {});
throw new Error(`session stream timeout after ${timeoutMs}ms`);
while (Date.now() < deadline) {
const run = await requestJson(fetchImpl, `${baseUrl}/api/agent/runs/${encodeURIComponent(runId)}`, {
headers: { Cookie: cookie },
timeoutMs: Math.min(10000, timeoutMs),
});
const status = run.json?.run?.status ?? null;
const resolvedSessionId = run.json?.run?.agent_session_id ?? sessionId;
if (status === 'succeeded' || status === 'failed') {
return { runId, sessionId: resolvedSessionId, seen, terminalStatus: status };
}
await new Promise((resolve) => setTimeout(resolve, 1500));
}
throw new Error(`agent run timeout after ${timeoutMs}ms`);
}
export { waitForAgentRunCompletion };
export async function runMemoryV2SessionFlowCli({
argv = process.argv.slice(2),
env = process.env,
@@ -219,10 +233,9 @@ export async function runMemoryV2SessionFlowCli({
}
const requestId = crypto.randomUUID();
const { runId, seen } = await waitForSessionFinish(
const { runId, sessionId: activeSessionId, seen, terminalStatus } = await waitForAgentRunCompletion(
fetchImpl,
options.baseUrl,
sessionId,
cookie,
options.timeoutMs,
async () => {
@@ -248,14 +261,18 @@ export async function runMemoryV2SessionFlowCli({
if (!created.ok || created.status !== 202 || !created.json?.run?.id) {
throw new Error(`agent run failed: ${created.status} ${created.text}`);
}
return created.json.run.id;
return {
runId: created.json.run.id,
sessionId: created.json?.run?.agent_session_id ?? sessionId,
};
},
);
checks.push(makeCheck('session_finish_seen', seen.some((chunk) => chunk.includes('type":"Finish"')), {
checks.push(makeCheck('agent_run_terminal', terminalStatus === 'succeeded', {
terminalStatus,
eventCount: seen.length,
}));
const detail = await requestJson(fetchImpl, `${options.baseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, {
const detail = await requestJson(fetchImpl, `${options.baseUrl}/api/sessions/${encodeURIComponent(activeSessionId)}`, {
headers: { Cookie: cookie },
timeoutMs: options.timeoutMs,
});
@@ -272,17 +289,22 @@ export async function runMemoryV2SessionFlowCli({
const remember = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/remember-recent`, {
method: 'POST',
headers: { Cookie: cookie },
body: { sessionId },
body: { sessionId: activeSessionId },
timeoutMs: options.timeoutMs,
});
checks.push(makeCheck('remember_recent_ok', remember.ok && remember.json?.ok === true, {
status: remember.status,
}));
checks.push(makeCheck('remember_recent_extracted', remember.ok && Number(remember.json?.analyzed ?? 0) > 0, {
status: remember.status,
analyzed: remember.json?.analyzed ?? 0,
memories: remember.json?.memories ?? 0,
}));
const sync = await requestJson(fetchImpl, `${options.baseUrl}/api/user-memory/v1/sync`, {
method: 'POST',
headers: { Cookie: cookie },
body: { sessionId },
body: { sessionId: activeSessionId },
timeoutMs: options.timeoutMs,
});
checks.push(makeCheck('sync_ok', sync.ok && sync.json?.ok === true, {
@@ -310,7 +332,7 @@ export async function runMemoryV2SessionFlowCli({
ok: checks.every((item) => item.ok),
baseUrl: options.baseUrl,
user: user.username,
sessionId,
sessionId: activeSessionId,
runId,
summary: {
assistantPreview: assistantMessages.at(-1)?.content?.[0]?.text?.slice?.(0, 200) ?? null,
@@ -326,6 +348,7 @@ export async function runMemoryV2SessionFlowCli({
}
: null,
sessionEventsTail: seen.slice(-4),
terminalStatus,
},
checks,
};
@@ -104,10 +104,10 @@ test('runMemoryV2SessionFlowCli passes against a mocked live session flow', asyn
if (url.endsWith('/api/agent/start')) {
return jsonResponse({ id: 'session-1' });
}
if (url.endsWith('/api/sessions/session-1/events')) {
if (url.endsWith('/api/agent/runs/run-1/events')) {
return sseResponse([
'id: 1\ndata: {"type":"Message","message":{"role":"assistant","content":[{"type":"text","text":"hi"}],"metadata":{"userVisible":true}}}\n\n',
'id: 2\ndata: {"type":"Finish","reason":"stop"}\n\n',
'event: run\ndata: {"run":{"id":"run-1","status":"running","agent_session_id":"session-1"}}\n\n',
'event: run\ndata: {"run":{"id":"run-1","status":"succeeded","agent_session_id":"session-1"}}\n\n',
]);
}
if (url.endsWith('/api/agent/runs')) {
+3 -2
View File
@@ -95,13 +95,14 @@ const useConfiguredPublicBase = ['1', 'true', 'yes', 'on'].includes(
const mindSpacePublicBase = (
useConfiguredPublicBase && process.env.H5_PUBLIC_BASE_URL
? process.env.H5_PUBLIC_BASE_URL
: localMindSpacePublicBase
: portalUrl
).replace(/\/$/, '');
process.env.H5_PUBLIC_BASE_URL = mindSpacePublicBase;
const viteEnv = {
VITE_PLAZA_BASE: plazaPublicBase,
VITE_MINDSPACE_BASE: mindSpacePublicBase,
H5_DEV_PORTAL: portalUrl,
};
const opsEnv = {
@@ -158,7 +159,7 @@ try {
console.log('');
console.log('本地服务:');
console.log(` MindSpace UI http://127.0.0.1:${vitePort}/?preview=mindspace`);
console.log(` MindSpace URL ${mindSpacePublicBase}`);
console.log(` MindSpace URL ${mindSpacePublicBase} (Portal 直链,Agent 交付链接)`);
console.log(` Ops 审核后台 http://127.0.0.1:${opsPort}/ops/`);
console.log(` API / Portal ${portalUrl}`);
console.log(` memind_adm ${adminUrl}`);
+101
View File
@@ -0,0 +1,101 @@
/**
* 修复 john 教育问卷问卷页 public + 独立后台页 password
* 用法node scripts/repair-child-education-survey.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '.env'));
loadEnvFile(path.join(root, '../../.env.local'));
const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32';
const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID);
const ADMIN_PASSWORD = '88888888';
const DATASET = 'child_edu_survey';
const pool = createDbPool();
const h5Root = root;
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
const pages = [
{
relativePath: 'public/child-education-analysis.html',
accessMode: 'public',
password: null,
policy: {
accessMode: 'public',
datasets: {
[DATASET]: {
insert: true,
read: false,
columns: { insert: ['q1_pressure', 'q2_concern', 'q3_action'] },
},
},
},
},
{
relativePath: 'public/child-education-analysis-admin.html',
accessMode: 'password',
password: ADMIN_PASSWORD,
policy: {
accessMode: 'password',
datasets: {
[DATASET]: {
insert: false,
read: true,
columns: {
read: ['id', 'q1_pressure', 'q2_concern', 'q3_action', 'created_at'],
},
},
},
},
},
];
console.log('修复教育问卷发布配置…\n');
const results = [];
for (const page of pages) {
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId: JOHN_USER_ID,
workspaceRoot: WORKSPACE_ROOT,
relativePath: page.relativePath,
accessMode: page.accessMode,
password: page.password,
pageDataPolicy: page.policy,
});
results.push(result);
console.log(`${page.relativePath}`);
console.log(` pageId: ${result.pageId}`);
console.log(` accessMode: ${result.publicationAccessMode}`);
console.log(` workspace: ${result.workspaceUrl}\n`);
}
console.log('完成。测试入口:');
console.log(` 问卷:http://127.0.0.1:8081/MindSpace/${JOHN_USER_ID}/public/child-education-analysis.html`);
console.log(` 后台:http://127.0.0.1:8081/MindSpace/${JOHN_USER_ID}/public/child-education-analysis-admin.html`);
console.log(` 后台口令:${ADMIN_PASSWORD}`);
await pool.end();
+97
View File
@@ -0,0 +1,97 @@
/**
* 重新绑定并刷新 john 的体验调研页发布快照与 Page Data 策略
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '.env'));
loadEnvFile(path.join(root, '../../.env.local'));
const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32';
const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID);
const ADMIN_PASSWORD = '66668888';
const pool = createDbPool();
const h5Root = root;
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
const DATASET = {
name: 'tkmind_exp_survey',
columns: {
insert: ['satisfaction', 'best_features', 'improvement'],
read: ['id', 'satisfaction', 'best_features', 'improvement', 'created_at'],
},
};
const pages = [
{
relativePath: 'public/tkmind-experience-survey.html',
accessMode: 'public',
password: null,
policy: {
accessMode: 'public',
datasets: {
[DATASET.name]: {
insert: true,
read: false,
columns: { insert: DATASET.columns.insert },
},
},
},
},
{
relativePath: 'public/tkmind-survey-experience-admin.html',
accessMode: 'password',
password: ADMIN_PASSWORD,
policy: {
accessMode: 'password',
datasets: {
[DATASET.name]: {
insert: false,
read: true,
columns: { read: DATASET.columns.read },
},
},
},
},
];
for (const page of pages) {
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId: JOHN_USER_ID,
workspaceRoot: WORKSPACE_ROOT,
relativePath: page.relativePath,
accessMode: page.accessMode,
password: page.password,
pageDataPolicy: page.policy,
});
console.log(`${page.relativePath}`);
console.log(` pageId: ${result.pageId}`);
console.log(` workspace: ${result.workspaceUrl}`);
console.log(` publication: ${result.publicationUrl}`);
console.log(` policy datasets: ${Object.keys(result.policy?.datasets ?? {}).join(', ')}\n`);
}
await pool.end();
+18
View File
@@ -17,6 +17,7 @@ import {
resolvePortalBase,
snapshotPublicHtml,
verifyPageAccess,
verifySurveyDelivery,
waitForAssistantGrowth,
waitForRunTerminal,
extractAssistantTexts,
@@ -112,6 +113,7 @@ async function runScenario(scenario, port) {
const run = await createAgentRun(baseUrl, auth.cookie, {
message: step.message,
sessionId,
selectedChatSkill: step.selectedChatSkill ?? null,
});
reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`);
@@ -174,6 +176,22 @@ async function runScenario(scenario, port) {
});
}
if (step.expect?.survey) {
await verifySurveyDelivery({
publishKey,
replyText: reply.combined,
expect: step.expect.survey,
reporter,
});
}
const forbidReply = step.expect?.forbidReplyPatterns ?? [];
for (const pattern of forbidReply) {
if (pattern && reply.combined.includes(pattern)) {
reporter.fail('回复禁用模式', `命中 ${pattern}`);
}
}
continue;
}
+104 -4
View File
@@ -75,20 +75,24 @@ export async function loginViaApi(baseUrl, { username, password }, reporter) {
};
}
function buildUserMessage(text) {
function buildUserMessage(text, { selectedChatSkill = null } = {}) {
const metadata = { userVisible: true, displayText: text };
if (selectedChatSkill) {
metadata.memindRun = { selectedChatSkill };
}
return {
id: crypto.randomUUID(),
role: 'user',
content: [{ type: 'text', text }],
metadata: { userVisible: true, displayText: text },
metadata,
};
}
export async function createAgentRun(baseUrl, cookie, { message, sessionId = null }) {
export async function createAgentRun(baseUrl, cookie, { message, sessionId = null, selectedChatSkill = null }) {
const requestId = crypto.randomUUID();
const body = {
request_id: requestId,
user_message: buildUserMessage(message),
user_message: buildUserMessage(message, { selectedChatSkill }),
};
if (sessionId) body.session_id = sessionId;
@@ -301,6 +305,102 @@ export async function verifyPageAccess({
return true;
}
export async function verifySurveyDelivery({
publishKey,
replyText = '',
expect = {},
reporter,
}) {
const publishDir = path.join(repoRoot, PUBLISH_ROOT_DIR, publishKey);
const publicDir = path.join(publishDir, 'public');
const policyDir = path.join(publishDir, '.mindspace', 'page-data-policies');
const sqlitePath = path.join(publishDir, '.mindspace', 'private-data.sqlite');
const forbidReply = expect.forbidReplyPatterns ?? [];
for (const pattern of forbidReply) {
if (pattern && replyText.includes(pattern)) {
reporter.fail('回复禁用模式', `命中 ${pattern}`);
}
}
if (forbidReply.length && !forbidReply.some((pattern) => pattern && replyText.includes(pattern))) {
reporter.pass('回复禁用模式', '未出现旁路 API / PLACEHOLDER');
}
let htmlFiles = [];
try {
const entries = await fs.readdir(publicDir, { withFileTypes: true });
htmlFiles = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.html'))
.map((entry) => entry.name);
} catch {
reporter.fail('问卷 HTML', 'public/ 目录不存在');
return false;
}
const surveyLike = htmlFiles.filter((name) => /survey|问卷|feature/i.test(name));
const adminLike = htmlFiles.filter((name) => /admin|后台|manage/i.test(name));
if (surveyLike.length === 0) {
reporter.fail('问卷 HTML', `public/ 中未找到问卷页,现有: ${htmlFiles.join(', ') || '(空)'}`);
} else {
reporter.pass('问卷 HTML', surveyLike.join(', '));
}
if (adminLike.length === 0) {
reporter.fail('后台 HTML', `public/ 中未找到后台页,现有: ${htmlFiles.join(', ') || '(空)'}`);
} else {
reporter.pass('后台 HTML', adminLike.join(', '));
}
const forbidHtml = expect.forbidHtmlPatterns ?? [];
for (const name of [...surveyLike, ...adminLike]) {
const html = await fs.readFile(path.join(publicDir, name), 'utf8');
if (!html.includes('page-data-client.js')) {
reporter.fail(`${name} 脚本`, '未引用 page-data-client.js');
}
for (const pattern of forbidHtml) {
if (pattern && html.includes(pattern)) {
reporter.fail(`${name} 禁用模式`, `命中 ${pattern}`);
}
}
}
if (surveyLike.length && adminLike.length) {
reporter.pass('Page Data 客户端', '问卷/后台 HTML 已引用 page-data-client.js');
}
if (expect.requirePolicy) {
try {
const policies = await fs.readdir(policyDir);
const jsonPolicies = policies.filter((name) => name.endsWith('.json'));
if (jsonPolicies.length === 0) {
reporter.fail('Page Data 策略', 'page-data-policies/ 为空');
} else {
reporter.pass('Page Data 策略', `${jsonPolicies.length} 个 policy 文件`);
}
} catch {
reporter.fail('Page Data 策略', '缺少 .mindspace/page-data-policies/');
}
}
if (expect.requireDataset) {
try {
await fs.stat(sqlitePath);
reporter.pass('私有 SQLite', 'private-data.sqlite 存在');
} catch {
reporter.fail('私有 SQLite', 'private-data.sqlite 不存在');
}
}
const links = extractPublicLinks(replyText, 'http://127.0.0.1:8081');
if (links.length >= 2) {
reporter.pass('交付链接', `${links.length} 个链接`);
} else if (links.length === 1) {
reporter.fail('交付链接', '仅 1 个链接,期望问卷 + 后台');
} else {
reporter.fail('交付链接', '回复中未找到 MindSpace 链接');
}
return true;
}
export async function loadScenario(scenarioId) {
const scenarioPath = path.join(repoRoot, 'scenarios', `${scenarioId}.json`);
const raw = await fs.readFile(scenarioPath, 'utf8');
+106
View File
@@ -0,0 +1,106 @@
/**
* 绑定并发布 john TKMind 问卷页面Page Data API 演示
* 用法node scripts/setup-page-data-survey-demo.mjs
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs';
import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) continue;
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (!process.env[key]) process.env[key] = value;
}
}
loadEnvFile(path.join(root, '.env'));
loadEnvFile(path.join(root, '../../.env.local'));
const JOHN_USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32';
const WORKSPACE_ROOT = path.join(root, 'MindSpace', JOHN_USER_ID);
const ADMIN_PASSWORD = '88888888';
const SURVEY_FORM_POLICY = {
accessMode: 'public',
datasets: {
survey_responses: {
insert: true,
read: false,
columns: {
insert: ['q1_feature', 'q2_usage', 'q3_suggestion'],
},
},
},
};
const SURVEY_ADMIN_POLICY = {
accessMode: 'password',
datasets: {
survey_responses: {
insert: false,
read: true,
columns: {
read: ['id', 'q1_feature', 'q2_usage', 'q3_suggestion', 'created_at'],
},
},
},
};
const pool = createDbPool();
const h5Root = root;
const storageRoot = resolveMindSpaceStorageRoot(h5Root);
const pages = [
{
relativePath: 'public/tkmind-survey.html',
accessMode: 'public',
password: null,
policy: SURVEY_FORM_POLICY,
},
{
relativePath: 'public/tkmind-survey-admin.html',
accessMode: 'password',
password: ADMIN_PASSWORD,
policy: SURVEY_ADMIN_POLICY,
},
];
console.log('绑定并发布 Page Data 问卷演示页…\n');
const results = [];
for (const page of pages) {
const result = await bindWorkspaceHtmlForPageData({
pool,
h5Root,
storageRoot,
userId: JOHN_USER_ID,
workspaceRoot: WORKSPACE_ROOT,
relativePath: page.relativePath,
accessMode: page.accessMode,
password: page.password,
pageDataPolicy: page.policy,
});
results.push(result);
console.log(`${page.relativePath}`);
console.log(` pageId: ${result.pageId}`);
console.log(` workspace: ${result.workspaceUrl}`);
console.log(` publication: ${result.publicationUrl}\n`);
}
console.log('完成。测试入口:');
console.log(` 问卷:${results[0].workspaceUrl}`);
console.log(` 后台:${results[1].workspaceUrl}`);
console.log(` 后台密码:${ADMIN_PASSWORD}`);
await pool.end();
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
/**
* Verify publication delivery chain: file layer vs /u/ publication layer.
*/
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
const base = process.env.VERIFY_BASE_URL || 'http://127.0.0.1:8081';
const username = process.env.VERIFY_USER || 'john';
const password = process.env.VERIFY_PASSWORD || '888888';
const userId = process.env.VERIFY_USER_ID || '1c99b83b-0454-474f-a5d2-129d34506a32';
const checks = [];
function record(name, ok, details = {}) {
checks.push({ name, ok: Boolean(ok), ...details });
const mark = ok ? 'PASS' : 'FAIL';
console.log(`${mark} ${name}${details.detail ? `${details.detail}` : ''}`);
}
async function login() {
const res = await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const cookie = res.headers.get('set-cookie')?.split(';', 1)[0] ?? '';
record('login', res.ok && cookie, { detail: `status=${res.status}` });
return cookie;
}
async function headOrGet(url, { cookie } = {}) {
const res = await fetch(url, {
headers: cookie ? { Cookie: cookie } : {},
redirect: 'manual',
});
const text = res.status >= 400 ? await res.text().catch(() => '') : '';
return { status: res.status, text: text.slice(0, 120) };
}
async function waitRun(cookie, sessionId, runId) {
for (let i = 0; i < 60; i += 1) {
const res = await fetch(`${base}/api/agent/runs/${runId}`, { headers: { Cookie: cookie } });
const json = await res.json().catch(() => null);
const status = json?.run?.status;
if (status === 'succeeded' || status === 'failed') return { status, run: json?.run };
await new Promise((r) => setTimeout(r, 1500));
}
return { status: 'timeout' };
}
async function generateTestPage(cookie) {
const started = await fetch(`${base}/api/agent/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: '{}',
});
const sessionId = (await started.json()).id;
const requestId = crypto.randomUUID();
const prompt = `请用 write_file 创建 public/verify-deliver-${Date.now()}.html,标题「发布链路验证页」,内容简单即可。完成后只回复「已生成」。`;
const created = await fetch(`${base}/api/agent/runs`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({
session_id: sessionId,
request_id: requestId,
user_message: {
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text: prompt }],
metadata: { userVisible: true, agentVisible: true, displayText: prompt },
},
}),
});
const runId = (await created.json())?.run?.id;
const finished = await waitRun(cookie, sessionId, runId);
record('agent_run_generate_page', finished.status === 'succeeded', {
detail: `run=${runId} status=${finished.status}`,
});
return { sessionId, runId, finished };
}
async function findLatestVerifyPage(cookie) {
const res = await fetch(`${base}/api/mindspace/v1/pages?limit=20`, {
headers: { Cookie: cookie },
});
const json = await res.json().catch(() => null);
const items = json?.data ?? [];
const match = items.find((p) => /发布链路验证|verify-deliver/i.test(String(p.title ?? '')))
?? items.find((p) => String(p.workspaceRelativePath ?? '').includes('verify-deliver'));
return match ?? items[0] ?? null;
}
async function main() {
console.log(`\n=== Publication delivery verify @ ${base} ===\n`);
const cookie = await login();
// 1. Existing publication route (/u/...)
for (const slug of ['page-2b201736', 'page-0360d67a']) {
const url = `${base}/u/john/pages/${slug}`;
const { status, text } = await headOrGet(url);
record(`publication_route /u/john/pages/${slug}`, status === 200, {
detail: `HTTP ${status}${status >= 500 ? ` ${text}` : ''}`,
});
}
// 2. File layer (/MindSpace/.../public/...)
const fileUrl = `${base}/MindSpace/${userId}/public/supplier-submit.html`;
const fileRes = await headOrGet(fileUrl);
record('file_layer MindSpace/public/supplier-submit.html', fileRes.status === 200, {
detail: `HTTP ${fileRes.status}`,
});
// 3. Runtime public base
const runtime = await fetch(`${base}/api/runtime/status`).then((r) => r.json()).catch(() => null);
const publicBase = runtime?.publicBaseUrl ?? runtime?.publish?.publicBaseUrl ?? null;
record('runtime public base uses portal', String(publicBase ?? '').includes(':8081'), {
detail: publicBase ?? 'missing',
});
// 4. Generate new page and check publication closure
await generateTestPage(cookie);
await new Promise((r) => setTimeout(r, 2000));
const page = await findLatestVerifyPage(cookie);
if (page) {
const detail = await fetch(`${base}/api/mindspace/v1/pages/${page.id}`, {
headers: { Cookie: cookie },
}).then((r) => r.json()).catch(() => null);
const pub = detail?.data?.publication;
record('new_page_has_publication', Boolean(pub?.id && pub?.status === 'online'), {
detail: pub ? `${pub.status} ${pub.publicUrl ?? ''}` : 'no publication',
});
if (pub?.publicUrl) {
const pubPath = pub.publicUrl.replace(/^https?:\/\/[^/]+/, '');
const pubCheck = await headOrGet(`${base}${pubPath.startsWith('/') ? pubPath : `/${pubPath}`}`);
record('new_page_publication_url_accessible', pubCheck.status === 200, {
detail: `HTTP ${pubCheck.status} ${pub.publicUrl}`,
});
}
const rel = page.workspaceRelativePath ?? page.publicationUrl;
if (page.workspaceRelativePath) {
const wsUrl = `${base}/MindSpace/${userId}/${page.workspaceRelativePath}`;
const ws = await headOrGet(wsUrl);
record('new_page_file_layer', ws.status === 200, { detail: `HTTP ${ws.status}` });
}
} else {
record('new_page_found_in_list', false, { detail: 'no matching page after generate' });
}
const ok = checks.every((c) => c.ok);
console.log(`\n=== ${ok ? 'ALL PASS' : 'SOME FAILED'} (${checks.filter((c) => c.ok).length}/${checks.length}) ===\n`);
process.exitCode = ok ? 0 : 1;
}
main().catch((err) => {
console.error(err);
process.exitCode = 1;
});
+70 -37
View File
@@ -13,8 +13,9 @@ import {
sessionCookie,
} from './auth.mjs';
import { createDbPool, initSchema, isDatabaseConfigured } from './db.mjs';
import { createAgentRunGateway } from './agent-run-gateway.mjs';
import { createWorkspacePageDeliverService } from './mindspace-workspace-page-deliver.mjs';
import { createToolGateway } from './tool-gateway.mjs';
import { createAgentRunGateway } from './agent-run-gateway.mjs';
import {
createAgentRunEventsHandler,
createGetAgentRunHandler,
@@ -58,6 +59,8 @@ import {
} from './mindspace-public-route.mjs';
import {
buildPublishedHtmlViewContext,
injectPublishedPageDataContext,
parseMindSpacePublishFilePath,
resolvePublicRequestOrigin,
} from './mindspace-public-page-context.mjs';
import {
@@ -65,6 +68,7 @@ import {
verifyPublicAssetToken,
} from './mindspace-public-asset-token.mjs';
import {
collectInlineScriptHashes,
decorateMindSpacePublishedHtml,
handleMindSpaceLongImageDownload,
} from './mindspace-public-delivery.mjs';
@@ -102,9 +106,9 @@ import {
allowPlazaEmbedFrame,
preparePublicationHtmlForEmbed,
isPlazaEmbedRequest,
publishedPageCspForEmbed,
stripPublicationHtmlCspMeta,
} from './plaza-embed.mjs';
import { publishedPageCsp } from './mindspace-published-page-csp.mjs';
import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs';
import {
analyzeChatMessageForSave,
@@ -173,7 +177,7 @@ import { createFeedbackService } from './user-feedback.mjs';
import { startScheduleReminderWorker } from './schedule-reminder-worker.mjs';
import { createLlmProviderService, RELAY_BOOTSTRAP } from './llm-providers.mjs';
import { createDirectChatService, isDirectChatSessionId, isPortalDirectChatSnapshot, sendDirectChatSessionEvents, shouldExpirePortalDirectChatSnapshot } from './direct-chat-service.mjs';
import { repairSessionConversationFromDb } from './conversation-repair.mjs';
import { filterUserVisibleConversation, repairSessionConversationFromDb } from './conversation-repair.mjs';
import { filterNonemptyUserVisibleMessages } from './conversation-transcript-persist.mjs';
import { createSessionStreamStore } from './session-stream-store.mjs';
import { isSessionStreamReplayEnabled } from './session-stream.mjs';
@@ -345,6 +349,7 @@ let mindSpacePageLiveEdit = null;
let mindSpaceAssetAgent = null;
let mindSpacePageEditSession = null;
let mindSpacePublications = null;
let workspacePageDeliver = null;
let plazaPosts = null;
let plazaEvents = null;
let plazaRecommend = null;
@@ -434,6 +439,13 @@ async function bootstrapUserAuth() {
mindSpacePageLiveEdit = mindSpaceRuntimeAdapter.pageLiveEditService;
mindSpaceAssetAgent = mindSpaceRuntimeAdapter.assetAgentService;
mindSpacePublications = mindSpaceRuntimeAdapter.publicationService;
workspacePageDeliver = createWorkspacePageDeliverService({
pool,
pageService: mindSpacePages,
publicationService: mindSpacePublications,
pageSyncService: mindSpacePageSync,
logger: console,
});
const resolveUserIdByDirKey = async (dirKey) => {
let userId = dirKey;
if (!PUBLISH_KEY_UUID.test(dirKey)) {
@@ -634,6 +646,7 @@ async function bootstrapUserAuth() {
chatIntentRouter = createManagedChatIntentRouter({
llmProviderService,
memoryV2,
conversationMemoryService,
configService: memoryV2ConfigService,
});
// GOOSED PROXY BOUNDARY: H5 chat → goosed 唯一入口(Patch 5, goosed-proxy-boundary.mjs
@@ -668,6 +681,9 @@ async function bootstrapUserAuth() {
chatIntentRouter,
sessionSnapshotService,
conversationMemoryService,
syncUserPagesOnSuccess: async ({ userId }) => {
await syncUserGeneratedPages(userId);
},
autoDispatch: ['1', 'true', 'yes', 'on'].includes(
String(process.env.MEMIND_AGENT_RUN_AUTODISPATCH ?? '1').trim().toLowerCase(),
),
@@ -1997,7 +2013,7 @@ async function loadUserVisibleConversation(sessionId, userId) {
if (authPool && userId) {
session = await repairSessionConversationFromDb(authPool, session, sessionId, userId);
}
return (session?.conversation ?? []).filter((message) => message?.metadata?.userVisible);
return filterUserVisibleConversation(session?.conversation ?? []);
}
async function syncUserMemoriesIntoSession(userId, sessionId) {
@@ -2006,6 +2022,19 @@ async function syncUserMemoriesIntoSession(userId, sessionId) {
return true;
}
async function resolveUserMemoryItems(userId, { sessionId = null, limit = 200 } = {}) {
const resolved = await memoryV2.resolve({
userId,
sessionId,
limit,
});
const memories = Array.isArray(resolved?.memories) ? resolved.memories : [];
if (memories.length || !conversationMemoryService?.listMemories) {
return memories;
}
return conversationMemoryService.listMemories(userId, { limit }).catch(() => []);
}
api.post('/user-memory/v1/remember-recent', async (req, res) => {
const memoryStatus = await memoryV2?.getStatus?.().catch(() => null);
if (!memoryStatus?.enabled) {
@@ -2034,12 +2063,10 @@ api.post('/user-memory/v1/remember-recent', async (req, res) => {
messages,
});
const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId);
const resolved = await memoryV2.resolve({
userId: req.currentUser.id,
const memories = await resolveUserMemoryItems(req.currentUser.id, {
sessionId,
limit: 200,
});
const memories = Array.isArray(resolved?.memories) ? resolved.memories : [];
return res.json({
ok: true,
analyzed: result.analyzed ?? 0,
@@ -2075,12 +2102,10 @@ api.post('/user-memory/v1/sync', async (req, res) => {
sessionId,
});
const syncedToSession = await syncUserMemoriesIntoSession(req.currentUser.id, sessionId);
const resolved = await memoryV2.resolve({
userId: req.currentUser.id,
const memories = await resolveUserMemoryItems(req.currentUser.id, {
sessionId,
limit: 200,
});
const memories = Array.isArray(resolved?.memories) ? resolved.memories : [];
return res.json({
ok: true,
analyzed: result.analyzed ?? 0,
@@ -3099,7 +3124,12 @@ const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']);
// REGRESSION GUARD: mindspace-page-sync-thumbnail — remote 也经 pageSyncService RPC 同步 public HTML
async function syncUserGeneratedPages(userId) {
if (!mindSpacePageSync || !userId) return;
if (!userId) return;
if (workspacePageDeliver?.syncAndDeliver) {
await workspacePageDeliver.syncAndDeliver(userId);
return;
}
if (!mindSpacePageSync) return;
await mindSpacePageSync.syncUserGeneratedPages(userId);
}
@@ -5005,31 +5035,6 @@ app.use(`/${PUBLISH_ROOT_DIR}`, async (req, res, next) => {
});
app.use('/mindspace', api);
function scriptSrcDirective({ inline = false, urls = [], hashes = [] } = {}) {
const parts = [];
if (inline) parts.push("'unsafe-inline'");
for (const hash of hashes) parts.push(`'sha256-${hash}'`);
for (const url of urls) parts.push(url);
return parts.length ? `script-src ${parts.join(' ')}` : "script-src 'none'";
}
function publishedPageCsp(html, { embed = false, raw = false, wechatShare = false, scriptHashes = [] } = {}) {
const isFullHtml = /^\s*<!doctype html/i.test(html) || /^\s*<html[\s>]/i.test(html);
if (embed && isFullHtml) {
return publishedPageCspForEmbed(true);
}
if (wechatShare && isFullHtml) {
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline' https://res.wx.qq.com";
}
if (raw && isFullHtml) {
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'";
}
if (isFullHtml) {
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({ hashes: scriptHashes })}`;
}
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
}
function extractOgImageUrl(html) {
return (
String(html ?? '').match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] ||
@@ -5412,6 +5417,10 @@ async function sendPublishedPage(req, res, result, { embed = false, raw = false,
} else if (raw) {
html = stripPublicationHtmlCspMeta(html);
}
html = injectPublishedPageDataContext(html, {
pageSource: result.pageSource,
publication: result.publication,
});
if (!embed) {
try {
html = injectOgTags(html, { origin, pageUrl, pageDirUrl });
@@ -5482,7 +5491,15 @@ async function sendPublishedPage(req, res, result, { embed = false, raw = false,
return res.send(shellHtml);
}
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Content-Security-Policy', publishedPageCsp(html, { embed, raw, wechatShare }));
res.set(
'Content-Security-Policy',
publishedPageCsp(html, {
embed,
raw,
wechatShare,
scriptHashes: collectInlineScriptHashes(html),
}),
);
res.set(
'Cache-Control',
result.publication.accessMode === 'public' ? 'public, max-age=60' : 'private, no-store',
@@ -5829,10 +5846,26 @@ async function sendPublishFile(req, res, filePath, { isOwner = true } = {}) {
filePath,
thumbnailPngPathForSvg,
});
let pageDataContext = null;
if (mindSpacePages) {
const parsed = parseMindSpacePublishFilePath(filePath, __dirname);
if (parsed?.userId && parsed.relativePath) {
const page = await mindSpacePages
.findPageByRelativePath(parsed.userId, parsed.relativePath)
.catch(() => null);
if (page?.id) {
pageDataContext = {
pageId: page.id,
accessMode: page.publicationAccessMode ?? null,
};
}
}
}
const decorated = decorateMindSpacePublishedHtml({
html,
embed,
isOwner,
pageDataContext,
context,
htmlFilePath: filePath,
userAgent: req.get('user-agent') || '',
+3
View File
@@ -47,6 +47,7 @@ export async function consumeSessionEventsUntilFinish(
if (Date.now() > deadline) {
const err = new Error(`session reply timed out after ${timeoutMs}ms`);
err.code = 'SESSION_REPLY_TIMEOUT';
err.retryable = false;
throw err;
}
@@ -63,6 +64,7 @@ export async function consumeSessionEventsUntilFinish(
if (event.type === 'Error') {
const err = new Error(String(event.error ?? 'session reply failed'));
err.code = 'SESSION_REPLY_ERROR';
err.retryable = false;
throw err;
}
if (event.type === 'Finish') {
@@ -76,5 +78,6 @@ export async function consumeSessionEventsUntilFinish(
const err = new Error('session event stream ended before Finish');
err.code = 'SESSION_REPLY_INCOMPLETE';
err.retryable = false;
throw err;
}
+5 -2
View File
@@ -10,6 +10,8 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect';
export const DEFAULT_USER_SKILLS = {
web: true,
search: true,
@@ -20,6 +22,7 @@ export const DEFAULT_USER_SKILLS = {
'product-campaign-page': true,
'docx-generate': true,
'long-image-download': true,
[PAGE_DATA_COLLECT_SKILL_NAME]: true,
[PUBLISH_SKILL_NAME]: false,
};
@@ -68,7 +71,7 @@ export function listPlatformSkillCatalog(h5Root = __dirname) {
label: name,
description: meta.description || '平台通用技能',
category: 'platform',
requiresPublish: name === PUBLISH_SKILL_NAME,
requiresPublish: name === PUBLISH_SKILL_NAME || name === PAGE_DATA_COLLECT_SKILL_NAME,
});
}
return catalog.sort((a, b) => a.name.localeCompare(b.name));
@@ -118,7 +121,7 @@ export function applySkillGrantsToCapabilities(capabilities, skillMap) {
if (enabled.length > 0) {
effective.skills = true;
}
if (enabled.includes(PUBLISH_SKILL_NAME)) {
if (enabled.includes(PUBLISH_SKILL_NAME) || enabled.includes(PAGE_DATA_COLLECT_SKILL_NAME)) {
effective.static_publish = true;
}
return effective;
+11
View File
@@ -15,12 +15,22 @@ const h5Root = path.dirname(fileURLToPath(import.meta.url));
test('lists static-page-publish in platform catalog', () => {
const catalog = listPlatformSkillCatalog(h5Root);
assert.ok(catalog.some((item) => item.name === 'static-page-publish'));
assert.ok(catalog.some((item) => item.name === 'page-data-collect'));
assert.ok(catalog.some((item) => item.name === 'schedule-assistant'));
assert.ok(catalog.some((item) => item.name === 'service-integration-smoke'));
assert.ok(catalog.some((item) => item.name === 'product-campaign-page'));
assert.ok(catalog.some((item) => item.name === 'long-image-download'));
});
test('granting page-data-collect enables static_publish capability', () => {
const caps = applySkillGrantsToCapabilities(
{ static_publish: false, skills: false },
{ 'page-data-collect': true },
);
assert.equal(caps.static_publish, true);
assert.equal(caps.skills, true);
});
test('granting publish skill enables static_publish capability', () => {
const caps = applySkillGrantsToCapabilities(
{ static_publish: false, skills: false },
@@ -54,5 +64,6 @@ test('DEFAULT_USER_SKILLS enables common platform skills', () => {
assert.equal(DEFAULT_USER_SKILLS['table-viewer'], true);
assert.equal(DEFAULT_USER_SKILLS['product-campaign-page'], true);
assert.equal(DEFAULT_USER_SKILLS['long-image-download'], true);
assert.equal(DEFAULT_USER_SKILLS['page-data-collect'], true);
assert.equal(DEFAULT_USER_SKILLS['static-page-publish'], false);
});
+277
View File
@@ -0,0 +1,277 @@
---
name: page-data-collect
description: 在 MindSpace 页面中收集、存储并管理结构化数据(问卷、报名、台账、后台查看),基于 Page Data API 与用户私有 SQLite
---
# 页面数据收集(Page Data Collect
在 MindSpace **公开 HTML 页面**中嵌入表单、问卷、报名等交互,并将提交持久化到当前用户的 `.mindspace/private-data.sqlite`,通过平台 **Page Data API** 受控读写。
详细 API 说明见工作区外文档 `docs/page-data-api-usage.md`Memind 仓库)。
## 何时使用
- 用户要在页面里**收集并保存**数据:问卷、投票、报名、签到、意见反馈
- 用户要**后台查看提交记录**,可能带口令/密码
- 用户提到「数据交互」「sqlite」「存数据库」「提交记录」
- 在已有页面上**追加**可提交、可统计的表单区块
**不要**用于:
- 只在聊天里弹表单、不落库 → 用 `form-builder`
- 纯静态展示页、无数据读写 → 用 `static-page-publish`
## 核心原则
```text
Agent 可以建模 SQL 并注册 dataset
HTML 页面只能调用 Page Data APIpage-data-client.js);
禁止自建 Express / 独立端口 / 直接暴露 SQLite 文件路径。
```
## 方案选择:默认快车道 + 能力分支(必做)
**平台配置不由 LLM 发明**;**页面内容可由 LLM 自由生成**。
```text
用户描述需求
→ 匹配能力分支(下表 A/B/C/D,默认 A)
→ 仅冲突或无法匹配时,用 1 题让用户选分支或改口令
→ 输出「方案摘要」供确认(用户可只改口令/选项)
→ 再 load_skill / 建表 / write_file / bind
```
### 默认方案 A(无特殊说明时直接采用,不必逐条追问)
| 项 | 默认值 |
|----|--------|
| 访客 | 匿名提交(`public`,仅 `insert` |
| 后台 | **独立** HTML 页(`password`,仅 `read` |
| 后台口令 | **`88888888`**(平台要求 8~128 位;用户可指定其它合法口令覆盖) |
| 提交后修改 | 不支持(一次性) |
| 页面 | `public/*-survey.html` + `public/*-admin.html`(两文件各 bind 一次) |
用户只说「问卷/报名/收集数据 + 后台查看」且未提登录、协作改单、同页后台时 → **用方案 A,先展示摘要再开工**
口令规则:
- **禁止**接受或使用 <8 位口令(如 `888`);若用户坚持短口令,说明平台限制并代用 `88888888` 或请其给出 ≥8 位。
- bind 时 **`password` 必须传入**且会写入发布记录;禁止只改 `access_mode` 不写 `password_hash`
- 交付说明写明后台口令;用户可在 MindSpace「页面数据」面板重置。
### 能力分支(非默认需求时切换,仍用结构化选项)
| 分支 | 适用用户表述 | 前台 | 后台/协作 | 页面数 |
|------|-------------|------|-----------|--------|
| **A 匿名问卷+口令后台**(默认) | 匿名填、管理员看统计 | `public` insert | 独立页 `password` read | 2 |
| **B 登录后各自提交/查看** | 要登录、只看自己的、销售上报 | `login_required` insert+read | 同页或独立;`own_rows` | 12 |
| **C 共口令协作台账** | 团队共用一个密码、一起改 | `password` insert+read+update | 同页;先 `authenticate` | 1 |
| **D 仅公开提交无后台** | 只要收集、不要后台 | `public` insert only | 无 | 1 |
| **E 登录提交+口令管理** | 登录填报 + 管理员口令看全量 | `login_required` insert | 独立页 `password` read | 2 |
**LLM 只做分支匹配**:从用户原话判断 A~E;能确定则写入方案摘要,**不要**机械念 5 题问卷。
**仅以下情况才问用户(每次最多 1~2 点)**:
1. 表述同时命中两个互斥分支(如「匿名提交」+「同页内嵌口令看全量」)→ 给 A/E 选项说明须拆页
2. 用户明确要的口令 <8 位 → 请改口令或确认用默认 `88888888`
3. 需要 B/C 但表结构是否要 `created_by_user_id` 等列尚不清楚 → 确认登录隔离
**禁止**连续多轮只输出「我先检查工作区/加载技能」而不调用工具;**第一轮工具**应是 `load_skill``list_dir` / `private_data_execute`,不是空计划。
### 平台硬约束(分支菜单边界,不可绕过)
```text
- public:可匿名 insert;服务端禁止 update / softDelete
- password:所有 API 须先 authenticate;口令 ≥8 位且须写入发布记录
- 同页不能同时「匿名 insert」+「口令 read 全量」→ 须拆前台 + 后台(方案 A/E)
- password 无法区分访客身份;「只改自己的」须 login_required + own_rows(方案 B
- 匿名提交后「凭链接改自己的」无内置能力,须改 B 或接受一次性提交
```
### 方案摘要模板(开工前展示;默认填 A,用户可改口令或换分支)
```text
## 页面数据方案(请确认或只改口令)
- 分支:A 匿名提交 + 独立口令后台
- 访客:匿名提交(public,仅 insert
- 管理员:独立后台页(password,仅 read
- 后台口令:88888888(可改为你的 ≥8 位口令)
- 提交后修改:不支持
- 页面:public/xxx.html + public/xxx-admin.html
- 内容:(由你的描述生成题目/字段/报表)
确认后开始建表与写页面。若需登录提交或团队共改,请说明,我换成 B/C 分支。
```
### 分支切换示例
**用户**:「销售要登录后才能上报,管理员用 88888888 看全部。」
**方案 E**:前台 `login_required` insert;后台独立页 `password` read;口令 `88888888`
**用户**:「小团队共用一个密码,一起维护台账。」
**方案 C**:单页 `password`bind 传 `password`;页内先 `authenticate` 再读写。
**用户**:「做个投票,不用后台。」
**方案 D**:单页 `public` insert only。
## 标准工作流
### 1. 加载本技能 + 选定分支
```text
load_skill → page-data-collect
→ 匹配分支(默认 A)→ 方案摘要 → 用户确认或仅改口令/选项
→ 禁止未确认就 bind;禁止空转计划不调用工具
```
### 2. 数据层:建表 + 注册 dataset
`private_data_execute` 建表(示例):
```sql
CREATE TABLE IF NOT EXISTS survey_responses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
q1_feature TEXT NOT NULL,
q2_usage TEXT NOT NULL,
q3_suggestion TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now', '+8 hours'))
);
```
`private_data_register_dataset` 注册(示例):
```json
{
"name": "survey_responses",
"table": "survey_responses",
"description": "TKMind 功能偏好问卷",
"actions": ["read", "insert"],
"columns": {
"read": ["id", "q1_feature", "q2_usage", "q3_suggestion", "created_at"],
"insert": ["q1_feature", "q2_usage", "q3_suggestion"]
}
}
```
### 3. 页面层:写 HTML
- 用 `write_file` / `edit_file` 写入或更新 `public/*.html`
- 页面视觉、封面、`mindspace-cover`、页脚等规范**参照 `static-page-publish`**
- 必须引入平台脚本:
```html
<script src="/assets/page-data-client.js"></script>
```
### 4. 发布并绑定页面(推荐)
`private_data_bind_workspace_page` 一步完成:**创建/更新页面记录 → 发布 → 写入 Page Data 策略**。
**问卷页(匿名提交)示例:**
```json
{
"relativePath": "public/tkmind-survey.html",
"accessMode": "public",
"datasets": {
"survey_responses": {
"insert": true,
"read": false,
"columns": {
"insert": ["q1_feature", "q2_usage", "q3_suggestion"]
}
}
}
}
```
**后台页(口令查看)示例:**
```json
{
"relativePath": "public/tkmind-survey-admin.html",
"accessMode": "password",
"password": "88888888",
"datasets": {
"survey_responses": {
"insert": false,
"read": true,
"columns": {
"read": ["id", "q1_feature", "q2_usage", "q3_suggestion", "created_at"]
}
}
}
}
```
返回 `pageId`**`workspaceUrl`**`/MindSpace/<用户ID>/public/xxx.html`)。
**交付时必须优先给用户 workspaceUrl**`/u/用户名/pages/...` 仅作补充。bind 会同步工作区 HTML 到发布快照,但禁止先发布占位内容再补文件。
**dataset 名称必须与 HTML 一致**`private_data_register_dataset``name``private_data_bind_workspace_page``datasets` 键名、以及 HTML 里 `insertRow('...')` / `listRows('...')` 的字符串必须完全相同(例如都用 `tkmind_exp_survey`)。若不一致,提交会报「dataset 未授权 insert/read」。
若问卷与后台是多个 HTML 文件,对每个 `public/*.html` 各调用一次 bind
- **问卷页**`accessMode: "public"` + dataset 仅 `insert`
- **后台页**`accessMode: "password"` + dataset 仅 `read`(发布口令至少 8 位)
**顺序**:先 `write_file` 完整 HTML → 再 `bind`;禁止只写「问卷页面」占位文字就发布。
也可手动发布后调用 `private_data_set_page_policy`(需已知 `pageId`)。
### 5. 配置 Page Access Policy(手动路径)
多页场景请**按页**分别配置,勿把 `public` 问卷与 `password` 后台混在同一 `pageId` 策略里。
### 6. 前端读写
```html
<script src="/assets/page-data-client.js"></script>
<script>
// pageId 可省略:平台访问时注入 __MINDSPACE_PAGE_DATA__
const client = MindSpacePageData.createClient({ apiBase: '/api' });
// 口令页后台:await client.authenticate('88888888'); // 口令 ≥8 位,与发布时一致
// 提交:await client.insertRow('survey_responses', { q1_feature: '...', ... });
// 列表:const { rows } = await client.listRows('survey_responses', { limit: 50 });
</script>
```
## 常见场景策略
| 分支 | 场景 | 发布模式 | Page Data 能力 |
|------|------|------------|----------------|
| A(默认) | 匿名问卷 + 口令后台 | 前台 `public`;后台 `password` | insert / read(分页) |
| B | 登录用户各自提交/查看/改自己的 | `login_required` | insert/read/update + `own_rows` |
| C | 共口令协作台账 | `password` | insert/read/update(先 authenticate |
| D | 仅公开收集 | `public` | 仅 `insert` |
| E | 登录提交 + 口令管理全量 | 前台 `login_required`;后台 `password` | insert / read(分页) |
**需要 update/delete**`public` 不支持;匿名无「改自己的」→ 用 B 或 C。详见上文分支表。
## 严格禁止
1. **禁止**在方案摘要未确认时建表 / bind / 发布(默认 A 也须展示摘要;用户明确「按默认做」视为确认)
2. **禁止**让 LLM 自行发明 accessMode/拆页/口令;必须落在分支 A~E 与默认口令规则内
3. **禁止**连续两轮仅输出计划、不调用 `load_skill` / `list_dir` / `write_file` / `private_data_execute` / `bind`
4. **禁止**接受 <8 位发布口令;用户未指定口令时后台默认 **`88888888`**
5. **禁止**创建 `scripts/*-api.mjs`、Express 服务、或监听独立端口(如 `8899`
6. **禁止** HTML 中硬编码 `http://127.0.0.1:端口` 或自定义 `/api/survey/*`
7. **禁止**在 HTML 中使用 `onclick` / `oninput` 等内联事件属性(MindSpace 发布页 CSP 不允许);改用 `addEventListener`
8. **禁止**在页面 JS 中直接使用 `better-sqlite3` 或读取 `.sqlite` 文件路径
9. **禁止**只 `CREATE TABLE` 而不 `private_data_register_dataset`
10. **禁止**未配置 `private_data_set_page_policy` 就让页面调用公开 API
11. **禁止**先发布占位页(如 `<p>问卷页面</p>`)再让用户访问 `/u/.../pages/...`
## 交付前自检
0. 已选定分支 A~E,方案摘要已确认;`password` 页口令 ≥8 位且 bind 已传入
1. `__page_data_datasets` 中存在对应 dataset
2. `.mindspace/page-data-policies/<pageId>.json` 已写入
3. HTML 含 `page-data-client.js`;已 bind 或发布后平台会注入 pageId
4. HTML **不含** `127.0.0.1:``/api/survey/``PLACEHOLDER_PAGE_ID`
5. 向用户说明:访客如何提交、管理员如何用口令查看记录
## 回复格式
除数据能力外,优先返回 **workspaceUrl** 的 Markdown 链接 `[标题](workspaceUrl)`,并简要说明后台入口与口令(如有)。
+4 -2
View File
@@ -325,7 +325,9 @@ export function ChatPanel({
const offlineBlocked = !online;
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill;
const hasPageDataCollectSkill = grantedSkills?.includes('page-data-collect') ?? false;
const canPublish =
Boolean(capabilities?.static_publish) || hasPublishSkill || hasPageDataCollectSkill;
const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish });
const compact = variant === 'compact';
const showHomeWelcome = !compact && messages.length === 0;
@@ -848,7 +850,7 @@ export function ChatPanel({
stopSignal={voiceStopSignal}
/>
</div>
{!showHomeWelcome && chatSkills.length > 0 && (
{chatSkills.length > 0 && (
<ChatSkillPicker
skills={chatSkills}
disabled={voiceDisabled}
+10 -13
View File
@@ -21,6 +21,7 @@ import { createSessionBrokerMetrics, isSessionBrokerMetricsEnabled } from './ses
import { createImgproxySigner } from './imgproxy-signer.mjs';
import { isDirectChatSessionId } from './direct-chat-service.mjs';
import { ensureGooseUserMessageMetadata } from './goose-message.mjs';
import { filterMemoriesByQuery, resolveMemoriesWithLegacyFallback } from './memory-legacy-fallback.mjs';
import { consumeSessionEventsUntilFinish } from './session-reply-wait.mjs';
import {
memoryLimitForIntervention,
@@ -1152,19 +1153,15 @@ export function createTkmindProxy({
const resolvedLimit = Number.isFinite(Number(limit)) && Number(limit) > 0
? Number(limit)
: memoryLimitForIntervention(intervention, { context: 'agent' });
if (resolvedLimit <= 0) return [];
if (memoryV2?.resolve) {
const resolved = await memoryV2.resolve({
userId,
sessionId,
query,
limit: resolvedLimit,
}).catch(() => null);
return Array.isArray(resolved?.memories) ? resolved.memories : [];
}
return conversationMemoryService?.listMemories
? conversationMemoryService.listMemories(userId, { limit: resolvedLimit }).catch(() => [])
: [];
return resolveMemoriesWithLegacyFallback({
memoryV2,
conversationMemoryService,
userId,
sessionId,
query,
limit: resolvedLimit,
recallQuestion,
});
}
async function startSessionForUser(