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:
@@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 退出健康通道/);
|
||||
});
|
||||
@@ -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
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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 : '保存失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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={() => {
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1016,6 +1016,7 @@ export type AuthStatus = {
|
||||
unrestricted?: boolean;
|
||||
agentCodeRun?: AgentCodeRunClientPolicy | null;
|
||||
goalRun?: GoalRunClientPolicy | null;
|
||||
health?: { enabled: boolean } | null;
|
||||
};
|
||||
|
||||
export type PageTemplateCatalogItem = {
|
||||
|
||||
+47
-3
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user