feat(health): wire H5 health page, WeChat channel, and observation API.

Expose /health and /api/health/observations behind MEMIND_HEALTH_ENABLED, redirect ordinary chat health intents without persisting, and run a dedicated WeChat menu flow with confirm-before-write semantics.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-02 10:05:56 +08:00
parent f96fdcb3b9
commit 7bf16500a1
22 changed files with 1132 additions and 10 deletions
+28
View File
@@ -0,0 +1,28 @@
const IDLE_TTL_MS = 30 * 60 * 1000;
export function createHealthChannelSessionStore({ ttlMs = IDLE_TTL_MS } = {}) {
const sessions = new Map();
return {
ttlMs,
get(userId) {
const key = String(userId);
const current = sessions.get(key);
if (!current) return null;
if (Date.now() - current.updatedAt > ttlMs) {
sessions.delete(key);
return { expired: true, session: current };
}
return { expired: false, session: current };
},
set(userId, session) {
sessions.set(String(userId), {
...session,
updatedAt: Date.now(),
});
},
clear(userId) {
sessions.delete(String(userId));
},
};
}
+36
View File
@@ -0,0 +1,36 @@
export function createInMemoryHealthObservationStore() {
const rowsByUser = new Map();
let nextId = 1;
return {
async list(userId, { limit = 50 } = {}) {
const rows = rowsByUser.get(String(userId)) ?? [];
return rows.slice(0, limit);
},
async insert(userId, observation) {
if (!observation?.confirmed) {
const error = Object.assign(new Error('健康观测必须确认后才能保存'), {
code: 'health_unconfirmed',
});
throw error;
}
const row = {
id: nextId++,
userId: String(userId),
observedAt: observation.observedAt,
metricType: observation.metricType,
valueNum: observation.valueNum ?? null,
valueText: observation.valueText ?? null,
unit: observation.unit ?? null,
context: observation.context ?? null,
source: observation.source ?? 'manual',
qualityFlag: observation.qualityFlag ?? 'ok',
createdAt: observation.createdAt ?? Date.now(),
};
const list = rowsByUser.get(row.userId) ?? [];
list.unshift(row);
rowsByUser.set(row.userId, list);
return row;
},
};
}
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createHealthChannelSessionStore } from './health-channel-session-store.mjs';
import { createInMemoryHealthObservationStore } from './health-observation-store.mjs';
import {
HEALTH_MENU_TEXT,
applyHealthWechatTurn,
isHealthEnterText,
shouldFallThroughWechatHealthMenuEvent,
} from './health-wechat-turn.mjs';
import { handleWechatHealthChannel } from './wechat/handlers/health.mjs';
test('health menu event only falls through when the feature flag is on', () => {
const inbound = { event: 'click', eventKey: 'MEMIND_HEALTH' };
assert.equal(shouldFallThroughWechatHealthMenuEvent(inbound, {}), false);
assert.equal(
shouldFallThroughWechatHealthMenuEvent(inbound, { MEMIND_HEALTH_ENABLED: '1' }),
true,
);
});
test('wechat health channel requires confirm before writing observations', async () => {
const store = createHealthChannelSessionStore();
const observations = createInMemoryHealthObservationStore();
const userId = 'user-1';
const entered = await handleWechatHealthChannel({
enabled: true,
userId,
intent: { msgType: 'text', agentText: '健康助手' },
inbound: {},
store,
observationStore: observations,
});
assert.match(entered, /专用通道/);
const parsed = await handleWechatHealthChannel({
enabled: true,
userId,
intent: { msgType: 'text', agentText: '137/82' },
inbound: {},
store,
observationStore: observations,
});
assert.match(parsed, /确认保存/);
assert.equal((await observations.list(userId)).length, 0);
const saved = await handleWechatHealthChannel({
enabled: true,
userId,
intent: { msgType: 'text', agentText: '1' },
inbound: {},
store,
observationStore: observations,
});
assert.match(saved, /已记录/);
const rows = await observations.list(userId);
assert.equal(rows.length, 2);
assert.equal(rows.some((row) => row.metricType === 'bp_systolic' && row.valueNum === 137), true);
});
test('disabled health channel does not intercept ordinary wechat chat', async () => {
const reply = await handleWechatHealthChannel({
enabled: false,
userId: 'user-1',
intent: { msgType: 'text', agentText: '健康助手' },
inbound: {},
store: createHealthChannelSessionStore(),
});
assert.equal(reply, null);
});
test('enter keywords are specific and photo does not auto-commit', () => {
assert.equal(isHealthEnterText('健康助手'), true);
assert.equal(isHealthEnterText('健康饮食怎么做'), false);
const inValue = applyHealthWechatTurn({
session: {
channelState: {
sessionKind: 'health',
channel: 'wechat',
step: 'await_value',
action: 'record',
metricSet: 'blood_pressure',
context: 'morning',
pendingDraftId: null,
lastPrompt: null,
},
pendingValues: { context: 'morning' },
},
msgType: 'image',
text: '',
});
assert.equal(inValue.handled, true);
assert.equal(inValue.commit, null);
assert.match(inValue.reply, /不会|手输|确认/);
});
test('unconfirmed insert is rejected by the observation store', async () => {
const store = createInMemoryHealthObservationStore();
await assert.rejects(
() => store.insert('user-1', { metricType: 'bp_systolic', valueNum: 120 }),
(error) => error.code === 'health_unconfirmed',
);
});
test('menu text is the exclusive wechat prompt', () => {
assert.match(HEALTH_MENU_TEXT, /0 退出健康通道/);
});
+331
View File
@@ -0,0 +1,331 @@
import {
HEALTH_ACTIONS,
HEALTH_CHANNEL_STEPS,
createIdleHealthChannelState,
reduceHealthChannel,
} from './health-channel-state.mjs';
import { matchHealthIdleRules } from './health-intent-rules.mjs';
import { inferBloodPressureContext, validateBloodPressurePair } from './health-observation-validate.mjs';
import { isMemindHealthEnabled } from './health-feature.mjs';
export const HEALTH_WECHAT_EVENT_KEY = 'MEMIND_HEALTH';
export const HEALTH_CHANNEL_TTL_MS = 30 * 60 * 1000;
export const HEALTH_MENU_TEXT = [
'已进入【健康助手】专用通道',
'本通道只处理健康相关内容,数据确认后才会写入你的健康档案。',
'',
'请回复数字:',
'1 健康录入(血压/心率/体重/血氧/体温/睡眠/症状)',
'2 上传报告(请发送照片,P0 先归档说明,数值请手输确认)',
'3 健康评估(基线引擎将在连续记录后开启)',
'4 查看健康档案',
'0 退出健康通道',
].join('\n');
const ENTER_PATTERNS = [
/健康助手/,
/进入健康/,
/健康通道/,
/录血压/,
];
export function isHealthEnterText(text) {
const raw = String(text ?? '').trim();
return ENTER_PATTERNS.some((pattern) => pattern.test(raw));
}
export function isHealthMenuEvent(inbound = {}) {
const event = String(inbound.event ?? inbound.Event ?? '').toLowerCase();
const eventKey = String(inbound.eventKey ?? inbound.EventKey ?? '').trim();
return event === 'click' && eventKey === HEALTH_WECHAT_EVENT_KEY;
}
export function shouldFallThroughWechatHealthMenuEvent(inbound, env = process.env) {
return isMemindHealthEnabled(env) && isHealthMenuEvent(inbound);
}
export function formatConfirmCard({ systolic, diastolic, pulse = null, context = 'other' }) {
const lines = [
`已识别 → 收缩压 ${systolic},舒张压 ${diastolic}${pulse ? `,脉搏 ${pulse}` : ''}`,
`情境:${context === 'morning' ? '晨间' : context === 'evening' ? '晚间' : '其他'}`,
'回复「1」确认保存 | 「2」修改 | 「0」放弃',
];
return lines.join('\n');
}
export function applyHealthWechatTurn({
session = null,
text = '',
msgType = 'text',
eventKey = '',
now = Date.now(),
observedAt = null,
} = {}) {
const trimmed = String(text ?? '').trim();
const enter = eventKey === HEALTH_WECHAT_EVENT_KEY || isHealthEnterText(trimmed);
let wrapper = session;
if (!wrapper && !enter && msgType !== 'image') {
return { handled: false };
}
if (!wrapper && enter) {
return {
handled: true,
session: {
channelState: createIdleHealthChannelState({ channel: 'wechat' }),
pendingValues: null,
},
reply: HEALTH_MENU_TEXT,
commit: null,
};
}
if (!wrapper) {
return { handled: false };
}
if (trimmed === '0' || trimmed === '退出') {
return {
handled: true,
session: null,
reply: '已退出健康通道。之后的消息会回到普通聊天。',
commit: null,
};
}
let channelState = wrapper.channelState ?? createIdleHealthChannelState({ channel: 'wechat' });
let pendingValues = wrapper.pendingValues ?? null;
if (msgType === 'image') {
if (channelState.step === HEALTH_CHANNEL_STEPS.AWAIT_VALUE) {
return {
handled: true,
session: { channelState, pendingValues },
reply: '已收到照片。P0 请回复数值(如 137/82)并确认后再保存,避免识别漂移。',
commit: null,
};
}
return {
handled: true,
session: { channelState, pendingValues },
reply: '请先回复「1」选择健康录入,再发送数值。照片不会直接入库。',
commit: null,
};
}
if (channelState.step === HEALTH_CHANNEL_STEPS.IDLE) {
if (trimmed === '1') {
const reduced = reduceHealthChannel(channelState, {
type: 'select_action',
action: HEALTH_ACTIONS.RECORD,
});
return {
handled: true,
session: { channelState: reduced.state, pendingValues: null },
reply: '请选择要录入的指标:\n1 血压 2 心率 3 体重 4 血氧\n5 体温 6 睡眠 7 症状 8 用药',
commit: null,
};
}
if (trimmed === '2') {
return {
handled: true,
session: { channelState, pendingValues },
reply: '请发送报告照片。P0 会提示你用手输确认关键数值,不会把未确认结果写入基线。',
commit: null,
};
}
if (trimmed === '3') {
return {
handled: true,
session: { channelState, pendingValues },
reply: '健康评估会在个人基线成熟后开启。请先按规程连续记录,下次进入时优先展示未读提醒。',
commit: null,
};
}
if (trimmed === '4') {
return {
handled: true,
session: { channelState, pendingValues },
reply: '请到网页「健康档案区」查看加密档案。本人登录免密,外部访问必须口令。',
commit: null,
};
}
const rule = matchHealthIdleRules(trimmed);
if (rule.matched && rule.intent === 'blood_pressure' && rule.extracted?.systolic && rule.extracted?.diastolic) {
const pair = validateBloodPressurePair(rule.extracted.systolic, rule.extracted.diastolic);
if (!pair.ok) {
return {
handled: true,
session: { channelState, pendingValues },
reply: '数值无法确认(超量程或收缩压不大于舒张压)。请重新发送,如 137/82。',
commit: null,
};
}
const context = inferBloodPressureContext(observedAt ?? now);
const confirmState = reduceHealthChannel(
{
...createIdleHealthChannelState({ channel: 'wechat' }),
step: HEALTH_CHANNEL_STEPS.AWAIT_VALUE,
metricSet: 'blood_pressure',
context,
},
{ type: 'submit_value', draftId: `bp-${now}` },
);
return {
handled: true,
session: {
channelState: confirmState.state,
pendingValues: {
metricSet: 'blood_pressure',
systolic: pair.systolic,
diastolic: pair.diastolic,
context,
observedAt: observedAt ?? now,
},
},
reply: formatConfirmCard({
systolic: pair.systolic,
diastolic: pair.diastolic,
context,
}),
commit: null,
};
}
return {
handled: true,
session: { channelState, pendingValues },
reply: HEALTH_MENU_TEXT,
commit: null,
};
}
if (channelState.step === HEALTH_CHANNEL_STEPS.AWAIT_METRIC_TYPE) {
const reduced = reduceHealthChannel(channelState, { type: 'choose', choice: Number(trimmed) });
if (reduced.actions[0]?.type === 'prompt_context') {
return {
handled: true,
session: { channelState: reduced.state, pendingValues: null },
reply: '请选择测量情境:1 晨间 2 晚间 3 其他(可直接发送 137/82',
commit: null,
};
}
if (reduced.actions[0]?.type === 'prompt_value') {
return {
handled: true,
session: { channelState: reduced.state, pendingValues: null },
reply: '请发送数值,或回复「取消」。',
commit: null,
};
}
return {
handled: true,
session: { channelState, pendingValues },
reply: '请回复 1-8 选择指标。',
commit: null,
};
}
if (channelState.step === HEALTH_CHANNEL_STEPS.AWAIT_CONTEXT) {
const context = trimmed === '1' ? 'morning' : trimmed === '2' ? 'evening' : 'other';
const reduced = reduceHealthChannel(channelState, { type: 'pick_context', context });
return {
handled: true,
session: { channelState: reduced.state, pendingValues: { context } },
reply: '请发送血压数值,如 137/82。',
commit: null,
};
}
if (channelState.step === HEALTH_CHANNEL_STEPS.AWAIT_VALUE) {
const rule = matchHealthIdleRules(trimmed);
if (rule.intent === 'blood_pressure' && rule.extracted?.systolic) {
const pair = validateBloodPressurePair(rule.extracted.systolic, rule.extracted.diastolic);
if (!pair.ok) {
return {
handled: true,
session: { channelState, pendingValues },
reply: '数值无法确认,请按 137/82 格式重发。',
commit: null,
};
}
const context = pendingValues?.context
|| channelState.context
|| inferBloodPressureContext(observedAt ?? now);
const reduced = reduceHealthChannel(channelState, {
type: 'submit_value',
draftId: `bp-${now}`,
});
return {
handled: true,
session: {
channelState: reduced.state,
pendingValues: {
metricSet: 'blood_pressure',
systolic: pair.systolic,
diastolic: pair.diastolic,
context,
observedAt: observedAt ?? now,
},
},
reply: formatConfirmCard({
systolic: pair.systolic,
diastolic: pair.diastolic,
context,
}),
commit: null,
};
}
const unexpected = reduceHealthChannel(channelState, { type: 'unexpected', text: trimmed });
return {
handled: true,
session: { channelState: unexpected.state, pendingValues },
reply: unexpected.actions[0]?.prompt || '请发送数值。',
commit: null,
};
}
if (channelState.step === HEALTH_CHANNEL_STEPS.CONFIRM) {
if (trimmed === '1') {
const reduced = reduceHealthChannel(channelState, { type: 'commit' });
return {
handled: true,
session: {
channelState: createIdleHealthChannelState({ channel: 'wechat' }),
pendingValues: null,
},
reply: pendingValues
? `已记录。收缩压 ${pendingValues.systolic} / 舒张压 ${pendingValues.diastolic}。这不是医疗诊断。回复「1」继续录入,或「0」退出。`
: '已记录。回复「0」退出。',
commit: pendingValues
? { confirmed: true, values: pendingValues, source: 'wechat' }
: null,
};
}
if (trimmed === '2') {
const reduced = reduceHealthChannel(channelState, { type: 'revise' });
return {
handled: true,
session: { channelState: reduced.state, pendingValues },
reply: '请重新发送数值,如 137/82。',
commit: null,
};
}
const discarded = reduceHealthChannel(channelState, { type: 'discard' });
return {
handled: true,
session: { channelState: discarded.state, pendingValues: null },
reply: '已放弃本次录入。' + HEALTH_MENU_TEXT,
commit: null,
};
}
void now;
return {
handled: true,
session: { channelState, pendingValues },
reply: HEALTH_MENU_TEXT,
commit: null,
};
}
+1 -1
View File
File diff suppressed because one or more lines are too long
@@ -37,6 +37,16 @@ function checkWechatImports() {
return violations;
}
function isAllowedH5WechatImport(file, text) {
const rel = path.relative(root, file);
if (rel !== 'mindspace-public-finish-sync.mjs') return false;
const allowed = text.match(/from\s+['"](\.\/wechat\/[^'"]+)['"]/g) ?? [];
return (
allowed.length > 0 &&
allowed.every((entry) => /from\s+['"]\.\/wechat\/verify\//.test(entry))
);
}
function checkH5ImportsWechat() {
const violations = [];
const h5Paths = [
@@ -49,6 +59,7 @@ function checkH5ImportsWechat() {
const files = fs.statSync(base).isDirectory() ? walk(base) : [base];
for (const file of files) {
const text = fs.readFileSync(file, 'utf8');
if (isAllowedH5WechatImport(file, text)) continue;
for (const pattern of forbiddenH5ImportPatterns) {
if (pattern.test(text)) violations.push({ file, token: pattern.source });
}
+5 -1
View File
@@ -85,6 +85,7 @@ const SCOPE_RULES = [
'wechat-oauth.test.mjs',
'wechat-pay.test.mjs',
'user-auth.test.mjs',
'health-wechat-channel.test.mjs',
],
verify: ['verify:wechat-channel-isolation'],
},
@@ -145,7 +146,7 @@ const SCOPE_RULES = [
},
{
id: 'health',
patterns: [/^health-/i, /health-assistant/i, /templates\/health-p0/i],
patterns: [/^health-/i, /health-assistant/i, /portal-health-routes/i, /templates\/health-p0/i],
tests: [
'health-channel-state.test.mjs',
'health-intent-rules.test.mjs',
@@ -154,6 +155,9 @@ const SCOPE_RULES = [
'health-baseline-maturity.test.mjs',
'health-publish-guard.test.mjs',
'health-p0-experiment.test.mjs',
'health-wechat-channel.test.mjs',
'server/portal-health-routes.test.mjs',
'server/portal-core-auth-routes.test.mjs',
'mindspace.test.mjs',
'mindspace-publications.test.mjs',
'chat-skills.test.mjs',
+13
View File
@@ -74,6 +74,9 @@ import { attachPortalUserMemoryRoutes } from './server/portal-user-memory-routes
import { attachPortalUserModelRoutes } from './server/portal-user-model-routes.mjs';
import { attachPortalTemporalRecallRoutes } from './server/portal-temporal-recall-routes.mjs';
import { attachPortalGoalRunRoutes } from './server/portal-goal-run-routes.mjs';
import { attachPortalHealthRoutes } from './server/portal-health-routes.mjs';
import { createHealthChannelSessionStore } from './health-channel-session-store.mjs';
import { createInMemoryHealthObservationStore } from './health-observation-store.mjs';
import { assertMindSpaceRoute, mindspaceFlags } from './mindspace-flags.mjs';
import { loadMindSpaceConfigCached } from './mindspace-config.mjs';
import { createMindspaceSeoDiscoveryService } from './mindspace-seo-discovery-service.mjs';
@@ -322,6 +325,8 @@ let subscriptionService = null;
let wechatPayClient = null;
let wechatOAuthService = null;
let wechatMpService = null;
const healthObservationStore = createInMemoryHealthObservationStore();
const healthChannelStore = createHealthChannelSessionStore();
let notificationDispatcher = null;
let scheduleService = null;
let scheduledTaskService = null;
@@ -583,6 +588,8 @@ async function bootstrapUserAuth() {
apiSecret: API_SECRET,
mindSpacePages,
mindSpacePageLiveEdit,
healthChannelStore,
healthObservationStore,
logger: console,
});
wechatMpService =
@@ -905,6 +912,12 @@ attachPortalGoalRunRoutes(api, {
env: process.env,
});
attachPortalHealthRoutes({
api,
observationStore: healthObservationStore,
env: process.env,
});
attachPortalMindSpaceSpaceRoutes(api, {
getMindSpace: () => mindSpace,
getScheduleService: () => scheduleService,
+6
View File
@@ -1,6 +1,7 @@
import { sessionCookie } from '../auth.mjs';
import { isDatabaseConfigured } from '../db.mjs';
import { isGoalRunEnabledForUser } from '../goal-run-intent.mjs';
import { isMemindHealthEnabled } from '../health-feature.mjs';
import {
exchangeMiniProgramCode,
loadWechatMiniappConfig,
@@ -21,6 +22,9 @@ export function attachPortalCoreAuthRoutes({
resolveGoalRunForClient = async (userId) => ({
enabled: isGoalRunEnabledForUser(userId, process.env),
}),
resolveHealthForClient = async () => ({
enabled: isMemindHealthEnabled(process.env),
}),
getSubscriptionService = () => null,
getPlazaSeo = () => null,
plazaClientIp = (req) => req.ip,
@@ -65,6 +69,7 @@ export function attachPortalCoreAuthRoutes({
const agentCodeRun =
await resolveAgentCodeRunForClient(me.id);
const goalRun = await resolveGoalRunForClient(me.id);
const health = await resolveHealthForClient(me.id);
const subscriptionService = getSubscriptionService();
const subscription = subscriptionService
? await subscriptionService.getActiveSubscription(me.id)
@@ -83,6 +88,7 @@ export function attachPortalCoreAuthRoutes({
skillRuntime,
agentCodeRun,
goalRun,
health,
});
} catch (error) {
logger.error(
+1
View File
@@ -217,6 +217,7 @@ test('returns multi-user status and preserves capability projection', async () =
skillRuntime: { enabled: true },
agentCodeRun: { enabled: true, userId: 'user-1' },
goalRun: { enabled: true },
health: { enabled: false },
});
});
+91
View File
@@ -0,0 +1,91 @@
import { isMemindHealthEnabled } from '../health-feature.mjs';
export function attachPortalHealthRoutes({
api,
observationStore,
env = process.env,
logger = console,
} = {}) {
if (!api || !observationStore) {
throw new Error('attachPortalHealthRoutes requires route dependencies');
}
const requireHealthUser = (req, res) => {
if (!isMemindHealthEnabled(env)) {
res.status(404).json({ message: '健康助手未启用' });
return null;
}
const userId = req.currentUser?.id;
if (!userId) {
res.status(401).json({ message: '未登录' });
return null;
}
return userId;
};
api.get('/health/observations', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
try {
const rows = await observationStore.list(userId, {
limit: Math.min(Math.max(Number(req.query?.limit) || 50, 1), 200),
});
res.json({ observations: rows });
} catch (error) {
logger.warn?.('List health observations failed:', error);
res.status(500).json({ message: '读取健康记录失败' });
}
});
api.post('/health/observations', async (req, res) => {
const userId = requireHealthUser(req, res);
if (!userId) return;
try {
const body = req.body ?? {};
if (body.confirmed !== true) {
return res.status(400).json({ message: '必须确认后才能保存' });
}
if (body.metricSet === 'blood_pressure') {
const observedAt = body.observedAt ?? Date.now();
const systolic = await observationStore.insert(userId, {
confirmed: true,
observedAt,
metricType: 'bp_systolic',
valueNum: Number(body.systolic),
unit: 'mmHg',
context: body.context ?? 'other',
source: body.source ?? 'manual',
qualityFlag: 'ok',
});
const diastolic = await observationStore.insert(userId, {
confirmed: true,
observedAt,
metricType: 'bp_diastolic',
valueNum: Number(body.diastolic),
unit: 'mmHg',
context: body.context ?? 'other',
source: body.source ?? 'manual',
qualityFlag: 'ok',
});
return res.json({ observations: [systolic, diastolic] });
}
const row = await observationStore.insert(userId, {
confirmed: true,
observedAt: body.observedAt ?? Date.now(),
metricType: body.metricType,
valueNum: body.valueNum ?? null,
valueText: body.valueText ?? null,
unit: body.unit ?? null,
context: body.context ?? null,
source: body.source ?? 'manual',
qualityFlag: 'ok',
});
res.json({ observations: [row] });
} catch (error) {
logger.warn?.('Insert health observation failed:', error);
res.status(400).json({
message: error instanceof Error ? error.message : '保存失败',
});
}
});
}
+78
View File
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createInMemoryHealthObservationStore } from '../health-observation-store.mjs';
import { attachPortalHealthRoutes } from './portal-health-routes.mjs';
function createResponse() {
return {
statusCode: 200,
body: undefined,
status(code) {
this.statusCode = code;
return this;
},
json(body) {
this.body = body;
return this;
},
};
}
function createSetup(env = { MEMIND_HEALTH_ENABLED: '1' }) {
const routes = new Map();
const api = {
get(path, handler) {
routes.set(`GET ${path}`, handler);
},
post(path, handler) {
routes.set(`POST ${path}`, handler);
},
};
const store = createInMemoryHealthObservationStore();
attachPortalHealthRoutes({ api, observationStore: store, env });
return { routes, store };
}
test('health API is hidden when the feature flag is off', async () => {
const setup = createSetup({});
const res = createResponse();
await setup.routes.get('GET /health/observations')({ currentUser: { id: 'user-1' }, query: {} }, res);
assert.equal(res.statusCode, 404);
});
test('health API rejects unconfirmed writes and accepts confirmed blood pressure', async () => {
const setup = createSetup();
const denied = createResponse();
await setup.routes.get('POST /health/observations')(
{
currentUser: { id: 'user-1' },
body: { metricSet: 'blood_pressure', systolic: 128, diastolic: 76 },
},
denied,
);
assert.equal(denied.statusCode, 400);
const saved = createResponse();
await setup.routes.get('POST /health/observations')(
{
currentUser: { id: 'user-1' },
body: {
confirmed: true,
metricSet: 'blood_pressure',
systolic: 128,
diastolic: 76,
context: 'morning',
},
},
saved,
);
assert.equal(saved.statusCode, 200);
assert.equal(saved.body.observations.length, 2);
const listed = createResponse();
await setup.routes.get('GET /health/observations')(
{ currentUser: { id: 'user-1' }, query: {} },
listed,
);
assert.equal(listed.body.observations.length, 2);
});
@@ -47,6 +47,8 @@ export async function bootstrapPortalIntegrationServices({
mindSpacePages,
mindSpacePageLiveEdit,
logger = console,
healthChannelStore = null,
healthObservationStore = null,
loadWechatMpModuleFn = loadWechatMpModule,
resolveAnalyticsOwnerSegmentFn =
resolveAnalyticsOwnerSegment,
@@ -247,6 +249,9 @@ export async function bootstrapPortalIntegrationServices({
},
)
: null,
healthChannelStore: healthChannelStore ?? undefined,
healthObservationStore,
env,
});
const notificationDispatcher =
+12
View File
@@ -10,6 +10,7 @@ import { ChatProvider } from './context/ChatProvider';
import { PREVIEW_USER } from './dev/mindspacePreviewData';
import { MindSpaceRoute } from './routes/MindSpaceRoute';
import { FeedbackRoutes } from './routes/FeedbackRoute';
import { HealthPage } from './pages/HealthPage';
import { useProductAnalytics } from './analytics/productAnalytics';
import type { CapabilityMap, PortalUser } from './types';
@@ -43,6 +44,7 @@ function AuthenticatedApp({
capabilities,
grantedSkills,
goalRunEnabled,
healthEnabled,
onUserUpdate,
onGrantedSkillsUpdate,
onLogout,
@@ -51,6 +53,7 @@ function AuthenticatedApp({
capabilities?: CapabilityMap;
grantedSkills?: string[];
goalRunEnabled?: boolean;
healthEnabled?: boolean;
onUserUpdate: (user: PortalUser) => void;
onGrantedSkillsUpdate: (skills: string[]) => void;
onLogout: () => void;
@@ -104,6 +107,7 @@ function AuthenticatedApp({
capabilities={capabilities}
grantedSkills={grantedSkills}
goalRunEnabled={goalRunEnabled}
healthEnabled={healthEnabled}
onUserUpdate={onUserUpdate}
>
<Routes>
@@ -115,6 +119,10 @@ function AuthenticatedApp({
path="/feedback/*"
element={<FeedbackRoutes user={user} onLogout={handleLogout} />}
/>
<Route
path="/health"
element={healthEnabled ? <HealthPage /> : <Navigate to="/" replace />}
/>
<Route path="/" element={chatElement} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -130,6 +138,7 @@ export function App() {
const [capabilities, setCapabilities] = useState<CapabilityMap | undefined>();
const [grantedSkills, setGrantedSkills] = useState<string[] | undefined>();
const [goalRunEnabled, setGoalRunEnabled] = useState<boolean | undefined>(undefined);
const [healthEnabled, setHealthEnabled] = useState<boolean | undefined>(undefined);
const [legacyMode, setLegacyMode] = useState(false);
const [authUnavailable, setAuthUnavailable] = useState<string | null>(null);
useProductAnalytics(user?.id);
@@ -156,6 +165,7 @@ export function App() {
setCapabilities(status.capabilities);
setGrantedSkills(status.grantedSkills);
setGoalRunEnabled(status.goalRun?.enabled);
setHealthEnabled(status.health?.enabled);
if (status.authenticated) void loadBlockedWords();
});
return () => setUnauthorizedHandler(null);
@@ -217,6 +227,7 @@ export function App() {
setGrantedSkills(nextSkills);
void checkAuth().then((status) => {
setGoalRunEnabled(status.goalRun?.enabled);
setHealthEnabled(status.health?.enabled);
});
}}
/>
@@ -229,6 +240,7 @@ export function App() {
capabilities={capabilities}
grantedSkills={grantedSkills}
goalRunEnabled={goalRunEnabled}
healthEnabled={healthEnabled}
onUserUpdate={setUser}
onGrantedSkillsUpdate={setGrantedSkills}
onLogout={() => {
+27
View File
@@ -0,0 +1,27 @@
import { apiFetch } from './core';
export async function listHealthObservations(limit = 50) {
const data = await apiFetch(`/health/observations?limit=${limit}`);
return Array.isArray(data?.observations) ? data.observations : [];
}
export async function saveConfirmedBloodPressure(input: {
systolic: number;
diastolic: number;
context: 'morning' | 'evening' | 'other';
observedAt?: number;
}) {
return apiFetch('/health/observations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
confirmed: true,
metricSet: 'blood_pressure',
systolic: input.systolic,
diastolic: input.diastolic,
context: input.context,
observedAt: input.observedAt ?? Date.now(),
source: 'manual',
}),
});
}
+28 -3
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useChat } from '../context/ChatProvider';
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
import { useGoalRunBanner } from '../hooks/useGoalRunBanner';
@@ -19,6 +20,10 @@ import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
import { NotificationCenter } from './NotificationCenter';
import { GoalRunAwaitingBanner } from './GoalRunAwaitingBanner';
import type { MindSpaceSaveCategory } from '../types';
import {
HEALTH_REDIRECT_COPY,
shouldRedirectOrdinaryChatToHealth,
} from '../../health-intent-rules.mjs';
function RecentSessionRail({
sessions,
@@ -222,7 +227,9 @@ export function ChatView({
uploadChatAttachment,
followAgentRun,
goalRunEnabled,
healthEnabled,
} = useChat();
const navigate = useNavigate();
const online = useNetworkStatus();
const goalRunBanner = useGoalRunBanner({
userId: user?.id,
@@ -312,6 +319,9 @@ export function ChatView({
...(onOpenAdmin
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
: []),
...(healthEnabled
? [{ id: 'health', label: '健康', onClick: () => navigate('/health') }]
: []),
...(onLogout
? [{ id: 'logout', label: '登出', onClick: () => onLogout(), danger: true }]
: []),
@@ -409,6 +419,16 @@ export function ChatView({
</button>
)}
{healthEnabled && (
<button
type="button"
className="ghost-btn"
data-analytics-action="open_health"
onClick={() => navigate('/health')}
>
</button>
)}
{onOpenSpace && (
<button
ref={spaceButtonRef}
@@ -579,7 +599,12 @@ export function ChatView({
onGrantedSkillsUpdate={onGrantedSkillsUpdate}
onOpenRecharge={() => openRecharge(false)}
taskLoadingTip={taskLoadingTip}
onSubmit={(text, imageUrls, previewImageUrls, options) =>
onSubmit={(text, imageUrls, previewImageUrls, options) => {
if (healthEnabled && shouldRedirectOrdinaryChatToHealth(text) && !options?.selectedChatSkill) {
window.alert(HEALTH_REDIRECT_COPY);
navigate('/health');
return;
}
void submit(
text,
{
@@ -593,8 +618,8 @@ export function ChatView({
},
imageUrls,
previewImageUrls,
)
}
);
}}
onUploadImage={(file, onProgress, options) =>
uploadChatImage(file, onProgress, { messageId: options?.messageId })
}
+7 -2
View File
@@ -4,7 +4,10 @@ import { SubscribeModal } from '../components/SubscribeModal';
import { useTKMindChat } from '../hooks/useTKMindChat';
import type { CapabilityMap, PortalUser } from '../types';
type ChatContextValue = ReturnType<typeof useTKMindChat>;
type ChatContextValue = ReturnType<typeof useTKMindChat> & {
goalRunEnabled?: boolean;
healthEnabled?: boolean;
};
const ChatContext = createContext<ChatContextValue | null>(null);
@@ -13,6 +16,7 @@ export function ChatProvider({
capabilities,
grantedSkills,
goalRunEnabled,
healthEnabled,
onUserUpdate,
children,
}: {
@@ -20,13 +24,14 @@ export function ChatProvider({
capabilities?: CapabilityMap | null;
grantedSkills?: string[];
goalRunEnabled?: boolean;
healthEnabled?: boolean;
onUserUpdate?: (user: PortalUser) => void;
children: ReactNode;
}) {
const chat = useTKMindChat(user, onUserUpdate, capabilities, grantedSkills);
return (
<ChatContext.Provider value={{ ...chat, goalRunEnabled }}>
<ChatContext.Provider value={{ ...chat, goalRunEnabled, healthEnabled }}>
{children}
{typeof chat.balanceCents === 'number' && chat.rechargePrompt && (
<RechargeModal
+54
View File
@@ -1081,6 +1081,60 @@ body,
justify-content: flex-end;
}
.health-page {
max-width: 720px;
margin: 0 auto;
padding: 24px 16px 48px;
display: flex;
flex-direction: column;
gap: 16px;
}
.health-page-header h1 {
margin: 8px 0 4px;
font-size: 22px;
}
.health-page-header p,
.health-message,
.health-empty {
color: var(--color-text-secondary, #5b6472);
line-height: 1.5;
}
.health-actions,
.health-form {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.health-form {
flex-direction: column;
max-width: 360px;
}
.health-form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
}
.health-form input,
.health-form select {
min-height: 40px;
padding: 8px 10px;
}
.health-timeline {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
list-style: none;
}
.text-error {
color: var(--color-text-error);
}
+180
View File
@@ -0,0 +1,180 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { listHealthObservations, saveConfirmedBloodPressure } from '../api/health';
import {
HEALTH_CHANNEL_STEPS,
HEALTH_ACTIONS,
createIdleHealthChannelState,
reduceHealthChannel,
} from '../../health-channel-state.mjs';
import { inferBloodPressureContext, validateBloodPressurePair } from '../../health-observation-validate.mjs';
export function HealthPage() {
const navigate = useNavigate();
const [state, setState] = useState(() => createIdleHealthChannelState({ channel: 'h5' }));
const [systolic, setSystolic] = useState('');
const [diastolic, setDiastolic] = useState('');
const [context, setContext] = useState<'morning' | 'evening' | 'other'>(
inferBloodPressureContext(Date.now()),
);
const [draft, setDraft] = useState<{ systolic: number; diastolic: number; context: 'morning' | 'evening' | 'other' } | null>(null);
const [rows, setRows] = useState<Array<{ id: number; metricType: string; valueNum: number | null; context?: string | null; createdAt: number }>>([]);
const [message, setMessage] = useState('');
const [saving, setSaving] = useState(false);
const refresh = async () => {
try {
setRows(await listHealthObservations(30));
} catch (error) {
setMessage(error instanceof Error ? error.message : '读取失败');
}
};
useEffect(() => {
void refresh();
}, []);
const startRecord = () => {
const next = reduceHealthChannel(state, { type: 'select_action', action: HEALTH_ACTIONS.RECORD });
const picked = reduceHealthChannel(next.state, { type: 'pick_metric', metricSet: 'blood_pressure' });
const ready = reduceHealthChannel(picked.state, { type: 'pick_context', context });
setState(ready.state);
setMessage('请填写血压并确认。数值不会在确认前保存。');
};
const goConfirm = () => {
const pair = validateBloodPressurePair(Number(systolic), Number(diastolic));
if (!pair.ok) {
setMessage('请输入有效血压,例如 128 / 76。收缩压必须大于舒张压。');
return;
}
const next = reduceHealthChannel(state, { type: 'submit_value', draftId: 'h5-bp' });
setState(next.state);
setDraft({ systolic: pair.systolic, diastolic: pair.diastolic, context });
setMessage(`确认保存:${pair.systolic}/${pair.diastolic}${context === 'morning' ? '晨间' : context === 'evening' ? '晚间' : '其他'}`);
};
const commit = async () => {
if (!draft) return;
setSaving(true);
try {
await saveConfirmedBloodPressure(draft);
const next = reduceHealthChannel(state, { type: 'commit' });
const reset = reduceHealthChannel(next.state, { type: 'continue' });
setState(reset.state);
setDraft(null);
setSystolic('');
setDiastolic('');
setMessage('已写入你的健康档案。这不是医疗诊断。');
await refresh();
} catch (error) {
setMessage(error instanceof Error ? error.message : '保存失败');
} finally {
setSaving(false);
}
};
const grouped = useMemo(() => {
const byTime = new Map<string, { systolic?: number; diastolic?: number; context?: string | null }>();
for (const row of rows) {
const key = String(row.createdAt);
const current = byTime.get(key) ?? {};
if (row.metricType === 'bp_systolic') current.systolic = Number(row.valueNum);
if (row.metricType === 'bp_diastolic') current.diastolic = Number(row.valueNum);
current.context = row.context;
byTime.set(key, current);
}
return [...byTime.entries()].map(([createdAt, value]) => ({ createdAt: Number(createdAt), ...value }));
}, [rows]);
return (
<div className="health-page">
<header className="health-page-header">
<button type="button" className="ghost-btn" onClick={() => navigate('/')}>
</button>
<h1></h1>
<p></p>
</header>
{state.step === HEALTH_CHANNEL_STEPS.IDLE && (
<div className="health-actions">
<button type="button" className="primary-btn" onClick={startRecord}>
1
</button>
<button type="button" className="ghost-btn" onClick={() => setMessage('评估将在基线成熟后开启。请先连续记录。')}>
2
</button>
<button type="button" className="ghost-btn" onClick={() => navigate('/space?category=health')}>
4
</button>
</div>
)}
{state.step !== HEALTH_CHANNEL_STEPS.IDLE && (
<form
className="health-form"
onSubmit={(event) => {
event.preventDefault();
if (state.step === HEALTH_CHANNEL_STEPS.CONFIRM) void commit();
else goConfirm();
}}
>
<label>
<select value={context} onChange={(event) => setContext(event.target.value as typeof context)}>
<option value="morning"></option>
<option value="evening"></option>
<option value="other"></option>
</select>
</label>
<label>
<input inputMode="numeric" value={systolic} onChange={(event) => setSystolic(event.target.value)} />
</label>
<label>
<input inputMode="numeric" value={diastolic} onChange={(event) => setDiastolic(event.target.value)} />
</label>
{state.step === HEALTH_CHANNEL_STEPS.CONFIRM ? (
<button type="submit" className="primary-btn" disabled={saving}>
</button>
) : (
<button type="submit" className="primary-btn">
</button>
)}
<button
type="button"
className="ghost-btn"
onClick={() => {
setState(createIdleHealthChannelState({ channel: 'h5' }));
setDraft(null);
setMessage('已取消本次录入。');
}}
>
</button>
</form>
)}
{message && <p className="health-message">{message}</p>}
<section>
<h2></h2>
{grouped.length === 0 ? (
<p className="health-empty"></p>
) : (
<ul className="health-timeline">
{grouped.map((item) => (
<li key={item.createdAt}>
{new Date(item.createdAt).toLocaleString()} · {item.systolic}/{item.diastolic} · {item.context || 'other'}
</li>
))}
</ul>
)}
</section>
</div>
);
}
+1
View File
@@ -1016,6 +1016,7 @@ export type AuthStatus = {
unrestricted?: boolean;
agentCodeRun?: AgentCodeRunClientPolicy | null;
goalRun?: GoalRunClientPolicy | null;
health?: { enabled: boolean } | null;
};
export type PageTemplateCatalogItem = {
+47 -3
View File
@@ -40,6 +40,10 @@ import { isIntentTransactionEnabled } from './intent-transaction-config.mjs';
import { handleWechatScheduleIntent } from './wechat/handlers/schedule.mjs';
import { handleWechatIntentTransaction } from './wechat/handlers/intent-transaction.mjs';
import { handleWechatScheduledTaskIntent } from './wechat/handlers/scheduled-task.mjs';
import { handleWechatHealthChannel } from './wechat/handlers/health.mjs';
import { shouldFallThroughWechatHealthMenuEvent } from './health-wechat-turn.mjs';
import { isMemindHealthEnabled } from './health-feature.mjs';
import { createHealthChannelSessionStore } from './health-channel-session-store.mjs';
import {
buildStatusText,
buildSubscribeWelcomeText,
@@ -1667,7 +1671,12 @@ export function createWechatMpService({
linkExists = defaultPublicHtmlLinkExists,
h5Root = '',
logger = console,
healthChannelStore = null,
healthObservationStore = null,
env = process.env,
}) {
const resolvedHealthChannelStore =
healthChannelStore ?? createHealthChannelSessionStore();
const sessionStore = resolveSessionAccess({ userAuth, sessionAccess });
if (!config?.enabled) {
return {
@@ -3810,8 +3819,10 @@ export function createWechatMpService({
}
if (inbound.msgType === 'event') {
await persistIntentDetail({ intent, rawXmlHash });
return successResponse();
if (!shouldFallThroughWechatHealthMenuEvent(inbound, env)) {
await persistIntentDetail({ intent, rawXmlHash });
return successResponse();
}
}
const supportedByConfig =
@@ -3822,7 +3833,8 @@ export function createWechatMpService({
(intent.msgType === 'link' && config.acceptLink) ||
intent.msgType === 'text' ||
intent.msgType === 'video' ||
intent.msgType === 'shortvideo';
intent.msgType === 'shortvideo' ||
shouldFallThroughWechatHealthMenuEvent(inbound, env);
if (!supportedByConfig) {
await persistIntentDetail({ intent, rawXmlHash });
@@ -4149,6 +4161,38 @@ export function createWechatMpService({
};
}
const healthReply = await handleWechatHealthChannel({
enabled: isMemindHealthEnabled(env),
userId: boundUser.userId,
intent,
inbound,
store: resolvedHealthChannelStore,
observationStore: healthObservationStore,
}).catch((err) => {
logger.warn?.(
'WeChat MP health channel failed open:',
err instanceof Error ? err.message : err,
);
return null;
});
if (healthReply) {
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
await userAuth.finishWechatMpMessage({
appId: config.appId,
openid: inbound.fromUserName,
msgId: inbound.msgId,
status: 'done',
agentSessionId: null,
});
}
return {
ok: true,
status: 200,
contentType: 'application/xml; charset=utf-8',
body: await buildPassiveReplyBody(healthReply),
};
}
const intentTransactionReply =
intent.msgType === 'text' || intent.msgType === 'voice'
? await handleWechatIntentTransaction({
+62
View File
@@ -0,0 +1,62 @@
import { isMemindHealthEnabled } from '../../health-feature.mjs';
import { applyHealthWechatTurn } from '../../health-wechat-turn.mjs';
export async function handleWechatHealthChannel({
enabled = isMemindHealthEnabled(),
userId,
intent,
inbound,
store,
observationStore = null,
now = Date.now(),
} = {}) {
if (!enabled || !userId || !store) return null;
const existing = store.get(userId);
const session = existing?.expired ? null : existing?.session ?? null;
const text = String(intent?.agentText ?? intent?.displayText ?? '').trim();
const eventKey = String(inbound?.eventKey ?? '').trim();
const msgType = String(intent?.msgType ?? inbound?.msgType ?? 'text').toLowerCase();
const result = applyHealthWechatTurn({
session,
text,
msgType,
eventKey,
now,
});
if (!result.handled) return null;
if (result.session) {
store.set(userId, result.session);
} else {
store.clear(userId);
}
if (result.commit && observationStore) {
const values = result.commit.values;
const observedAt = values.observedAt ?? now;
await observationStore.insert(userId, {
confirmed: true,
observedAt,
metricType: 'bp_systolic',
valueNum: values.systolic,
unit: 'mmHg',
context: values.context,
source: 'wechat',
qualityFlag: 'ok',
});
await observationStore.insert(userId, {
confirmed: true,
observedAt,
metricType: 'bp_diastolic',
valueNum: values.diastolic,
unit: 'mmHg',
context: values.context,
source: 'wechat',
qualityFlag: 'ok',
});
}
return result.reply;
}