From 7bf16500a15ff4c266ac2ac7caca6150f357809d Mon Sep 17 00:00:00 2001 From: john Date: Wed, 2 Sep 2026 10:05:56 +0800 Subject: [PATCH] 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 --- health-channel-session-store.mjs | 28 ++ health-observation-store.mjs | 36 ++ health-wechat-channel.test.mjs | 108 ++++++ health-wechat-turn.mjs | 331 ++++++++++++++++++ package.json | 2 +- scripts/check-wechat-channel-isolation.mjs | 11 + scripts/run-memind-tests.mjs | 6 +- server.mjs | 13 + server/portal-core-auth-routes.mjs | 6 + server/portal-core-auth-routes.test.mjs | 1 + server/portal-health-routes.mjs | 91 +++++ server/portal-health-routes.test.mjs | 78 +++++ .../portal-integration-services-bootstrap.mjs | 5 + src/App.tsx | 12 + src/api/health.ts | 27 ++ src/components/ChatView.tsx | 31 +- src/context/ChatProvider.tsx | 9 +- src/index.css | 54 +++ src/pages/HealthPage.tsx | 180 ++++++++++ src/types.ts | 1 + wechat-mp.mjs | 50 ++- wechat/handlers/health.mjs | 62 ++++ 22 files changed, 1132 insertions(+), 10 deletions(-) create mode 100644 health-channel-session-store.mjs create mode 100644 health-observation-store.mjs create mode 100644 health-wechat-channel.test.mjs create mode 100644 health-wechat-turn.mjs create mode 100644 server/portal-health-routes.mjs create mode 100644 server/portal-health-routes.test.mjs create mode 100644 src/api/health.ts create mode 100644 src/pages/HealthPage.tsx create mode 100644 wechat/handlers/health.mjs diff --git a/health-channel-session-store.mjs b/health-channel-session-store.mjs new file mode 100644 index 0000000..9bc9cba --- /dev/null +++ b/health-channel-session-store.mjs @@ -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)); + }, + }; +} diff --git a/health-observation-store.mjs b/health-observation-store.mjs new file mode 100644 index 0000000..97c5513 --- /dev/null +++ b/health-observation-store.mjs @@ -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; + }, + }; +} diff --git a/health-wechat-channel.test.mjs b/health-wechat-channel.test.mjs new file mode 100644 index 0000000..f44bcd0 --- /dev/null +++ b/health-wechat-channel.test.mjs @@ -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 退出健康通道/); +}); diff --git a/health-wechat-turn.mjs b/health-wechat-turn.mjs new file mode 100644 index 0000000..c1ccb50 --- /dev/null +++ b/health-wechat-turn.mjs @@ -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, + }; +} diff --git a/package.json b/package.json index 1db487a..943c747 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "verify:experience-agent-run-local": "node scripts/verify-experience-agent-run-local.mjs", "verify:experience-reflect-local": "node scripts/verify-experience-reflect-local.mjs", "test:scenario:john4-diet": "node scripts/run-scenario-test.mjs --scenario john4-children-hobby-diet-update", - "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-voice-reco.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs scheduled-task-intent.test.mjs scheduled-task-service.test.mjs scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs scheduled-task-worker-config.test.mjs wechat/handlers/scheduled-task.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs health-channel-state.test.mjs health-intent-rules.test.mjs health-extraction.test.mjs health-observation-validate.test.mjs health-baseline-maturity.test.mjs health-publish-guard.test.mjs health-p0-experiment.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-long-image.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-audit.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", + "test": "node --test api-core-retry.test.mjs auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-token-state.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-voice-reco.test.mjs wechat-mp.test.mjs wechat-media.test.mjs wechat/image-generation-policy.test.mjs wechat/verify/generated-thumbnail.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs scheduled-task-intent.test.mjs scheduled-task-service.test.mjs scheduled-task-executor.test.mjs scheduled-task-worker.test.mjs scheduled-task-worker-config.test.mjs wechat/handlers/scheduled-task.test.mjs capabilities.test.mjs policies.test.mjs server/portal-api-auth-middleware.test.mjs server/portal-config-routes.test.mjs server/portal-plaza-discovery-routes.test.mjs server/portal-runtime-routes.test.mjs server/portal-gateway-services-bootstrap.test.mjs chat-skills.test.mjs chat-intent-router.test.mjs chat-finish-sync.test.mjs chat-agent-run-gate.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs skill-runtime-policy.test.mjs excel-analyst.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs session-broker.test.mjs sse-event-taxonomy.test.mjs goosed-proxy-boundary.test.mjs agent-run-stream.test.mjs mindspace-h5-html-finish-guard.test.mjs admin-routes.test.mjs services/orchestrator/admin-config.test.mjs services/orchestrator/contracts.test.mjs services/orchestrator/checkpoint.test.mjs services/orchestrator/runtime.test.mjs services/orchestrator/app.test.mjs services/orchestrator/server.test.mjs services/orchestrator/shadow-dispatcher.test.mjs services/orchestrator/shadow-observer.test.mjs services/orchestrator/observability.test.mjs services/orchestrator/executor-gateway.test.mjs services/orchestrator/executor-job-store.test.mjs image-make-admin-config.test.mjs asset-gateway.test.mjs image-make-client.test.mjs mindspace-image-generation.test.mjs mindspace-image-generation-routes.test.mjs mindspace-image-review.test.mjs mindspace-run-public-html-scope.test.mjs direct-chat-service.test.mjs tool-gateway.test.mjs mindspace.test.mjs health-channel-state.test.mjs health-intent-rules.test.mjs health-extraction.test.mjs health-observation-validate.test.mjs 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 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-long-image.test.mjs mindspace-page-purge.test.mjs mindspace-public-delivery.test.mjs mindspace-public-page-context.test.mjs mindspace-published-page-csp.test.mjs mindspace-published-script-localize.test.mjs agent-run-deliverable-check.test.mjs mindspace-public-route.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-chat-save-service.test.mjs mindspace-chat-docx-package.test.mjs mindspace-public-finish-sync.test.mjs mindspace-wechat-html-delivery.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-package-artifact-service.test.mjs mindspace-conversation-package-audit.test.mjs mindspace-conversation-package-backfill.test.mjs mindspace-conversation-package-public-html.test.mjs mindspace-conversation-package-verify.test.mjs mindspace-conversation-package-registry.test.mjs mindspace-conversation-package-routes.test.mjs mindspace-conversation-package-store.test.mjs mindspace-conversation-schema.test.mjs mindspace-runtime-config.test.mjs mindspace-config.test.mjs mindspace-analytics.test.mjs mindspace-service.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-workspace-publication-delivery-service.test.mjs mindspace-workspace-tool-service.test.mjs mindspace-mcp-scoped-token.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs mindspace-userdata-postgres.test.mjs postgres-user-data-space-service.test.mjs user-data-space-service.test.mjs page-data-routes.test.mjs page-access-policy.test.mjs page-access-visitor.test.mjs page-data-public-service.test.mjs page-data-integration.test.mjs page-data-log-store.test.mjs page-data-ops.test.mjs page-data-session-store.test.mjs page-data-browser-client.test.mjs page-data-policy-index.test.mjs message-stream.test.mjs mindspace-service/mindspace-rpc-server.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs memory-v2.test.mjs memory-v2-admin-config.test.mjs memory-v2-lifecycle.test.mjs memory-v2-adapter-scaffold.test.mjs memory-v2-backend-contract.test.mjs memory-v2-health.test.mjs memory-v2-runtime.test.mjs memory-v2-plugin-backends.test.mjs memory-v2-pgvector.test.mjs memory-v2-pgvector-schema.test.mjs memory-v2-pgvector-backfill.test.mjs memory-v2-pgvector-smoke.test.mjs memory-v2-qdrant.test.mjs memory-v2-weaviate.test.mjs memory-v2-mem0.test.mjs memory-v2-letta.test.mjs memory-v2-external-adapters.test.mjs scripts/embed-memory-v2-local-hash.test.mjs scripts/check-memory-v2-app-canary.test.mjs scripts/check-memory-v2-config-gaps.test.mjs scripts/check-memory-v2-contracts.test.mjs scripts/check-memory-v2-health.test.mjs scripts/check-memory-v2-session-flow.test.mjs scripts/check-memory-v2-stack.test.mjs scripts/setup-memory-v2-pgvector-schema.test.mjs scripts/backfill-memory-v2-pgvector.test.mjs scripts/scaffold-memory-v2-backend.test.mjs scripts/smoke-memory-v2-pgvector.test.mjs scripts/smoke-memory-v2-qdrant.test.mjs scripts/smoke-memory-v2-external.test.mjs scripts/mock-memory-v2-services.test.mjs", "test:episodic-memory": "node --test episodic-memory.test.mjs direct-chat-service.test.mjs chat-intent-router.test.mjs", "test:deep-search": "node --test deep-search.test.mjs mindsearch.test.mjs", "test:image-review": "node --test mindspace-image-review.test.mjs mindspace-image-generation.test.mjs", diff --git a/scripts/check-wechat-channel-isolation.mjs b/scripts/check-wechat-channel-isolation.mjs index fb360d5..eca2c78 100644 --- a/scripts/check-wechat-channel-isolation.mjs +++ b/scripts/check-wechat-channel-isolation.mjs @@ -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 }); } diff --git a/scripts/run-memind-tests.mjs b/scripts/run-memind-tests.mjs index 4b5796a..23ab1a6 100644 --- a/scripts/run-memind-tests.mjs +++ b/scripts/run-memind-tests.mjs @@ -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', diff --git a/server.mjs b/server.mjs index 4d4ed9b..b479111 100644 --- a/server.mjs +++ b/server.mjs @@ -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, diff --git a/server/portal-core-auth-routes.mjs b/server/portal-core-auth-routes.mjs index 5dc6a0f..4947821 100644 --- a/server/portal-core-auth-routes.mjs +++ b/server/portal-core-auth-routes.mjs @@ -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( diff --git a/server/portal-core-auth-routes.test.mjs b/server/portal-core-auth-routes.test.mjs index 62d5e92..dc662a1 100644 --- a/server/portal-core-auth-routes.test.mjs +++ b/server/portal-core-auth-routes.test.mjs @@ -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 }, }); }); diff --git a/server/portal-health-routes.mjs b/server/portal-health-routes.mjs new file mode 100644 index 0000000..e1b550f --- /dev/null +++ b/server/portal-health-routes.mjs @@ -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 : '保存失败', + }); + } + }); +} diff --git a/server/portal-health-routes.test.mjs b/server/portal-health-routes.test.mjs new file mode 100644 index 0000000..0ec83ab --- /dev/null +++ b/server/portal-health-routes.test.mjs @@ -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); +}); diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index 986689f..69d0e2d 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -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 = diff --git a/src/App.tsx b/src/App.tsx index a995a54..b160712 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} > @@ -115,6 +119,10 @@ function AuthenticatedApp({ path="/feedback/*" element={} /> + : } + /> } /> @@ -130,6 +138,7 @@ export function App() { const [capabilities, setCapabilities] = useState(); const [grantedSkills, setGrantedSkills] = useState(); const [goalRunEnabled, setGoalRunEnabled] = useState(undefined); + const [healthEnabled, setHealthEnabled] = useState(undefined); const [legacyMode, setLegacyMode] = useState(false); const [authUnavailable, setAuthUnavailable] = useState(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={() => { diff --git a/src/api/health.ts b/src/api/health.ts new file mode 100644 index 0000000..be351fb --- /dev/null +++ b/src/api/health.ts @@ -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', + }), + }); +} diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 181809c..a945385 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -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({ 管理 )} + {healthEnabled && ( + + )} {onOpenSpace && ( +

健康助手

+

只记录你本人的数据。确认前不会写入,也不会在普通聊天落库。

+ + + {state.step === HEALTH_CHANNEL_STEPS.IDLE && ( +
+ + + +
+ )} + + {state.step !== HEALTH_CHANNEL_STEPS.IDLE && ( +
{ + event.preventDefault(); + if (state.step === HEALTH_CHANNEL_STEPS.CONFIRM) void commit(); + else goConfirm(); + }} + > + + + + {state.step === HEALTH_CHANNEL_STEPS.CONFIRM ? ( + + ) : ( + + )} + +
+ )} + + {message &&

{message}

} + +
+

最近记录

+ {grouped.length === 0 ? ( +

还没有确认过的血压记录。

+ ) : ( +
    + {grouped.map((item) => ( +
  • + {new Date(item.createdAt).toLocaleString()} · {item.systolic}/{item.diastolic} · {item.context || 'other'} +
  • + ))} +
+ )} +
+ + ); +} diff --git a/src/types.ts b/src/types.ts index 494baaa..3cbbc21 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1016,6 +1016,7 @@ export type AuthStatus = { unrestricted?: boolean; agentCodeRun?: AgentCodeRunClientPolicy | null; goalRun?: GoalRunClientPolicy | null; + health?: { enabled: boolean } | null; }; export type PageTemplateCatalogItem = { diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 5272196..ed3ce37 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -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({ diff --git a/wechat/handlers/health.mjs b/wechat/handlers/health.mjs new file mode 100644 index 0000000..c7db4bb --- /dev/null +++ b/wechat/handlers/health.mjs @@ -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; +}