Skip to content

Commit e7fce42

Browse files
committed
fix(assistant,dashboard): 修复 A3/A4 使用中高概率缺陷
A4: 意图无完成入口导致每4h无限循环提醒+队头阻塞——每条意图至多提醒一次(localStorage持久化), findDueIntention 支持排除已提醒ID A3: 叙述增强异步竞态守卫——仅当气泡仍在展示本规则时才覆盖, 不抢占已展开面板; 启动时叙述延迟8s展示避免瞬间覆盖问候, 延迟期间用户关闭气泡则放弃 A4: 提醒时刻已过顺延到明天, 避免创建后立即提醒
1 parent b721398 commit e7fce42

5 files changed

Lines changed: 71 additions & 11 deletions

File tree

‎client/src/features/assistant/constants.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ export const EDIT_BURST_RATIO = 0.7;
4545
export const PROGRESS_NARRATIVE_STORAGE_KEY = 'assistant_progress_narrative_at';
4646
/** 叙述节奏:每 7 天一次 */
4747
export const PROGRESS_NARRATIVE_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
48+
/** 叙述气泡延迟(ms):与问候同批触发时延迟展示,避免瞬间覆盖问候语 */
49+
export const PROGRESS_NARRATIVE_DELAY_MS = 8 * 1000;
50+
51+
// ── A4 实施意图 ────────────────────────────────────────────
52+
53+
/** 已提醒意图 ID 列表的 localStorage 键(每条意图至多提醒一次) */
54+
export const INTENTION_REMINDED_STORAGE_KEY = 'assistant_intention_reminded_ids';
4855

4956
// ── A2 语音对话 ────────────────────────────────────────────────
5057

‎client/src/features/assistant/hooks/useIntentionCoach.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,24 +5,53 @@
55
* @ai-context: 挂载后周期扫描 implementation_intentions 表(本地 SQLite),
66
* 发现到期意图时向事件总线发射 intention:due,由 ProactiveEngine 按
77
* intention-reminder 规则决策是否弹气泡(冷却 4h + 频率上限天然防打扰)。
8+
* 每条意图至多提醒一次(localStorage 持久化已提醒 ID)——应用暂无
9+
* 意图完成入口,若允许重复提醒会导致到期意图每 4h 无限循环提醒,
10+
* 且 findDueIntention 只返回最新一条会阻塞后续新意图(队头阻塞)。
811
* 觉察 > 管控:只发事件不强制;检测失败静默跳过。
912
* @ai-context: Emits intention:due when a stored intention falls due;
10-
* the proactive engine decides whether to surface a bubble.
13+
* each intention reminds at most once (persisted in localStorage) since
14+
* there is no completion UI yet. The proactive engine decides whether to
15+
* surface a bubble.
1116
*/
1217
import { useEffect } from 'react';
1318
import { assistantEventBus } from '../lib/eventBus';
1419
import { findDueIntention } from '../lib/intentionRepository';
20+
import { INTENTION_REMINDED_STORAGE_KEY } from '../constants';
1521

1622
/** 检测间隔:5 分钟一次,重复打扰由规则冷却与频率上限兜底 */
1723
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
24+
/** 已提醒 ID 列表保留上限(防 localStorage 无限增长) */
25+
const MAX_REMINDED_IDS = 100;
26+
27+
/** 读取已提醒意图 ID 列表(损坏/不可用时降级为空集) */
28+
function readRemindedIds(): Set<string> {
29+
try {
30+
const raw = localStorage.getItem(INTENTION_REMINDED_STORAGE_KEY);
31+
const arr = raw ? (JSON.parse(raw) as unknown) : [];
32+
return new Set(Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string') : []);
33+
} catch {
34+
return new Set();
35+
}
36+
}
37+
38+
/** 记录已提醒意图 ID(超出上限截断旧条目) */
39+
function markReminded(id: string): void {
40+
try {
41+
const ids = [...readRemindedIds(), id].slice(-MAX_REMINDED_IDS);
42+
localStorage.setItem(INTENTION_REMINDED_STORAGE_KEY, JSON.stringify(ids));
43+
} catch { /* localStorage 不可用时降级为会话内去重 */ }
44+
}
1845

1946
export function useIntentionCoach(): void {
2047
useEffect(() => {
2148
const check = async () => {
2249
try {
2350
const now = new Date();
24-
const due = await findDueIntention(now);
51+
// 已提醒过的意图直接跳过(否则最新一条会队头阻塞后续意图)
52+
const due = await findDueIntention(now, readRemindedIds());
2553
if (!due) return;
54+
markReminded(due.id);
2655

2756
assistantEventBus.emit('intention:due', {
2857
currentHour: now.getHours(),

‎client/src/features/assistant/hooks/useProactiveEngine.ts‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { PROACTIVE_RULES } from '../lib/proactiveRules';
1212
import { pickTemplate } from '../lib/messageTemplates';
1313
import { getStatsCacheText, refreshStatsCache } from '../lib/progressStats';
1414
import { useAssistantStore } from '../store/useAssistantStore';
15-
import { MAX_TRIGGERS_PER_HOUR, MAX_CONSECUTIVE_IGNORES, PROGRESS_NARRATIVE_STORAGE_KEY } from '../constants';
15+
import { MAX_TRIGGERS_PER_HOUR, MAX_CONSECUTIVE_IGNORES, PROGRESS_NARRATIVE_STORAGE_KEY, PROGRESS_NARRATIVE_DELAY_MS } from '../constants';
1616
import { getAuthToken } from '@/lib/ai/aiPluginProvider';
1717
import type { TriggerContext, ProactiveRule } from '../types';
1818

@@ -21,6 +21,8 @@ const lastTriggeredMap = new Map<string, number>();
2121
let hourlyCount = 0;
2222
let hourlyResetAt = Date.now() + 60 * 60 * 1000;
2323
let consecutiveIgnores = 0;
24+
/** 最近一次气泡被关闭的时刻(延迟展示类规则用它判断用户是否已表达不想被打扰) */
25+
let lastDismissedAt = 0;
2426

2527
/** A3 {stats} 占位符缓存未就绪时的通用正向兜底文案 */
2628
const FALLBACK_STATS_TEXT = '你的每一点坚持都算数';
@@ -47,8 +49,12 @@ async function enhanceNarrative(statsText: string, ruleId: string): Promise<void
4749
authToken: authToken ?? undefined,
4850
}) as { narrative?: string; status?: string };
4951
if (res?.narrative && res.status === 'success') {
50-
// getState 读取避免闭包捕获旧的 showBubble 引用
51-
useAssistantStore.getState().showBubble(res.narrative, ruleId);
52+
// 竞态守卫:AI 返回时用户可能已展开面板或关闭气泡——
53+
// 仅当当前仍在展示本规则的气泡时才覆盖,不抢占用户当前状态
54+
const s = useAssistantStore.getState();
55+
if (s.panelState === 'bubble' && s.bubbleTriggerId === ruleId) {
56+
s.showBubble(res.narrative, ruleId);
57+
}
5258
}
5359
} catch { /* 静默降级:保留离线模板拼装的统计气泡 */ }
5460
}
@@ -108,19 +114,31 @@ export function useProactiveEngine(): void {
108114
// 缓存未就绪(非 Electron/采集失败)时用通用正向文案兜底
109115
const statsText = getStatsCacheText() ?? FALLBACK_STATS_TEXT;
110116
const message = pickTemplate(rule.id).replace('{stats}', statsText);
111-
showBubble(message, rule.id);
117+
// progress-narrative 走下方延迟展示分支,其余规则立即展示
118+
if (rule.id !== 'progress-narrative') {
119+
showBubble(message, rule.id);
120+
}
112121

113-
// A3 微进展叙述:记录节奏 + 异步 AI 增强(成功后覆盖气泡)
122+
// A3 微进展叙述:同步记录节奏;气泡延迟展示——app:startup 同批的
123+
// 问候语先曝光,避免叙述瞬间覆盖问候;延迟期间用户已展开面板
124+
// 或关闭过气泡则放弃展示(奖赏回来 ≠ 抢回注意力)
114125
if (rule.id === 'progress-narrative') {
115126
markNarrativeShown();
116-
void enhanceNarrative(statsText, rule.id);
127+
const scheduleAt = Date.now();
128+
window.setTimeout(() => {
129+
const s = useAssistantStore.getState();
130+
if (s.panelState === 'expanded' || lastDismissedAt > scheduleAt) return;
131+
s.showBubble(message, rule.id);
132+
void enhanceNarrative(statsText, rule.id);
133+
}, PROGRESS_NARRATIVE_DELAY_MS);
117134
}
118135
}
119136
}
120137

121138
/** 外部调用:标记用户忽略了气泡 */
122139
export function reportBubbleDismissed(): void {
123140
consecutiveIgnores++;
141+
lastDismissedAt = Date.now();
124142
}
125143

126144
/** 外部调用:标记用户回应了气泡(奖赏回来——重置退让计数) */

‎client/src/features/assistant/lib/intentionRepository.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,14 @@ export async function getActiveIntentions(): Promise<ImplementationIntention[]>
7070
/**
7171
* 找出当前到期可提醒的意图(active 且 trigger_at 已到/未设)。
7272
* 觉察 > 管控:只返回最近一条,避免多意图同时打扰。
73+
* @param excludeIds 需跳过的意图 ID(如已提醒过的),避免队头阻塞后续意图
7374
*/
74-
export async function findDueIntention(now: Date = new Date()): Promise<ImplementationIntention | undefined> {
75+
export async function findDueIntention(
76+
now: Date = new Date(),
77+
excludeIds?: ReadonlySet<string>,
78+
): Promise<ImplementationIntention | undefined> {
7579
const actives = await getActiveIntentions();
76-
return actives.find(i => isIntentionDue(i.trigger_at, now));
80+
return actives.find(i => !excludeIds?.has(i.id) && isIntentionDue(i.trigger_at, now));
7781
}
7882

7983
/**

‎client/src/features/dashboard/components/StartupRitual.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,15 @@ export default function StartupRitual(props: Props) {
6464

6565
const { onPhaseChange, onCycleComplete } = useBreathGuideSound(sound);
6666

67-
/** A4:HH:mm 提醒时间 → 当日 ISO 时间戳(留空则不设定时提醒) */
67+
/** A4:HH:mm 提醒时间 → ISO 时间戳(留空则不设定时提醒;时刻已过则顺延到明天) */
6868
const buildTriggerAt = useCallback((): string | undefined => {
6969
if (!triggerTime) return undefined;
7070
const [h, m] = triggerTime.split(':').map(Number);
7171
if (Number.isNaN(h) || Number.isNaN(m)) return undefined;
7272
const d = new Date();
7373
d.setHours(h, m, 0, 0);
74+
// 下午补做仪式时选的早间时刻已过去——顺延到明天而非创建后立即提醒
75+
if (d.getTime() <= Date.now()) d.setDate(d.getDate() + 1);
7476
return d.toISOString();
7577
}, [triggerTime]);
7678

0 commit comments

Comments
 (0)