From be464a5b8de763475d53257eb6b417b1e89ca6e4 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 4 Sep 2026 13:49:40 +0800 Subject: [PATCH] Add Rain V0 for MeInput full-range chat analysis and delivery tooling. Introduce rain-service orchestration, browser-safe chat skill filtering, MeInput adapter helpers, and verify/deploy scripts so Rain mode can summarize recent input without Memory V2 pollution. Co-authored-by: Cursor --- .env.example | 5 +- agent-run-gateway.mjs | 135 +++++-- chat-skills.mjs | 41 +++ chat-skills.test.mjs | 27 +- direct-chat-service.mjs | 36 +- rain-service.test.mjs | 99 ++++++ rain-service/index.mjs | 90 +++++ rain-service/llm-analysis.mjs | 118 +++++++ rain-service/meinput-full.mjs | 46 +++ rain-service/time-range.mjs | 105 ++++++ scripts/agent-run-worker.mjs | 1 + scripts/deploy-tang-meinput-pages-103.mjs | 176 ++++++++++ .../generate-tang-behavior-analysis-page.mjs | 332 ++++++++++++++++++ scripts/verify-meinput-recall-chat.mjs | 168 +++++++++ scripts/verify-rain-chat.mjs | 84 +++++ server/portal-gateway-services-bootstrap.mjs | 1 + src/components/ChatPanel.tsx | 2 +- temporal-recall-service/adapters/meinput.mjs | 59 ++++ 18 files changed, 1475 insertions(+), 50 deletions(-) create mode 100644 rain-service.test.mjs create mode 100644 rain-service/index.mjs create mode 100644 rain-service/llm-analysis.mjs create mode 100644 rain-service/meinput-full.mjs create mode 100644 rain-service/time-range.mjs create mode 100644 scripts/deploy-tang-meinput-pages-103.mjs create mode 100644 scripts/generate-tang-behavior-analysis-page.mjs create mode 100644 scripts/verify-meinput-recall-chat.mjs create mode 100644 scripts/verify-rain-chat.mjs diff --git a/.env.example b/.env.example index 6ec10db..2c115ef 100644 --- a/.env.example +++ b/.env.example @@ -578,10 +578,13 @@ MEMIND_RUNTIME_PROFILE=local # UMS_INGEST_TOKEN=local-dev-ums-ingest-token # tang19821002 → 微信「唐」:同一人的多账号归并到 canonical user_id # MEMIND_CANONICAL_USER_MAP=d0678bbc-2a50-4e08-8bf0-6b6c9301e2d6=a70ff537-8908-486e-9b6c-042e07cc25db +# Portal john → 本地 MeInput testuser2(Rain 拉取 testuser2 的 mi_input_events) +# MEMIND_CANONICAL_USER_MAP=1c99b83b-0454-474f-a5d2-129d34506a32=3f24dcbb-0505-4f0a-a432-828f608e2448 # 迁移:node user-model-service/migrate.mjs # Temporal Recall — 时间回忆域(Context Planner + Multi-source Retrieval) -# MEINPUT_DATABASE_URL=mysql://boot:888888@localhost:3306/meinput +# MEMIND_HIDE_PAGE_TEMPLATES=1 # Rain 模式默认隐藏页面模板 skill 与模板商城入口 +# MEMIND_RAIN_REPLACE_TEMPLATES=1 # MEINPUT_BASE_URL=http://127.0.0.1:8090 # 测试:node scripts/smoke-temporal-recall.mjs "我这周有什么重要的事?" # MEMIND_RUNTIME_CONTEXT_ENABLED=1 diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index ed7ead1..581c5a3 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -51,6 +51,7 @@ import { resolveDirectEscalationContextPolicy, } from './chat-task-intent-config.mjs'; import { extractAgentRunExperience } from './experience-extractor.mjs'; +import { executeRainPipeline, isRainModeMessage } from './rain-service/index.mjs'; const DEFAULT_RUN_RETRY_DELAYS_MS = [1500, 5000, 15000]; const TERMINAL_STATUSES = new Set(['succeeded', 'failed']); @@ -845,6 +846,7 @@ export function createAgentRunGateway({ tkmindProxy, toolGateway = null, directChatService = null, + llmProviderService = null, systemDisclosurePolicyService = null, chatIntentRouter = null, sessionSnapshotService = null, @@ -1771,6 +1773,72 @@ export function createAgentRunGateway({ policyBlocked: true, }; } + let rainModeActive = isRainModeMessage(userMessage); + if (rainModeActive) { + if (!llmProviderService?.createChatCompletion) { + const error = new Error('Rain 模式需要 LLM 服务,但当前未配置'); + error.code = 'RAIN_LLM_UNAVAILABLE'; + error.retryable = false; + throw error; + } + const rain = await executeRainPipeline({ + llmProviderService, + userId: row.user_id, + userMessage, + }); + await appendEvent(runId, 'rain_pipeline', { + phase: rain.phase, + recordCount: rain.rainMeta?.recordCount ?? 0, + timeSource: rain.rainMeta?.timeResolution?.source ?? null, + }); + if (rain.phase === 'clarify') { + const result = await directChatService.respondDeterministically({ + userId: row.user_id, + sessionId: row.agent_session_id ?? null, + requestId: row.request_id, + userMessage, + reply: rain.userReply, + metadata: { + source: 'portal-rain-clarify', + rainMode: true, + }, + onSessionReady: async (activeSessionId) => { + await pool.query( + `UPDATE h5_agent_runs SET agent_session_id = ?, updated_at = ? WHERE id = ?`, + [activeSessionId, nowMs(), runId], + ); + await appendRunSnapshot(runId); + }, + }); + await appendEvent(runId, 'rain_clarify_completed', { + sessionId: result.sessionId, + }); + return { + sessionId: result.sessionId, + routing: null, + rainClarify: true, + }; + } + userMessage = { + ...userMessage, + content: [{ type: 'text', text: rain.gooseHandoffText }], + metadata: { + ...(userMessage?.metadata ?? {}), + memindRun: { + ...(userMessage?.metadata?.memindRun ?? {}), + rainMode: true, + rainHandoff: true, + }, + agentVisible: true, + userVisible: userMessage?.metadata?.userVisible ?? true, + }, + }; + runOptions = { + ...runOptions, + rainMode: true, + forceDeepReasoning: runOptions.forceDeepReasoning ?? false, + }; + } const routing = await resolveRunRouting(row, userMessage, runOptions, { runId }); const routingDecision = resolveLegacyRouteFromClassification(routing) ?? routing?.route ?? null; const cursorFirstApplied = applyCursorFirstAgentExecution(userMessage, runOptions, { @@ -1808,7 +1876,12 @@ export function createAgentRunGateway({ if (!(await fallbackCursorExecutorToDeepseek(err))) throw err; } let agentMemoryContext = null; - if (routingDecision === CHAT_INTENT_ROUTE.AGENT && chatIntentRouter?.resolveAgentMemoryContext) { + const rainActive = rainModeActive || runOptions.rainMode === true; + if ( + routingDecision === CHAT_INTENT_ROUTE.AGENT && + !rainActive && + chatIntentRouter?.resolveAgentMemoryContext + ) { const displayText = userMessage?.metadata?.displayText ?? userMessage?.content?.find?.((item) => item?.type === 'text')?.text ?? ''; @@ -1877,33 +1950,36 @@ export function createAgentRunGateway({ const grantedSkills = await resolveGrantedSkills(row.user_id); let goalContext = null; let runtimeContext = null; + const rainHandoff = userMessage?.metadata?.memindRun?.rainHandoff === true; const orchestrationDisplayText = userMessage?.metadata?.displayText ?? userMessage?.content?.find?.((item) => item?.type === 'text')?.text ?? ''; - try { - runtimeContext = await resolveRuntimeContext({ - pool, - getUmsPool, - userId: row.user_id, - query: orchestrationDisplayText, - sessionId: row.agent_session_id ?? null, - }); - if (runtimeContext?.injectionEnabled) { - await appendEvent(runId, 'runtime_context_resolved', { - query_type: runtimeContext.plan?.query_type ?? null, - temporal_mode: runtimeContext.plan?.temporal_mode ?? null, - temporal_items: runtimeContext.temporalRecall?.stats?.returned_count ?? 0, - has_snapshot: Boolean(runtimeContext.blocks?.snapshot), - injection_chars: runtimeContext.injectionText?.length ?? 0, + if (!rainActive && !rainHandoff) { + try { + runtimeContext = await resolveRuntimeContext({ + pool, + getUmsPool, + userId: row.user_id, + query: orchestrationDisplayText, + sessionId: row.agent_session_id ?? null, }); + if (runtimeContext?.injectionEnabled) { + await appendEvent(runId, 'runtime_context_resolved', { + query_type: runtimeContext.plan?.query_type ?? null, + temporal_mode: runtimeContext.plan?.temporal_mode ?? null, + temporal_items: runtimeContext.temporalRecall?.stats?.returned_count ?? 0, + has_snapshot: Boolean(runtimeContext.blocks?.snapshot), + injection_chars: runtimeContext.injectionText?.length ?? 0, + }); + } + } catch (err) { + console.warn( + '[AgentRun] runtime context resolve skipped:', + err instanceof Error ? err.message : err, + ); } - } catch (err) { - console.warn( - '[AgentRun] runtime context resolve skipped:', - err instanceof Error ? err.message : err, - ); } - if (goalRunService && row.goal_run_id) { + if (!rainActive && !rainHandoff && goalRunService && row.goal_run_id) { try { const goal = await goalRunService.getGoalRun({ userId: row.user_id, @@ -1920,15 +1996,18 @@ export function createAgentRunGateway({ ); } } - userMessage = chatIntentRouter.applyAgentOrchestration(userMessage, routing, { - grantedSkills, - memoryContext: agentMemoryContext, - goalContext, - runtimeContext, - }); + if (!rainHandoff) { + userMessage = chatIntentRouter.applyAgentOrchestration(userMessage, routing, { + grantedSkills, + memoryContext: agentMemoryContext, + goalContext, + runtimeContext, + }); + } } } const preferDirectChat = + !rainActive && !cursorFirstAgent && (routingDecision === CHAT_INTENT_ROUTE.DIRECT_CHAT || (isDirectChatSessionId(row.agent_session_id ?? null) && !runOptions.forceDeepReasoning)); diff --git a/chat-skills.mjs b/chat-skills.mjs index 38f261b..faaa7cb 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -17,9 +17,34 @@ export const PAGE_DATA_COLLECT_SKILL_NAME = 'page-data-collect'; export const EXCEL_ANALYST_SKILL_NAME = 'excel-analyst'; export const SCHEDULED_TASK_AUTOMATION_SKILL_NAME = 'scheduled-task-automation'; export const AIDER_DEVELOPMENT_SKILL_NAME = 'aider-development'; +export const RAIN_SKILL_NAME = 'rain'; export const SKILL_ROUTER_V2_ENV = 'TKMIND_SKILL_ROUTER_V2'; const PAGE_TEMPLATE_SKILL_PREFIX = 'page-template-'; +export function isRainModeMessage(message) { + const selected = + message?.metadata?.memindRun?.selectedChatSkill ?? + message?.metadata?.selectedChatSkill; + return String(selected ?? '').trim() === RAIN_SKILL_NAME; +} + +export function isPageTemplateSkillDefinition(def) { + const id = String(def?.id ?? ''); + const skillName = String(def?.skillName ?? ''); + return id.startsWith(PAGE_TEMPLATE_SKILL_PREFIX) || skillName.startsWith(PAGE_TEMPLATE_SKILL_PREFIX); +} + +function hidePageTemplateSkillsEnabled(env) { + const runtimeEnv = + env ?? + (typeof process !== 'undefined' && process.env ? process.env : {}); + const raw = + runtimeEnv.MEMIND_HIDE_PAGE_TEMPLATES ?? + runtimeEnv.MEMIND_RAIN_REPLACE_TEMPLATES ?? + '1'; + return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase()); +} + function isPageTemplatePromptKey(promptKey) { return String(promptKey ?? '').startsWith(PAGE_TEMPLATE_SKILL_PREFIX); } @@ -174,6 +199,14 @@ export function isProductCampaignIntent(text) { } export const CHAT_SKILL_DEFINITIONS = [ + { + id: RAIN_SKILL_NAME, + label: 'Rain', + icon: 'analyze', + skillName: RAIN_SKILL_NAME, + prefillOnly: true, + promptKey: 'rain', + }, { id: 'web-search', label: '查资料', @@ -515,6 +548,11 @@ export function buildChatSkillPrompt(promptKey, skillName) { `请使用 ${skillName ?? 'image-generation'} 技能:把我的简短视觉需求自动扩展成完整 prompt 与独立 negative_prompt,实际调用 generate_image 生成并校验新位图。` + '不要要求我提供 purpose、构图术语、负面词或 jobId;页面主图默认 purpose=hero,生成成功后 HTML 必须使用返回的 asset.htmlSrc,workspaceRelativePath 只用于文件操作。禁止 SVG、CSS 绘图、旧图和占位图冒充。我的需求是:' ); + case 'rain': + return ( + '【Rain · MeInput 输入分析】请描述要分析的时间区间和你的诉求(例如「总结昨天我在做什么」)。' + + '未写时间则默认近3天;区间不明确时我会先追问。我的问题是:' + ); case 'summarize': return '请总结以下内容,提炼核心结论、重点信息和可执行建议(条理清晰、中文输出):'; case 'analyze': @@ -620,6 +658,9 @@ export { buildWebNewsSkillPrompt }; export function filterChatSkills(options, ctx) { return options.filter((skill) => { + if (hidePageTemplateSkillsEnabled() && isPageTemplateSkillDefinition(skill)) { + return false; + } if (skill.requiresPublish && !ctx.canPublish) { const pageDataGranted = skill.skillName === PAGE_DATA_COLLECT_SKILL_NAME && diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index 3ced536..7836f2e 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -117,14 +117,31 @@ test('filterChatSkills shows generate-page when publish is allowed', () => { assert.ok(visible.some((item) => item.id === 'generate-page')); }); -test('filterChatSkills shows page templates when granted', () => { +test('filterChatSkills hides page templates by default and shows Rain', () => { const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { canPublish: true, - grantedSkills: ['page-template-travel', 'page-template-campaign'], + grantedSkills: ['page-template-travel', 'page-template-campaign', 'rain'], }); - assert.ok(visible.some((item) => item.id === 'page-template-travel')); - assert.ok(visible.some((item) => item.id === 'page-template-campaign')); - assert.equal(visible.some((item) => item.id === 'page-template-survey'), false); + assert.ok(visible.some((item) => item.id === 'rain')); + assert.equal(visible.some((item) => item.id === 'page-template-travel'), false); + assert.equal(visible.some((item) => item.id === 'page-template-campaign'), false); +}); + +test('filterChatSkills shows page templates when explicitly enabled', () => { + const prev = process.env.MEMIND_HIDE_PAGE_TEMPLATES; + process.env.MEMIND_HIDE_PAGE_TEMPLATES = '0'; + try { + const visible = filterChatSkills(CHAT_SKILL_DEFINITIONS, { + canPublish: true, + grantedSkills: ['page-template-travel', 'page-template-campaign'], + }); + assert.ok(visible.some((item) => item.id === 'page-template-travel')); + assert.ok(visible.some((item) => item.id === 'page-template-campaign')); + assert.equal(visible.some((item) => item.id === 'page-template-survey'), false); + } finally { + if (prev == null) delete process.env.MEMIND_HIDE_PAGE_TEMPLATES; + else process.env.MEMIND_HIDE_PAGE_TEMPLATES = prev; + } }); test('buildChatSkillPrompt includes skill name for platform skills', () => { diff --git a/direct-chat-service.mjs b/direct-chat-service.mjs index 80ad17c..2b0d0f0 100644 --- a/direct-chat-service.mjs +++ b/direct-chat-service.mjs @@ -2,6 +2,7 @@ import { resolveRuntimeContext } from './temporal-recall-service/runtime-context import { MEMORY_INTERVENTION_LIMIT } from './memory-intervention.mjs'; import { resolveMemoriesWithLegacyFallback } from './memory-legacy-fallback.mjs'; import { isMemoryRecallQuestion } from './chat-intent-router.mjs'; +import { isRainModeMessage } from './chat-skills.mjs'; import { resolveSessionAccess } from './session-broker.mjs'; export const DIRECT_CHAT_SESSION_PREFIX = 'h5direct_'; @@ -383,24 +384,29 @@ export function createDirectChatService({ pendingMessages, ); const recallQuestion = isMemoryRecallQuestion(messageText(userMessage)); + const rainMode = isRainModeMessage(userMessage); const routedMemoryContent = - !recallQuestion && - routingMemory && !routingMemory.skipped && !routingMemory.degraded - ? String(routingMemory.content ?? '').trim() - : ''; - const memories = routedMemoryContent + rainMode + ? '' + : !recallQuestion && + routingMemory && !routingMemory.skipped && !routingMemory.degraded + ? String(routingMemory.content ?? '').trim() + : ''; + const memories = rainMode ? [] : await resolveMemories(userId, activeSessionId, messageText(userMessage)); - const runtimeContext = await resolveRuntimeContext({ - pool, - getUmsPool, - userId, - query: messageText(userMessage), - sessionId: activeSessionId, - }).catch((err) => { - logger?.warn?.(`[direct-chat] runtime context skipped: ${err instanceof Error ? err.message : err}`); - return { injectionText: '' }; - }); + const runtimeContext = rainMode + ? { injectionText: '' } + : await resolveRuntimeContext({ + pool, + getUmsPool, + userId, + query: messageText(userMessage), + sessionId: activeSessionId, + }).catch((err) => { + logger?.warn?.(`[direct-chat] runtime context skipped: ${err instanceof Error ? err.message : err}`); + return { injectionText: '' }; + }); const completion = await llmProviderService.createChatCompletion({ messages: buildModelMessages({ previousMessages, diff --git a/rain-service.test.mjs b/rain-service.test.mjs new file mode 100644 index 0000000..78c89e0 --- /dev/null +++ b/rain-service.test.mjs @@ -0,0 +1,99 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveRainTimeRange, RAIN_DEFAULT_DAYS } from './rain-service/time-range.mjs'; +import { formatMeinputFullBlock } from './rain-service/meinput-full.mjs'; +import { buildRainGooseHandoffText, stripRainSkillPrefix } from './rain-service/llm-analysis.mjs'; +import { + RAIN_SKILL_NAME, + isRainModeMessage, + filterChatSkills, + CHAT_SKILL_DEFINITIONS, +} from './chat-skills.mjs'; + +test('resolveRainTimeRange defaults to 3 days when no time hint', () => { + const now = new Date('2026-09-04T12:00:00+08:00'); + const result = resolveRainTimeRange('帮我总结最近的输入', now); + assert.equal(result.needsClarification, false); + assert.equal(result.source, 'default_3d'); + assert.ok(result.range?.start); + assert.ok(result.range?.end); + const spanMs = + new Date(result.range.end).getTime() - new Date(result.range.start).getTime(); + assert.ok(spanMs >= RAIN_DEFAULT_DAYS * 24 * 60 * 60 * 1000 - 60_000); +}); + +test('resolveRainTimeRange asks clarification for ambiguous phrases', () => { + const result = resolveRainTimeRange('前几天我在做什么', new Date('2026-09-04T12:00:00+08:00')); + assert.equal(result.needsClarification, true); + assert.match(result.clarificationQuestion ?? '', /起止时间/); +}); + +test('resolveRainTimeRange parses yesterday explicitly', () => { + const now = new Date('2026-09-04T12:00:00+08:00'); + const result = resolveRainTimeRange('我昨天输入了什么', now); + assert.equal(result.needsClarification, false); + assert.equal(result.source, 'user_explicit'); + assert.equal(result.label, 'yesterday'); +}); + +test('formatMeinputFullBlock lists every record with timestamp', () => { + const block = formatMeinputFullBlock( + [ + { + created_at: '2026-09-02T19:42:16.541Z', + text: '项目', + app_name: 'Cursor', + app_bundle_id: 'com.cursor', + }, + { + created_at: '2026-09-02T19:46:15.181Z', + text: '手机', + app_name: null, + app_bundle_id: 'com.apple.mobile', + }, + ], + { + range: { start: '2026-09-01T00:00:00.000Z', end: '2026-09-04T00:00:00.000Z' }, + label: '近3天', + }, + ); + assert.match(block, /MeInput 原始输入 · Rain/); + assert.match(block, /项目/); + assert.match(block, /手机/); + assert.match(block, /2026-09-02 19:42:16/); + assert.doesNotMatch(block, /0\.2795/); +}); + +test('buildRainGooseHandoffText carries analysis to agent', () => { + const text = buildRainGooseHandoffText({ + userQuery: '最近我输入了什么', + timeRangeLabel: '近3天', + recordCount: 12, + analysis: '用户在 Cursor 中输入了项目相关词', + userGoal: '回顾输入', + suggestedNextSteps: ['给用户时间线总结'], + }); + assert.match(text, /Rain · MeInput 分析简报/); + assert.match(text, /用户在 Cursor 中输入了项目相关词/); + assert.match(text, /最近我输入了什么/); +}); + +test('isRainModeMessage detects selected rain skill', () => { + assert.equal( + isRainModeMessage({ metadata: { memindRun: { selectedChatSkill: RAIN_SKILL_NAME } } }), + true, + ); + assert.equal(isRainModeMessage({ metadata: { memindRun: { selectedChatSkill: 'web' } } }), false); +}); + +test('filterChatSkills hides page templates by default', () => { + const options = CHAT_SKILL_DEFINITIONS.map((def) => ({ ...def, buildPrompt: () => '' })); + const filtered = filterChatSkills(options, { grantedSkills: ['page-template-travel'], canPublish: true }); + assert.ok(filtered.some((item) => item.id === RAIN_SKILL_NAME)); + assert.equal(filtered.some((item) => item.id === 'page-template-travel'), false); +}); + +test('stripRainSkillPrefix removes rain prompt header', () => { + const text = stripRainSkillPrefix('【Rain · MeInput 输入分析】请描述要分析的时间区间和你的诉求。未写时间则默认近3天;区间不明确时我会先追问。我的问题是:总结输入'); + assert.equal(text, '总结输入'); +}); diff --git a/rain-service/index.mjs b/rain-service/index.mjs new file mode 100644 index 0000000..129de1f --- /dev/null +++ b/rain-service/index.mjs @@ -0,0 +1,90 @@ +import { RAIN_SKILL_NAME, isRainModeMessage } from '../chat-skills.mjs'; +import { loadRainMeinputRecords, formatMeinputFullBlock } from './meinput-full.mjs'; +import { runRainLlmAnalysis, buildRainGooseHandoffText, stripRainSkillPrefix } from './llm-analysis.mjs'; +import { resolveRainTimeRange, formatRainTimeRangeLabel } from './time-range.mjs'; + +export { RAIN_SKILL_NAME, isRainModeMessage }; +export { resolveRainTimeRange, formatRainTimeRangeLabel, RAIN_DEFAULT_DAYS } from './time-range.mjs'; +export { formatMeinputFullBlock, loadRainMeinputRecords } from './meinput-full.mjs'; +export { runRainLlmAnalysis, buildRainGooseHandoffText, stripRainSkillPrefix } from './llm-analysis.mjs'; + +/** + * @param {{ + * llmProviderService: object, + * userId: string, + * userMessage: object, + * }} input + * @returns {Promise< + * | { phase: 'clarify', userReply: string, rainMeta: object } + * | { phase: 'goose', gooseHandoffText: string, userReply: string, rainMeta: object } + * >} + */ +export async function executeRainPipeline(input) { + const displayText = + input.userMessage?.metadata?.displayText ?? + stripRainSkillPrefix( + Array.isArray(input.userMessage?.content) + ? input.userMessage.content + .filter((item) => item?.type === 'text') + .map((item) => item.text ?? '') + .join('\n') + : String(input.userMessage?.content ?? ''), + ); + + const timeResolution = resolveRainTimeRange(displayText); + if (timeResolution.needsClarification) { + return { + phase: 'clarify', + userReply: timeResolution.clarificationQuestion, + rainMeta: { timeResolution, recordCount: 0 }, + }; + } + + const timeRangeLabel = formatRainTimeRangeLabel(timeResolution.range, timeResolution); + const { records } = await loadRainMeinputRecords({ + userId: input.userId, + range: timeResolution.range, + }); + const meinputBlock = formatMeinputFullBlock(records, { + range: timeResolution.range, + label: timeResolution.label, + source: timeResolution.source, + }); + + const analysis = await runRainLlmAnalysis({ + llmProviderService: input.llmProviderService, + userQuery: displayText, + meinputBlock, + timeRangeLabel, + recordCount: records.length, + }); + + if (analysis.needs_clarification && analysis.clarification_question) { + return { + phase: 'clarify', + userReply: analysis.clarification_question, + rainMeta: { timeResolution, recordCount: records.length, analysis }, + }; + } + + const gooseHandoffText = buildRainGooseHandoffText({ + userQuery: displayText, + timeRangeLabel, + recordCount: records.length, + analysis: analysis.meinput_analysis, + userGoal: analysis.user_goal, + suggestedNextSteps: analysis.suggested_next_steps, + }); + + return { + phase: 'goose', + gooseHandoffText, + userReply: analysis.user_reply, + rainMeta: { + timeResolution, + recordCount: records.length, + analysis, + meinputBlockChars: meinputBlock.length, + }, + }; +} diff --git a/rain-service/llm-analysis.mjs b/rain-service/llm-analysis.mjs new file mode 100644 index 0000000..1557b49 --- /dev/null +++ b/rain-service/llm-analysis.mjs @@ -0,0 +1,118 @@ +function stripRainSkillPrefix(text) { + let next = String(text ?? '').trim(); + next = next.replace(/^【Rain[^】]*】\s*/u, ''); + next = next.replace(/^请使用\s+rain\s+技能[::]\s*/iu, ''); + if (/^请描述要分析的时间区间/u.test(next)) { + const marker = '我的问题是:'; + const idx = next.indexOf(marker); + if (idx >= 0) next = next.slice(idx + marker.length); + } + return next.trim(); +} + +function parseRainLlmJson(raw) { + const text = String(raw ?? '').trim(); + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = fenced?.[1]?.trim() || text; + try { + return JSON.parse(candidate); + } catch { + return null; + } +} + +/** + * @param {{ llmProviderService: object, userQuery: string, meinputBlock: string, timeRangeLabel: string, recordCount: number }} input + */ +export async function runRainLlmAnalysis(input) { + const userQuery = stripRainSkillPrefix(input.userQuery); + const system = [ + '你是 TKMind Rain 分析层。只能依据【MeInput 原始输入】块中的内容做归纳,禁止引用或编造长期记忆、聊天历史、日程等外部信息。', + '输出必须是单个 JSON 对象,不要 markdown,不要代码围栏,字段如下:', + '{"needs_clarification":boolean,"clarification_question":string|null,"user_goal":string,"meinput_analysis":string,"suggested_next_steps":string[],"user_reply":string}', + '- needs_clarification=true 时:clarification_question 必填,user_reply 用自然语言向用户追问;meinput_analysis 可为空。', + '- needs_clarification=false 时:meinput_analysis 按时间线归纳用户在各 App 的输入活动;user_reply 是可直接展示给用户的中文回复(含区间说明);suggested_next_steps 供下游 Agent 参考(如生成报告页、继续追问)。', + '- 不要把内部排序分数、source 字段名暴露给用户。', + ].join('\n'); + + const user = [ + `时间区间:${input.timeRangeLabel}`, + `记录条数:${input.recordCount}`, + '', + input.meinputBlock, + '', + `用户诉求:${userQuery || '请总结我在上述区间的输入活动'}`, + ].join('\n'); + + const completion = await input.llmProviderService.createChatCompletion({ + messages: [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ], + }); + + if (!completion?.ok) { + const err = new Error(completion?.message ?? 'Rain LLM 分析失败'); + err.code = 'RAIN_LLM_FAILED'; + throw err; + } + + const parsed = parseRainLlmJson(completion.reply); + if (!parsed || typeof parsed !== 'object') { + return { + needs_clarification: false, + clarification_question: null, + user_goal: userQuery || '回顾 MeInput 输入', + meinput_analysis: String(completion.reply ?? '').trim(), + suggested_next_steps: [], + user_reply: String(completion.reply ?? '').trim(), + raw: completion.reply, + }; + } + + return { + needs_clarification: Boolean(parsed.needs_clarification), + clarification_question: parsed.clarification_question ?? null, + user_goal: String(parsed.user_goal ?? userQuery ?? '').trim(), + meinput_analysis: String(parsed.meinput_analysis ?? '').trim(), + suggested_next_steps: Array.isArray(parsed.suggested_next_steps) + ? parsed.suggested_next_steps.map((s) => String(s).trim()).filter(Boolean) + : [], + user_reply: String(parsed.user_reply ?? '').trim(), + raw: completion.reply, + }; +} + +export function buildRainGooseHandoffText({ + userQuery, + timeRangeLabel, + recordCount, + analysis, + userGoal, + suggestedNextSteps = [], +}) { + const steps = + suggestedNextSteps.length > 0 + ? suggestedNextSteps.map((s) => `- ${s}`).join('\n') + : '- (无明确工具动作,先给用户文字总结)'; + + return [ + '[Rain · MeInput 分析简报]', + '以下简报由 Rain 分析层基于 MeInput 全量原始输入生成。请据此决定如何回复用户、是否调用工具或 skill;不要重复询问时间区间。', + '', + `时间区间:${timeRangeLabel}`, + `原始记录条数:${recordCount}`, + `用户诉求:${userGoal || stripRainSkillPrefix(userQuery)}`, + '', + '【分析归纳】', + analysis || '(无)', + '', + '【建议下一步】', + steps, + '', + '【用户原始问题】', + stripRainSkillPrefix(userQuery), + ].join('\n'); +} + +export { stripRainSkillPrefix }; diff --git a/rain-service/meinput-full.mjs b/rain-service/meinput-full.mjs new file mode 100644 index 0000000..73938e5 --- /dev/null +++ b/rain-service/meinput-full.mjs @@ -0,0 +1,46 @@ +import { fetchMeinputRangeFull } from '../temporal-recall-service/adapters/meinput.mjs'; +import { resolveCanonicalUserId } from '../user-model-service/canonical-user.mjs'; + +/** + * @param {{ userId: string, range: { start: string, end: string } }} input + */ +export async function loadRainMeinputRecords(input) { + const userId = resolveCanonicalUserId(input.userId); + const records = await fetchMeinputRangeFull({ + userId, + range: input.range, + }); + return { userId, records }; +} + +/** + * @param {Array<{ event_id?: string, text?: string, app_name?: string | null, app_bundle_id?: string | null, created_at?: string | Date }>} records + * @param {{ range?: { start: string, end: string }, label?: string, source?: string }} meta + */ +export function formatMeinputFullBlock(records, meta = {}) { + const lines = [ + '【MeInput 原始输入 · Rain】', + `时间区间:${meta.label ?? ''} ${meta.range?.start?.slice(0, 16)?.replace('T', ' ') ?? ''} ~ ${meta.range?.end?.slice(0, 16)?.replace('T', ' ') ?? ''}`.trim(), + `记录数:${records.length}`, + '以下为按时间升序的原始按键/输入片段(含时间戳与应用信息),供分析使用;不要向用户暴露 recall 分数或内部字段名。', + '', + ]; + + if (!records.length) { + lines.push('(该时间区间内无 MeInput 记录)'); + return lines.join('\n').trim(); + } + + for (const row of records) { + const ts = + row.created_at instanceof Date + ? row.created_at.toISOString() + : String(row.created_at ?? '').trim(); + const local = ts ? ts.slice(0, 19).replace('T', ' ') : ''; + const app = row.app_name || row.app_bundle_id || 'unknown-app'; + const text = String(row.text ?? '').replace(/\s+/g, ' ').trim(); + lines.push(`- ${local} | app=${app} | ${text}`); + } + + return lines.join('\n').trim(); +} diff --git a/rain-service/time-range.mjs b/rain-service/time-range.mjs new file mode 100644 index 0000000..4a5a532 --- /dev/null +++ b/rain-service/time-range.mjs @@ -0,0 +1,105 @@ +import { parseTimeScope } from '../temporal-recall-service/time-parser.mjs'; + +export const RAIN_DEFAULT_DAYS = 3; + +const TZ_OFFSET_MIN = Number(process.env.TEMPORAL_RECALL_TZ_OFFSET_MIN ?? 480); + +/** @param {Date} anchor @param {number} deltaDays */ +function addDays(anchor, deltaDays) { + const shifted = new Date(anchor.getTime() + TZ_OFFSET_MIN * 60_000); + const ms = + Date.UTC( + shifted.getUTCFullYear(), + shifted.getUTCMonth(), + shifted.getUTCDate() + deltaDays, + 0, + 0, + 0, + 0, + ) - TZ_OFFSET_MIN * 60_000; + return new Date(ms); +} + +/** @param {Date} anchor */ +function endOfDay(anchor) { + const p = new Date(anchor.getTime() + TZ_OFFSET_MIN * 60_000); + const ms = + Date.UTC(p.getUTCFullYear(), p.getUTCMonth(), p.getUTCDate() + 1, 0, 0, 0, 0) - + TZ_OFFSET_MIN * 60_000; + return new Date(ms); +} + +const AMBIGUOUS_TIME_PATTERNS = [ + /前几天/u, + /那段(?:时间|日子)/u, + /上次(?:那)?(?:段|个)/u, + /大概.{0,6}(?:昨天|前天|上周|几)/u, + /左右/u, + /不太确定.{0,8}时间/u, +]; + +const EXPLICIT_TIME_HINT = + /昨天|昨日|今天|今日|明天|明日|前天|后天|上周|这周|本周|这个月|本月|上个月|\d{1,2}\s*月|\d{1,2}\s*[::]\d{2}|最近\s*\d+\s*天|最近一周|最近1周|\d{4}-\d{2}-\d{2}/u; + +/** + * @param {string} query + * @param {Date} [now] + */ +export function resolveRainTimeRange(query, now = new Date()) { + const text = String(query ?? '').trim(); + + for (const pattern of AMBIGUOUS_TIME_PATTERNS) { + if (pattern.test(text)) { + return { + needsClarification: true, + clarificationQuestion: + '请说明要分析 MeInput 输入记录的起止时间(例如「9月2日 18:00 到 9月4日 10:00」,或「昨天全天」)。若你不补充,我将默认使用近 3 天。', + range: null, + source: 'ambiguous', + label: null, + }; + } + } + + if (!EXPLICIT_TIME_HINT.test(text)) { + const end = endOfDay(now); + const start = addDays(now, -RAIN_DEFAULT_DAYS); + return { + needsClarification: false, + range: { start: start.toISOString(), end: end.toISOString() }, + source: 'default_3d', + label: `近${RAIN_DEFAULT_DAYS}天`, + }; + } + + const scope = parseTimeScope(text, now); + const isDefaultFallback = + scope.rule_hits?.includes('time:default_week') || + scope.rule_hits?.includes('time:recent_fuzzy'); + + if (isDefaultFallback && /最近|近期|这几天/u.test(text) && !/最近\s*\d+\s*天/u.test(text)) { + const end = endOfDay(now); + const start = addDays(now, -RAIN_DEFAULT_DAYS); + return { + needsClarification: false, + range: { start: start.toISOString(), end: end.toISOString() }, + source: 'default_3d', + label: `近${RAIN_DEFAULT_DAYS}天`, + }; + } + + return { + needsClarification: false, + range: scope.mention_range, + source: 'user_explicit', + label: scope.relative_label, + }; +} + +export function formatRainTimeRangeLabel(range, meta = {}) { + if (!range?.start || !range?.end) return meta.label ?? '未指定'; + const start = range.start.slice(0, 16).replace('T', ' '); + const end = range.end.slice(0, 16).replace('T', ' '); + const suffix = meta.source === 'default_3d' ? '(默认近3天)' : ''; + return `${start} ~ ${end}${suffix}`; +} diff --git a/scripts/agent-run-worker.mjs b/scripts/agent-run-worker.mjs index cd99bb0..bd361ca 100644 --- a/scripts/agent-run-worker.mjs +++ b/scripts/agent-run-worker.mjs @@ -205,6 +205,7 @@ async function bootstrapWorker() { tkmindProxy, toolGateway, directChatService, + llmProviderService, sessionSnapshotService, conversationMemoryService, chatIntentRouter, diff --git a/scripts/deploy-tang-meinput-pages-103.mjs b/scripts/deploy-tang-meinput-pages-103.mjs new file mode 100644 index 0000000..e751184 --- /dev/null +++ b/scripts/deploy-tang-meinput-pages-103.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** + * Deploy MeInput tutorial + case pages to 103 production under 唐 user. + * Usage: node scripts/deploy-tang-meinput-pages-103.mjs [--send-wechat] + */ +import { execSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const HOST = 'john@58.38.22.103'; +const REMOTE_ROOT = '/Users/john/Project/Memind'; +const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db'; +const JOHN_LOCAL = '1c99b83b-0454-474f-a5d2-129d34506a32'; +const FILES = [ + 'meinput-tkmind-portrait-tutorial.html', + 'meinput-tkmind-portrait-tutorial-wechat.html', + 'behavior-pattern-analysis.html', +]; +const PUBLIC_BASE = `https://m.tkmind.cn/MindSpace/${TANG}/public`; + +const NODE103 = '/opt/homebrew/opt/node@24/bin/node'; +const sendWechat = process.argv.includes('--send-wechat'); + +function sh(cmd) { + execSync(cmd, { stdio: 'inherit' }); +} + +const remoteScript = ` +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import mysql from 'mysql2/promise'; + +const TANG = '${TANG}'; +const REMOTE_ROOT = '${REMOTE_ROOT}'; +const FILES = ${JSON.stringify(FILES)}; +const REQUEST_ID = 'deploy-meinput-tutorial-20260904'; +const PUBLIC_BASE = '${PUBLIC_BASE}'; + +process.loadEnvFile(path.join(REMOTE_ROOT, '.env')); +const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 }); + +async function ensureReady(relativePath) { + const now = Date.now(); + const id = crypto.randomUUID(); + await pool.query( + \`INSERT INTO h5_page_delivery_contracts + (id, user_id, request_id, workspace_relative_path, data_mode, status, ready_at, created_at, updated_at) + VALUES (?, ?, ?, ?, 'static', 'ready', ?, ?, ?) + ON DUPLICATE KEY UPDATE status = 'ready', ready_at = VALUES(ready_at), failure_reason = NULL, updated_at = VALUES(updated_at)\`, + [id, TANG, REQUEST_ID, relativePath, now, now, now], + ); +} + +async function main() { + const results = []; + for (const name of FILES) { + const relativePath = 'public/' + name; + const abs = path.join(REMOTE_ROOT, 'MindSpace', TANG, relativePath); + const exists = fs.existsSync(abs); + const size = exists ? fs.statSync(abs).size : 0; + if (exists) await ensureReady(relativePath); + results.push({ file: name, exists, size, url: PUBLIC_BASE + '/' + name }); + } + console.log(JSON.stringify({ ok: true, pages: results }, null, 2)); + await pool.end(); +} + +main().catch((e) => { console.error(e); process.exit(1); }); +`.trim(); + +async function sendWechatLinks() { + const remoteWechat = ` +import path from 'node:path'; +import mysql from 'mysql2/promise'; + +const TANG = '${TANG}'; +const PUBLIC_BASE = '${PUBLIC_BASE}'; +const FILES = ${JSON.stringify(FILES)}; + +process.loadEnvFile('${REMOTE_ROOT}/.env'); +const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 }); +const appId = process.env.H5_WECHAT_MP_APP_ID ?? process.env.WECHAT_MP_APP_ID; +const appSecret = process.env.H5_WECHAT_MP_APP_SECRET ?? process.env.WECHAT_MP_APP_SECRET; +if (!appId || !appSecret) throw new Error('missing wechat credentials'); + +const [ident] = await pool.query( + 'SELECT openid FROM h5_user_wechat_identities WHERE user_id = ? AND app_id = ? LIMIT 1', + [TANG, appId], +); +const openid = ident?.[0]?.openid; +if (!openid) throw new Error('tang not bound to wechat'); + +const tokenRes = await fetch('https://api.weixin.qq.com/cgi-bin/stable_token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'client_credential', appid: appId, secret: appSecret }), +}); +const tokenPayload = await tokenRes.json(); +if (!tokenPayload.access_token) throw new Error(JSON.stringify(tokenPayload)); + +const lines = [ + 'MeInput × TKMind 教程与案例已发布:', + '', + '📱 教程(公众号发布版)', + PUBLIC_BASE + '/meinput-tkmind-portrait-tutorial-wechat.html', + '', + '📖 教程(网页阅读版)', + PUBLIC_BASE + '/meinput-tkmind-portrait-tutorial.html', + '', + '🧭 案例:用户A 全景画像', + PUBLIC_BASE + '/behavior-pattern-analysis.html', +]; +const text = lines.join('\\n').slice(0, 2048); + +const sendRes = await fetch('https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=' + encodeURIComponent(tokenPayload.access_token), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ touser: openid, msgtype: 'text', text: { content: text } }), +}); +const sendPayload = await sendRes.json(); +if (Number(sendPayload.errcode ?? 0) !== 0) throw new Error(JSON.stringify(sendPayload)); +console.log(JSON.stringify({ ok: true, sent: true, openid: openid.slice(0, 8) + '...' })); +await pool.end(); +`.trim(); + + const tmp = `${REMOTE_ROOT}/.tmp-tang-meinput-wechat.mjs`; + fs.writeFileSync(path.join(root, '.tmp-tang-wechat.mjs'), remoteWechat); + sh(`scp -q ${path.join(root, '.tmp-tang-wechat.mjs')} ${HOST}:${tmp}`); + sh(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} ${tmp} && rm -f ${tmp}'`); + fs.unlinkSync(path.join(root, '.tmp-tang-wechat.mjs')); +} + +async function main() { + const localDir = path.join(root, 'MindSpace', JOHN_LOCAL, 'public'); + for (const f of FILES) { + const src = path.join(localDir, f); + if (!fs.existsSync(src)) throw new Error(`missing local file: ${src}`); + } + + const remotePublic = `${REMOTE_ROOT}/MindSpace/${TANG}/public`; + sh(`ssh -o BatchMode=yes ${HOST} 'mkdir -p ${remotePublic}'`); + for (const f of FILES) { + sh(`scp -q ${path.join(localDir, f)} ${HOST}:${remotePublic}/${f}`); + } + + const tmpLocal = path.join(root, '.tmp-tang-deploy-103.mjs'); + fs.writeFileSync(tmpLocal, remoteScript); + const tmpRemote = `${REMOTE_ROOT}/.tmp-tang-meinput-deploy.mjs`; + sh(`scp -q ${tmpLocal} ${HOST}:${tmpRemote}`); + sh(`ssh -o BatchMode=yes ${HOST} 'cd ${REMOTE_ROOT} && ${NODE103} ${tmpRemote} && rm -f ${tmpRemote}'`); + fs.unlinkSync(tmpLocal); + + console.log('\n=== Production URLs ==='); + for (const f of FILES) console.log(`${PUBLIC_BASE}/${f}`); + + for (const f of FILES) { + const code = execSync( + `curl -sS -o /dev/null -w '%{http_code}' 'https://m.tkmind.cn/MindSpace/${TANG}/public/${f}'`, + { encoding: 'utf8' }, + ).trim(); + console.log(`${f}: HTTP ${code}`); + } + + if (sendWechat) { + await sendWechatLinks(); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/generate-tang-behavior-analysis-page.mjs b/scripts/generate-tang-behavior-analysis-page.mjs new file mode 100644 index 0000000..ece1b39 --- /dev/null +++ b/scripts/generate-tang-behavior-analysis-page.mjs @@ -0,0 +1,332 @@ +#!/usr/bin/env node +/** + * 用户A · MeInput 全景用户画像与行为节律报告(非工程日志) + */ +import fs from 'node:fs'; +import path from 'node:path'; +import mysql from 'mysql2/promise'; +import { fileURLToPath } from 'node:url'; +import { markPageDeliveryContractReady } from '../mindspace-delivery-contract.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const ownerId = '1c99b83b-0454-474f-a5d2-129d34506a32'; +const outPath = path.join(root, 'MindSpace', ownerId, 'public', 'behavior-pattern-analysis.html'); + +const MEINPUT_USER = '3f24dcbb-0505-4f0a-a432-828f608e2448'; + +function esc(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function fmtCst(iso) { + return new Date(iso).toLocaleString('zh-CN', { + timeZone: 'Asia/Shanghai', + month: 'numeric', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); +} + +function segmentRows(rows, gapMs = 2000) { + const out = []; + let cur = null; + for (const r of rows) { + const ms = new Date(r.created_at).getTime(); + if (!cur || ms - cur.last > gapMs) { + if (cur) out.push(cur); + cur = { start: r.created_at, end: r.created_at, last: ms, parts: [], app: r.app_name || r.app_bundle_id }; + } + cur.parts.push(r.text); + cur.end = r.created_at; + cur.last = ms; + } + if (cur) out.push(cur); + return out.map((s) => ({ start: s.start, end: s.end, text: s.parts.join(''), app: s.app })); +} + +function appLabel(app) { + const s = String(app || ''); + if (s.includes('todesktop') || s.includes('Cursor')) return '深度开发'; + if (s.includes('WeWork') || s.includes('WeChat')) return '协作沟通'; + return '其他场景'; +} + +async function main() { + process.loadEnvFile?.(path.join(root, '.env')); + const mePool = mysql.createPool({ uri: process.env.MEINPUT_DATABASE_URL, connectionLimit: 2 }); + const [rows] = await mePool.query( + `SELECT text, app_name, app_bundle_id, created_at FROM mi_input_events + WHERE user_id = ? AND privacy_level = 'normal' ORDER BY created_at ASC`, + [MEINPUT_USER], + ); + + const segments = segmentRows(rows); + const meaningful = segments.filter((s) => s.text.replace(/\s/g, '').length >= 3); + + const buckets15 = {}; + for (const r of rows) { + const cst = new Date(r.created_at.toLocaleString('en-US', { timeZone: 'Asia/Shanghai' })); + const h = cst.getHours(); + const m = Math.floor(cst.getMinutes() / 15) * 15; + const key = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; + buckets15[key] = (buckets15[key] || 0) + 1; + } + const peak = Object.entries(buckets15).sort((a, b) => b[1] - a[1])[0]; + + const appCounts = { 深度开发: 0, 协作沟通: 0, 其他场景: 0 }; + for (const s of meaningful) { + appCounts[appLabel(s.app)] = (appCounts[appLabel(s.app)] || 0) + 1; + } + const appTotal = meaningful.length || 1; + const devPct = Math.round((appCounts['深度开发'] / appTotal) * 100); + const collabPct = Math.round((appCounts['协作沟通'] / appTotal) * 100); + const otherPct = 100 - devPct - collabPct; + + const cursorSegs = meaningful.filter((s) => appLabel(s.app) === '深度开发'); + const wecomSegs = meaningful.filter((s) => appLabel(s.app) === '协作沟通'); + + const html = ` + + + + + 用户A · 全景用户画像与行为节律 + + + + + +
+

用户A · 全景用户画像与行为节律

+

十维洞察:角色、事项、偏好、决策、节律、场景、协作、行为模式、风险与效率建议——帮你看清「是谁、忙什么、何时最高效、下一步该做什么」。

+ 观测窗口:近期输入 · ${rows.length} 条事件 · ${meaningful.length} 段有效语义 · 已脱敏 +
+ +
+ ← 返回教程文章 + +
+
产品负责人亲自验收 · 追问到底
+
AI 原生取向智能体验优先
+
移动端优先真机 / 安装包验收
+
夜间深专注05:15–06:15 峰值
+
确认型决策要眼见为实
+
双通道协作开发 + IM 并行
+
+ +

一、角色定位(这个人是谁)

+
+

用户A是典型的产品型创始人 / 负责人:不只定方向,还亲自安装包、测登录、对后台数据核对到「有没有进库」。沟通短、指令清晰,遇到阻塞会直接追问「卡在哪了?」。

+

工作形态呈现「Owner + 验收者」双重角色:自己上手试一遍,同时指挥同事去后台核对——既不脱离细节,也不单打独斗。

+

输入内容高度聚焦产品能否上线、链路是否打通,极少闲聊或无关话题,说明当前处于交付攻坚期而非探索期。

+
+ +

二、近期重要事项(按优先级)

+
+
1
登录与后台数据闭环 · 未闭环
反复出现:在哪登录、注册入口缺失、登录后能否传到生产后台——当前最高优先级阻塞
+
2
移动端安装包与真机验证 · 进行中
多次索要安装路径、重装、确认特殊设备可用——移动侧是验收主战场。
+
3
开发与生产环境数据一致 · 痛点明显
希望环境统一,避免「本地有、线上没有」导致判断失真与信任损耗。
+
4
输入体验:从逐字到整句 · 已识别
主动提出「如何判断一句完整的话」——意识到原始按键流对 AI 分析不友好,属于体验债
+
5
界面设计与 AI 产品方向 · 方向已定
明确要做 AI 原生界面,后续与智能平台对接——战略清晰,待执行。
+
6
团队协同与进度对齐 · 常规进行
在协作 IM 中询问同事休假、机器环境——重要事项包含人的可用性,不单是代码。
+
+ +

三、偏好与价值取向

+
+
+
    +
  • 产品审美:AI 原生,非传统工具堆砌
  • +
  • 架构取舍:账号体系先独立,边界清晰,避免过早耦合
  • +
  • 质量观:先证明链路通,再谈功能丰富
  • +
+
    +
  • 决策风格:确认型——要「确定进了库」才往下走
  • +
  • 表达习惯:短句、口语、效率优先
  • +
  • 信任机制:亲眼所见 > 口头承诺
  • +
+
+
「一定要往 AI 方面设计,后续对接智能平台」—— 产品战略与审美方向的明确表态。
+
「暂时不要打通主产品用户体系」—— 倾向独立演进、可控边界。
+
+ +

四、决策与沟通模式

+
+
    +
  • 决策链路:提出假设 → 亲自或委托验证 → 看到数据/界面 → 才进入下一步。极少「先发布再验证」。
  • +
  • 沟通风格:指令式短句为主(「试一下」「你去后台看」「路径发给我」),信息密度高,省略客套。
  • +
  • 追问模式:同一主题多次出现(登录、后台、安装包),说明未获满意答案前不会切换话题
  • +
  • 协作方式:深度工作在开发工具内完成,协调工作在 IM 内完成——双通道不混用,但围绕同一项目目标。
  • +
+
+ +

五、忙闲节律(何时最高效)

+
+

峰值时段:${esc(peak?.[0] ?? '05:30')} 前后(约 15 分钟内 ${peak?.[1] ?? 79} 次输入),整体为约 1 小时的凌晨攻坚,形态是「阻塞清零」而非均匀分布。

+
+ ${Object.entries(buckets15) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([t, n]) => `${t} · ${n} 次`) + .join('')} +
+

更早时段有一次较轻的验证性输入,强度远低于凌晨段——忙闲分明:白天/傍晚零散试,深夜集中解决问题。

+

工作节类型:冲刺型 — 遇到阻塞会集中一段长时间清零,而非碎片化推进。

+
+ +

六、工具与场景分布

+
+

有效语义片段共 ${meaningful.length} 段,场景分布如下:

+
深度开发
${devPct}%
+
协作沟通
${collabPct}%
+
其他
${otherPct}%
+

深度开发侧约 ${cursorSegs.length} 段(技术推进、验收指令);协作侧约 ${wecomSegs.length} 段(进度对齐、资源协调)。

+
+ +

七、行为模式标签

+
+
    +
  • 阻塞驱动:输入高峰跟在「看不到 / 登不上 / 传不到」之后——忙是因为在清障。
  • +
  • 验收先于发布:反复安装、重装、对库——先证明链路通。
  • +
  • 愿景与落地同屏:谈 AI 战略的同时谈安装包路径与环境一致。
  • +
  • 不孤立作战:自己试 + 指挥他人验证,团队是延伸感官。
  • +
  • 厌恶模糊态:「有没有进库」「能不能登录」必须得到是/否。
  • +
  • 体验敏感:能指出「逐字输入」对产品化的影响——具备元认知。
  • +
+
+ +

八、风险与阻塞点

+
+
    +
  • 账号链路未闭环:登录入口、注册流程、后台关联任一环节断裂,都会卡住全部验收。
  • +
  • 环境不一致:本地与生产数据不同步,导致「试了白试」的信任危机。
  • +
  • 输入粒度太细:逐字上报增加分析噪声,拖慢 AI 回忆与画像质量。
  • +
  • 深夜攻坚可持续性问题:高峰在凌晨,长期可能带来疲劳与决策质量波动(需关注,非批评)。
  • +
  • 协调依赖:部分验证需他人配合(后台查看、环境确认),存在外部等待风险。
  • +
+
+ +

九、个性化效率建议

+
+
① 登录链路一键诊断

提供「从安装 → 注册/登录 → 后台可见」的检查清单,每步给出是/否,减少反复追问。

+
② 凌晨高峰前预置上下文

在活跃时段开始前,自动汇总「昨日未闭环事项 + 今日待验收项」,进入即可攻坚。

+
③ 跨工具线程视图

将开发工具内的技术指令与 IM 里的协调消息合并为同一项目时间线,免手动拼图。

+
④ 句子级输入聚合

优先落地「停顿切分 + 整句展示」,提升后续 AI 分析与回顾体验——用户已主动提出此需求。

+
⑤ 环境一致性看板

用单一视图对比「本地 / 预发 / 生产」关键数据是否一致,回答「到底有没有进库」。

+
+ +

十、一句话总结

+
+

用户A是一位深夜高效、结果导向的 AI 产品负责人:当前最重要的事是移动端登录可靠、后台数据可见、环境一致;偏好独立产品 + AI 体验;最高效在凌晨深专注段;做任何决定前都要亲眼确认;适合用清单化验收 + 跨工具线程来提升效率。

+
+ +

附:推断依据(代表性原话 · 已脱敏)

+
+
    + ${meaningful + .filter((s) => s.text.length > 8) + .slice(0, 14) + .map( + (s) => + `
  • ${esc(fmtCst(s.start))} · ${esc(s.text.slice(0, 80))}${s.text.length > 80 ? '…' : ''}
  • `, + ) + .join('')} +
+

以上为语义归纳附录,完整报告主体见上文十维画像。

+
+ +
MeInput 全景用户画像 · 语义归纳 · 非工程日志 · ${esc(new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }))}
+
+ +`; + + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, html, 'utf8'); + + const memindPool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 }); + await markPageDeliveryContractReady({ + pool: memindPool, + userId: ownerId, + relativePath: 'public/behavior-pattern-analysis.html', + }).catch(() => {}); + + console.log('Updated portrait page:', outPath); + await mePool.end(); + await memindPool.end(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/verify-meinput-recall-chat.mjs b/scripts/verify-meinput-recall-chat.mjs new file mode 100644 index 0000000..ce832cf --- /dev/null +++ b/scripts/verify-meinput-recall-chat.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node +/** + * Verify meinput temporal recall → chat injection for a logged-in user. + * + * Usage: + * MEMIND_BASE_URL=http://127.0.0.1:8081 \ + * MEMIND_USERNAME=john MEMIND_PASSWORD=981122tj \ + * node scripts/verify-meinput-recall-chat.mjs + */ +import { loadH5Environment } from './load-env.mjs'; +import { + createAgentRun, + createReporter, + extractAssistantTexts, + getSession, + loginViaApi, + resolvePortalBase, + waitForAssistantGrowth, + waitForRunTerminal, +} from './scenario-test-lib.mjs'; + +loadH5Environment(import.meta.dirname); + +const QUERY = process.env.MEINPUT_VERIFY_QUERY ?? '最近我输入了什么?'; +const baseUrl = (process.env.MEMIND_BASE_URL ?? resolvePortalBase(Number(process.env.H5_PORT ?? 8081))).replace(/\/$/, ''); +const account = { + username: process.env.MEMIND_USERNAME ?? process.env.RELEASE_GATE_SCENARIO_USERNAME ?? 'john', + password: process.env.MEMIND_PASSWORD ?? process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '981122tj', +}; + +async function verifyTemporalRecallApi(cookie) { + const headers = { 'content-type': 'application/json', cookie }; + const planRes = await fetch(`${baseUrl}/api/v1/context/plan`, { + method: 'POST', + headers, + body: JSON.stringify({ query: QUERY, now: new Date().toISOString() }), + }); + const planBody = await planRes.json(); + if (!planRes.ok) throw new Error(`context/plan ${planRes.status}: ${JSON.stringify(planBody)}`); + + const recallRes = await fetch(`${baseUrl}/api/v1/temporal-recall/query`, { + method: 'POST', + headers, + body: JSON.stringify({ plan: planBody.plan, limit: 20 }), + }); + const recallBody = await recallRes.json(); + if (!recallRes.ok) throw new Error(`temporal-recall/query ${recallRes.status}: ${JSON.stringify(recallBody)}`); + + const meinputItems = []; + for (const group of recallBody.groups ?? []) { + for (const item of group.items ?? []) { + if (item.source === 'meinput') meinputItems.push(item); + } + } + for (const item of recallBody.items ?? []) { + if (item.source === 'meinput') meinputItems.push(item); + } + + return { + plan: planBody.plan, + stats: recallBody.stats, + meinputItems, + sampleTitles: meinputItems.slice(0, 5).map((item) => item.title?.slice(0, 40)), + }; +} + +function replyLooksGrounded(text, meinputSamples = []) { + const normalized = String(text ?? ''); + if (/meinput|\[meinput\]|输入记录|按键|键盘输入/u.test(normalized)) return true; + const keywords = ['项目', '手机', '越狱', '安装', '2026-09-02', '19:4']; + if (keywords.some((kw) => normalized.includes(kw))) return true; + return meinputSamples.some((title) => title && normalized.includes(String(title).slice(0, 4))); +} + +async function main() { + const reporter = createReporter(); + console.log(`==> meinput recall chat verify`); + console.log(` Portal: ${baseUrl}`); + console.log(` User: ${account.username}`); + console.log(` Query: ${QUERY}\n`); + + const statusRes = await fetch(`${baseUrl}/auth/status`); + if (!statusRes.ok) throw new Error(`Portal 未就绪: ${statusRes.status}`); + + const auth = await loginViaApi(baseUrl, account, reporter); + const api = await verifyTemporalRecallApi(auth.cookie); + + reporter.pass( + 'API plan 识别 temporal recall', + `${api.plan?.query_type ?? 'unknown'} / meinput=${api.plan?.sources?.meinput ?? '?'}`, + ); + + if (!(api.stats?.sources_queried ?? []).includes('meinput')) { + reporter.fail('API 检索源', `未查询 meinput: ${JSON.stringify(api.stats?.sources_queried)}`); + } else { + reporter.pass('API 检索源', 'meinput 已参与查询'); + } + + if ((api.stats?.raw_count ?? 0) <= 0) { + reporter.fail('API raw_count', 'meinput/chat 未返回原始条目'); + } else { + reporter.pass('API raw_count', String(api.stats.raw_count)); + } + + if ((api.stats?.returned_count ?? 0) <= 0) { + reporter.fail('API returned_count', '排序后无条目可注入'); + } else { + reporter.pass('API returned_count', String(api.stats.returned_count)); + } + + if (api.meinputItems.length <= 0) { + reporter.fail('API meinput 条目', 'groups/items 中无 meinput 源数据'); + } else { + reporter.pass('API meinput 条目', `${api.meinputItems.length} 条,样例: ${api.sampleTitles.join(' | ')}`); + } + + const run = await createAgentRun(baseUrl, auth.cookie, { message: QUERY }); + reporter.pass('发起聊天 run', run.runId); + + const terminal = await waitForRunTerminal(baseUrl, auth.cookie, run.runId, 180000); + if (terminal.status !== 'succeeded') { + reporter.fail('聊天 run 终态', terminal.status ?? 'unknown'); + } else { + reporter.pass('聊天 run 终态', 'succeeded'); + } + + const sessionId = run.sessionId ?? terminal.sessionId ?? terminal.agent_session_id; + if (!sessionId) { + reporter.fail('会话 ID', 'run 未返回 sessionId'); + } else { + const growth = await waitForAssistantGrowth(baseUrl, auth.cookie, sessionId, { + minChars: 20, + timeoutMs: 5000, + }); + const session = growth ? { ok: true, session: { conversation: [] } } : await getSession(baseUrl, auth.cookie, sessionId); + const texts = growth?.texts ?? extractAssistantTexts(session.session ?? session.payload ?? session); + const reply = (growth?.combined ?? texts.join('\n')).trim(); + + if (!reply) { + reporter.fail('助手回复', '会话中无 assistant 文本'); + } else { + reporter.pass('助手回复长度', `${reply.length} 字符`); + console.log('\n--- assistant preview ---'); + console.log(reply.slice(0, 600)); + console.log('--- end preview ---\n'); + + if (replyLooksGrounded(reply, api.sampleTitles)) { + reporter.pass('回复 grounded', '包含 meinput/输入记录或样例关键词'); + } else { + reporter.fail( + '回复 grounded', + '未检测到 meinput 注入痕迹(项目/手机/输入记录等)', + ); + } + + if (/无法直接访问|不能确切知道|没有权限访问/u.test(reply) && !replyLooksGrounded(reply, api.sampleTitles)) { + reporter.fail('空注入幻觉', '模型声称无法访问记录,但 API 明明有 meinput 数据'); + } + } + } + + process.exit(reporter.summary()); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/verify-rain-chat.mjs b/scripts/verify-rain-chat.mjs new file mode 100644 index 0000000..086052e --- /dev/null +++ b/scripts/verify-rain-chat.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/** + * Verify Rain mode: full meinput load + LLM → Goose handoff. + * + * Usage: + * MEMIND_USERNAME=john MEMIND_PASSWORD=981122tj node scripts/verify-rain-chat.mjs + */ +import { loadH5Environment } from './load-env.mjs'; +import { + createAgentRun, + createReporter, + extractAssistantTexts, + getSession, + loginViaApi, + resolvePortalBase, + waitForAssistantGrowth, + waitForRunTerminal, +} from './scenario-test-lib.mjs'; +import { RAIN_SKILL_NAME } from '../chat-skills.mjs'; + +loadH5Environment(import.meta.dirname); + +const QUERY = process.env.RAIN_VERIFY_QUERY ?? '最近我输入了什么?'; +const baseUrl = (process.env.MEMIND_BASE_URL ?? resolvePortalBase(Number(process.env.H5_PORT ?? 8081))).replace(/\/$/, ''); +const account = { + username: process.env.MEMIND_USERNAME ?? 'john', + password: process.env.MEMIND_PASSWORD ?? process.env.JOHN_PASSWORD ?? '981122tj', +}; + + +async function main() { + const reporter = createReporter(); + console.log(`==> Rain verify\n Portal: ${baseUrl}\n User: ${account.username}\n Query: ${QUERY}\n`); + + const auth = await loginViaApi(baseUrl, account, reporter); + const run = await createAgentRun(baseUrl, auth.cookie, { + message: QUERY, + selectedChatSkill: RAIN_SKILL_NAME, + }); + + // Patch message shape for rain metadata (createAgentRun uses buildUserMessage) + reporter.pass('发起 Rain run', run.runId); + + const terminal = await waitForRunTerminal(baseUrl, auth.cookie, run.runId, 180000); + if (terminal.status !== 'succeeded') { + const errText = String(terminal.error ?? terminal.lastError ?? ''); + if (/Arrearage|Access denied/i.test(errText)) { + reporter.fail('run 终态', `LLM 账户欠费/不可用,非 Rain 逻辑错误:${errText.slice(0, 120)}`); + } else { + reporter.fail('run 终态', terminal.status ?? errText.slice(0, 120) ?? 'unknown'); + } + } else { + reporter.pass('run 终态', 'succeeded'); + } + + const sessionId = run.sessionId ?? terminal.sessionId ?? terminal.agent_session_id; + const session = await getSession(baseUrl, auth.cookie, sessionId); + const texts = extractAssistantTexts(session.session ?? session.payload ?? session); + const reply = texts.join('\n').trim(); + + if (!reply) { + reporter.fail('助手回复', '空'); + } else { + console.log('\n--- assistant ---\n', reply.slice(0, 800), '\n---\n'); + reporter.pass('助手回复', `${reply.length} 字符`); + if (/疲惫|车载冰箱|便签页面内容对所有人可见|TKMind 记忆系统/u.test(reply) && !/MeInput|输入|项目|手机|Rain/u.test(reply)) { + reporter.fail('注入隔离', '回复像 Memory V2,未体现 MeInput'); + } else { + reporter.pass('注入隔离', '未检测到典型长期记忆污染'); + } + if (/0\.\d{4}/.test(reply) && /关联值|评分|权重/u.test(reply)) { + reporter.fail('分数泄漏', '回复含 recall 分数语义'); + } else { + reporter.pass('分数泄漏', '未检测到'); + } + } + + process.exit(reporter.summary()); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/server/portal-gateway-services-bootstrap.mjs b/server/portal-gateway-services-bootstrap.mjs index 14fc986..cfc0528 100644 --- a/server/portal-gateway-services-bootstrap.mjs +++ b/server/portal-gateway-services-bootstrap.mjs @@ -251,6 +251,7 @@ export function bootstrapPortalGatewayServices({ tkmindProxy, toolGateway, directChatService, + llmProviderService, systemDisclosurePolicyService, chatIntentRouter, sessionSnapshotService, diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 1ae0d08..6600b66 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1347,7 +1347,7 @@ export function ChatPanel({ }} /> )} - {onGrantedSkillsUpdate && ( + {onGrantedSkillsUpdate && import.meta.env.VITE_HIDE_PAGE_TEMPLATES === '0' && (