From d2d463b1db4d9009e2fc271c5a99cf67f042d336 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 7 Sep 2026 16:54:11 +0800 Subject: [PATCH] feat(rain): add date preset picker and reduce time clarification prompts. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default to last 3 days in UI and backend, only ask when time is truly ambiguous, and treat「做成页面」as HTML report pages without follow-up questions. Co-authored-by: Cursor --- chat-skills.mjs | 4 +- rain-service.test.mjs | 24 +++++++-- rain-service/index.mjs | 13 +++-- rain-service/llm-analysis.mjs | 6 ++- rain-service/time-range.mjs | 75 +++++++++++++++++++++++------ src/components/ChatPanel.tsx | 27 +++++++++++ src/components/ChatView.tsx | 1 + src/components/RainTimeRangeBar.tsx | 37 ++++++++++++++ src/hooks/useTKMindChat.ts | 2 + src/index.css | 53 ++++++++++++++++++++ src/utils/rainTimeRange.ts | 12 +++++ 11 files changed, 230 insertions(+), 24 deletions(-) create mode 100644 src/components/RainTimeRangeBar.tsx create mode 100644 src/utils/rainTimeRange.ts diff --git a/chat-skills.mjs b/chat-skills.mjs index faaa7cb..4c02143 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -550,8 +550,8 @@ export function buildChatSkillPrompt(promptKey, skillName) { ); case 'rain': return ( - '【Rain · MeInput 输入分析】请描述要分析的时间区间和你的诉求(例如「总结昨天我在做什么」)。' + - '未写时间则默认近3天;区间不明确时我会先追问。我的问题是:' + '【Rain · MeInput 输入分析】在下方选择时间范围(默认近3天),描述你的诉求(例如「总结输入活动」或「做成页面报告」)。' + + '我的问题是:' ); case 'summarize': return '请总结以下内容,提炼核心结论、重点信息和可执行建议(条理清晰、中文输出):'; diff --git a/rain-service.test.mjs b/rain-service.test.mjs index 78c89e0..e0a700b 100644 --- a/rain-service.test.mjs +++ b/rain-service.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { resolveRainTimeRange, RAIN_DEFAULT_DAYS } from './rain-service/time-range.mjs'; +import { resolveRainTimeRange, RAIN_DEFAULT_DAYS, resolveRainTimeRangeFromPreset } from './rain-service/time-range.mjs'; import { formatMeinputFullBlock } from './rain-service/meinput-full.mjs'; import { buildRainGooseHandoffText, stripRainSkillPrefix } from './rain-service/llm-analysis.mjs'; import { @@ -22,12 +22,28 @@ test('resolveRainTimeRange defaults to 3 days when no time hint', () => { 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')); +test('resolveRainTimeRange defaults to 3 days for fuzzy phrases like 前几天', () => { + 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'); +}); + +test('resolveRainTimeRange asks clarification only for strictly ambiguous phrases', () => { + const result = resolveRainTimeRange('不太确定那段时间我在做什么', new Date('2026-09-04T12:00:00+08:00')); assert.equal(result.needsClarification, true); assert.match(result.clarificationQuestion ?? '', /起止时间/); }); +test('resolveRainTimeRangeFromPreset resolves UI presets', () => { + const now = new Date('2026-09-06T12:00:00+08:00'); + const today = resolveRainTimeRangeFromPreset('today', now); + assert.equal(today.label, '今天'); + assert.ok(today.range?.start); + const threeDays = resolveRainTimeRangeFromPreset('3d', now); + assert.equal(threeDays.label, '近3天'); +}); + test('resolveRainTimeRange parses yesterday explicitly', () => { const now = new Date('2026-09-04T12:00:00+08:00'); const result = resolveRainTimeRange('我昨天输入了什么', now); @@ -94,6 +110,6 @@ test('filterChatSkills hides page templates by default', () => { }); test('stripRainSkillPrefix removes rain prompt header', () => { - const text = stripRainSkillPrefix('【Rain · MeInput 输入分析】请描述要分析的时间区间和你的诉求。未写时间则默认近3天;区间不明确时我会先追问。我的问题是:总结输入'); + const text = stripRainSkillPrefix('【Rain · MeInput 输入分析】在下方选择时间范围(默认近3天),描述你的诉求(例如「总结输入活动」或「做成页面报告」)。我的问题是:总结输入'); assert.equal(text, '总结输入'); }); diff --git a/rain-service/index.mjs b/rain-service/index.mjs index 129de1f..1a14123 100644 --- a/rain-service/index.mjs +++ b/rain-service/index.mjs @@ -1,10 +1,10 @@ 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'; +import { resolveRainTimeRange, formatRainTimeRangeLabel, resolveRainTimeRangeFromPreset, RAIN_TIME_PRESET_IDS } from './time-range.mjs'; export { RAIN_SKILL_NAME, isRainModeMessage }; -export { resolveRainTimeRange, formatRainTimeRangeLabel, RAIN_DEFAULT_DAYS } from './time-range.mjs'; +export { resolveRainTimeRange, formatRainTimeRangeLabel, RAIN_DEFAULT_DAYS, resolveRainTimeRangeFromPreset, RAIN_TIME_PRESET_IDS } from './time-range.mjs'; export { formatMeinputFullBlock, loadRainMeinputRecords } from './meinput-full.mjs'; export { runRainLlmAnalysis, buildRainGooseHandoffText, stripRainSkillPrefix } from './llm-analysis.mjs'; @@ -31,7 +31,14 @@ export async function executeRainPipeline(input) { : String(input.userMessage?.content ?? ''), ); - const timeResolution = resolveRainTimeRange(displayText); + const preset = String(input.userMessage?.metadata?.memindRun?.rainTimePreset ?? '').trim(); + const timeResolution = RAIN_TIME_PRESET_IDS.includes(preset) + ? { + needsClarification: false, + clarificationQuestion: null, + ...resolveRainTimeRangeFromPreset(preset), + } + : resolveRainTimeRange(displayText); if (timeResolution.needsClarification) { return { phase: 'clarify', diff --git a/rain-service/llm-analysis.mjs b/rain-service/llm-analysis.mjs index 1557b49..28b6946 100644 --- a/rain-service/llm-analysis.mjs +++ b/rain-service/llm-analysis.mjs @@ -2,7 +2,7 @@ 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)) { + if (/^请描述要分析的时间/u.test(next) || /^在下方选择时间范围/u.test(next)) { const marker = '我的问题是:'; const idx = next.indexOf(marker); if (idx >= 0) next = next.slice(idx + marker.length); @@ -32,6 +32,9 @@ export async function runRainLlmAnalysis(input) { '{"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 参考(如生成报告页、继续追问)。', + '- 时间区间已在输入中给定,禁止追问「这几天指哪几天」或让用户再次确认区间;若查询范围内实际有记录的时段更短,在 user_reply 中说明实际覆盖时段即可。', + '- 用户提到「做成页面」「生成报告」时,默认理解为 MindSpace HTML 可视化报告页;suggested_next_steps 应包含生成 HTML 报告页,禁止追问是要卡片还是页面。', + '- needs_clarification 仅用于用户诉求本身完全无法理解(与时间区间、页面形式无关)。', '- 不要把内部排序分数、source 字段名暴露给用户。', ].join('\n'); @@ -99,6 +102,7 @@ export function buildRainGooseHandoffText({ return [ '[Rain · MeInput 分析简报]', '以下简报由 Rain 分析层基于 MeInput 全量原始输入生成。请据此决定如何回复用户、是否调用工具或 skill;不要重复询问时间区间。', + '若用户诉求含「做成页面」或「生成报告」,直接生成 MindSpace HTML 可视化报告页,勿再追问形式。', '', `时间区间:${timeRangeLabel}`, `原始记录条数:${recordCount}`, diff --git a/rain-service/time-range.mjs b/rain-service/time-range.mjs index 4a5a532..e627cd7 100644 --- a/rain-service/time-range.mjs +++ b/rain-service/time-range.mjs @@ -20,6 +20,15 @@ function addDays(anchor, deltaDays) { return new Date(ms); } +/** @param {Date} anchor */ +function startOfDay(anchor) { + const p = new Date(anchor.getTime() + TZ_OFFSET_MIN * 60_000); + const ms = + Date.UTC(p.getUTCFullYear(), p.getUTCMonth(), p.getUTCDate(), 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); @@ -29,13 +38,55 @@ function endOfDay(anchor) { return new Date(ms); } -const AMBIGUOUS_TIME_PATTERNS = [ - /前几天/u, - /那段(?:时间|日子)/u, - /上次(?:那)?(?:段|个)/u, - /大概.{0,6}(?:昨天|前天|上周|几)/u, - /左右/u, +/** @param {Date} now */ +function buildDefault3dRange(now) { + return { + start: addDays(now, -RAIN_DEFAULT_DAYS).toISOString(), + end: endOfDay(now).toISOString(), + }; +} + +export const RAIN_TIME_PRESET_IDS = ['today', 'yesterday', '3d', '7d']; + +/** + * @param {string} presetId + * @param {Date} [now] + */ +export function resolveRainTimeRangeFromPreset(presetId, now = new Date()) { + const id = String(presetId ?? '3d').trim(); + if (id === 'today') { + return { + range: { start: startOfDay(now).toISOString(), end: endOfDay(now).toISOString() }, + source: 'ui_preset', + label: '今天', + }; + } + if (id === 'yesterday') { + const day = addDays(now, -1); + return { + range: { start: startOfDay(day).toISOString(), end: endOfDay(day).toISOString() }, + source: 'ui_preset', + label: '昨天', + }; + } + if (id === '7d') { + return { + range: { start: addDays(now, -7).toISOString(), end: endOfDay(now).toISOString() }, + source: 'ui_preset', + label: '近7天', + }; + } + return { + range: buildDefault3dRange(now), + source: 'ui_preset', + label: `近${RAIN_DEFAULT_DAYS}天`, + }; +} + +/** Only truly unknowable time phrases trigger clarification; others fall back to default 3d. */ +const STRICT_AMBIGUOUS_TIME_PATTERNS = [ /不太确定.{0,8}时间/u, + /说不清.{0,6}(?:哪|什么).{0,4}(?:天|时间)/u, ]; const EXPLICIT_TIME_HINT = @@ -48,7 +99,7 @@ const EXPLICIT_TIME_HINT = export function resolveRainTimeRange(query, now = new Date()) { const text = String(query ?? '').trim(); - for (const pattern of AMBIGUOUS_TIME_PATTERNS) { + for (const pattern of STRICT_AMBIGUOUS_TIME_PATTERNS) { if (pattern.test(text)) { return { needsClarification: true, @@ -62,11 +113,9 @@ export function resolveRainTimeRange(query, now = new Date()) { } 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() }, + range: buildDefault3dRange(now), source: 'default_3d', label: `近${RAIN_DEFAULT_DAYS}天`, }; @@ -77,12 +126,10 @@ export function resolveRainTimeRange(query, now = new Date()) { 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); + if (isDefaultFallback && /最近|近期|这几天|前几天|那段(?:时间|日子)/u.test(text) && !/最近\s*\d+\s*天/u.test(text)) { return { needsClarification: false, - range: { start: start.toISOString(), end: end.toISOString() }, + range: buildDefault3dRange(now), source: 'default_3d', label: `近${RAIN_DEFAULT_DAYS}天`, }; diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 6600b66..6c0d4f0 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -33,6 +33,7 @@ import { import { AvatarPicker } from './AvatarPicker'; import { ChatSkillPicker } from './ChatSkillPicker'; import { ChatSkillIcon } from './ChatSkillIcons'; +import { RainTimeRangeBar } from './RainTimeRangeBar'; import { PageTemplateShopModal } from './PageTemplateShopModal'; import { ChatLoadingSpinner } from './ChatLoadingSpinner'; import { ChatSharePreviewModal } from './ChatSharePreviewModal'; @@ -42,6 +43,11 @@ import { PageSaveDialog } from './PageSaveDialog'; import { VoiceInputButton } from './VoiceInputButton'; import type { CapabilityMap, ChatFileAttachment, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types'; import type { MindSpaceSaveCategory } from '../types'; +import { + DEFAULT_RAIN_TIME_PRESET, + RAIN_SKILL_ID, + type RainTimePresetId, +} from '../utils/rainTimeRange'; const CHAT_PLACEHOLDER_PROMPTS = [ '帮我写一篇温柔一点的小短文', @@ -202,6 +208,7 @@ export function ChatPanel({ pgRequired?: boolean; imageGenerationMode?: ImageGenerationMode; selectedChatSkill?: string; + rainTimePreset?: RainTimePresetId; fileAttachments?: ChatFileAttachment[]; }, ) => void | Promise; @@ -241,6 +248,8 @@ export function ChatPanel({ const [deepReasoningTipVisible, setDeepReasoningTipVisible] = useState(false); const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | null>(null); const [templateShopOpen, setTemplateShopOpen] = useState(false); + const [activeRainSkill, setActiveRainSkill] = useState(false); + const [rainTimePreset, setRainTimePreset] = useState(DEFAULT_RAIN_TIME_PRESET); const [activeTemplatePrefill, setActiveTemplatePrefill] = useState<{ skillId: string; label: string; @@ -840,8 +849,12 @@ export function ChatPanel({ pgRequired: false, imageGenerationMode: 'auto', selectedChatSkill, + ...(selectedChatSkill === RAIN_SKILL_ID ? { rainTimePreset } : {}), fileAttachments: fileAttachmentsToSend, }); + if (selectedChatSkill === RAIN_SKILL_ID) { + setActiveRainSkill(false); + } uploadedImages.forEach(revokePendingImage); setPendingImages([]); setPendingFiles([]); @@ -868,6 +881,7 @@ export function ChatPanel({ }, [ activeTemplatePrefill, forceDeepReasoning, + rainTimePreset, input, onSubmit, onUploadFile, @@ -1255,6 +1269,13 @@ export function ChatPanel({ )} + {activeRainSkill && !taskInputStatusText && ( + + )}
{canUpload && ( @@ -1343,6 +1364,12 @@ export function ChatPanel({ } pendingSkillRef.current = skillId ?? null; setActiveTemplatePrefill(null); + if (skillId === RAIN_SKILL_ID) { + setActiveRainSkill(true); + setRainTimePreset(DEFAULT_RAIN_TIME_PRESET); + } else { + setActiveRainSkill(false); + } setInput((current) => mergeChatSkillPromptWithInput(prompt, current)); }} /> diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 05035b2..181809c 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -588,6 +588,7 @@ export function ChatView({ pgRequired: options?.pgRequired, imageGenerationMode: options?.imageGenerationMode, selectedChatSkill: options?.selectedChatSkill, + rainTimePreset: options?.rainTimePreset, fileAttachments: options?.fileAttachments, }, imageUrls, diff --git a/src/components/RainTimeRangeBar.tsx b/src/components/RainTimeRangeBar.tsx new file mode 100644 index 0000000..fde5710 --- /dev/null +++ b/src/components/RainTimeRangeBar.tsx @@ -0,0 +1,37 @@ +import { + DEFAULT_RAIN_TIME_PRESET, + RAIN_TIME_PRESET_OPTIONS, + type RainTimePresetId, +} from '../utils/rainTimeRange'; + +export function RainTimeRangeBar({ + value, + disabled, + onChange, +}: { + value?: RainTimePresetId; + disabled?: boolean; + onChange: (preset: RainTimePresetId) => void; +}) { + const active = value ?? DEFAULT_RAIN_TIME_PRESET; + + return ( +
+ 时间范围 +
+ {RAIN_TIME_PRESET_OPTIONS.map((option) => ( + + ))} +
+
+ ); +} diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts index 5e9bf9b..3bd751f 100644 --- a/src/hooks/useTKMindChat.ts +++ b/src/hooks/useTKMindChat.ts @@ -100,6 +100,7 @@ type ChatSubmitOptions = { pgRequired?: boolean; imageGenerationMode?: ImageGenerationMode; selectedChatSkill?: string; + rainTimePreset?: import('../utils/rainTimeRange').RainTimePresetId; fileAttachments?: ChatFileAttachment[]; goalRunId?: string; }; @@ -1874,6 +1875,7 @@ export function useTKMindChat( ...(options?.pgRequired ? { pgRequired: true } : {}), imageGenerationMode: options?.imageGenerationMode ?? 'auto', ...(options?.selectedChatSkill ? { selectedChatSkill: options.selectedChatSkill } : {}), + ...(options?.rainTimePreset ? { rainTimePreset: options.rainTimePreset } : {}), }, }; diff --git a/src/index.css b/src/index.css index b5d2f93..f6eb0c4 100644 --- a/src/index.css +++ b/src/index.css @@ -3162,6 +3162,59 @@ body, flex: 0 0 auto; } +.rain-time-range-bar { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 8px; + padding: 8px 10px; + border: 1px solid var(--color-border-input); + border-radius: var(--radius-lg); + background: var(--color-bg-elevated); +} + +.rain-time-range-bar-label { + flex: 0 0 auto; + font-size: 12px; + font-weight: 600; + color: var(--color-text-secondary); + white-space: nowrap; +} + +.rain-time-range-bar-options { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.rain-time-range-option { + min-height: 28px; + padding: 4px 10px; + border: 1px solid var(--color-border-input); + border-radius: 999px; + background: transparent; + color: var(--color-text-secondary); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.rain-time-range-option:hover:not(:disabled) { + color: var(--color-text-primary); + border-color: var(--color-border-strong, var(--color-border-input)); +} + +.rain-time-range-option.is-active { + color: var(--color-text-primary); + border-color: rgba(121, 146, 255, 0.55); + background: rgba(121, 146, 255, 0.12); +} + +.rain-time-range-option:disabled { + opacity: 0.55; + cursor: not-allowed; +} + .chat-deep-reasoning-toggle { position: relative; display: inline-flex; diff --git a/src/utils/rainTimeRange.ts b/src/utils/rainTimeRange.ts new file mode 100644 index 0000000..0a0ba2f --- /dev/null +++ b/src/utils/rainTimeRange.ts @@ -0,0 +1,12 @@ +export type RainTimePresetId = 'today' | 'yesterday' | '3d' | '7d'; + +export const RAIN_SKILL_ID = 'rain'; + +export const RAIN_TIME_PRESET_OPTIONS: ReadonlyArray<{ id: RainTimePresetId; label: string }> = [ + { id: 'today', label: '今天' }, + { id: 'yesterday', label: '昨天' }, + { id: '3d', label: '近3天' }, + { id: '7d', label: '近7天' }, +]; + +export const DEFAULT_RAIN_TIME_PRESET: RainTimePresetId = '3d';