PageSourceSearch

https://hiword.ai/src/mixins/fox-behavior.js

js hiword.ai collected 2026-10-05 12:02:46 UTC 21,607 bytes, 522 lines download raw bytes

1/**
2 * fox-behavior.js — BehaviorEngine + SmartCandidate + 持久化
3 *
4 * 6 维用户画像 + 智能候选消息 + digest 洞察映射
5 * Vue 2 CDN mixin,挂在 window.FoxBehaviorMixin
6 */
7(function () {
8
9  /* ── private helpers ── */
10
11  function _dateStr(daysAgo) {
12    var d = new Date(Date.now() + 8 * 3600000 - (daysAgo || 0) * 86400000);
13    return d.toISOString().slice(0, 10);
14  }
15
16  function _utcHour() {
17    return new Date(Date.now() + 8 * 3600000).getUTCHours();
18  }
19
20  function _readJSON(key) {
21    try { return JSON.parse(localStorage.getItem(key)) || null; } catch (e) { return null; }
22  }
23
24  function _writeJSON(key, val) {
25    try { localStorage.setItem(key, JSON.stringify(val)); } catch (e) { /* quota */ }
26  }
27
28  function _adviceAction(area) {
29    if (!area) return 'goLearn';
30    var a = area.toLowerCase();
31    if (/shadow|发音|phonem|音素/.test(a)) return 'goShadow';
32    if (/talk|对话|口语|speak/.test(a)) return 'goTalk';
33    if (/review|复习/.test(a)) return 'goReview';
34    if (/read|阅读/.test(a)) return 'goRead';
35    if (/practice|练习/.test(a)) return 'goPractice';
36    return 'goLearn';
37  }
38
39  /* ── mixin ── */
40
41  window.FoxBehaviorMixin = {
42    methods: {
43
44      /* ────────────────────────────────────────────
45       * 1. _behaviorEngine()
46       *    计算 6 维画像 → localStorage + this.behaviorProfile
47       * ──────────────────────────────────────────── */
48      _behaviorEngine: function () {
49        var stats = this.dailyStats || {};
50        var tdy = today();
51        var profile = {};
52
53        /* ── rhythm (近 14 天) ── */
54        var activeDays = 0;
55        var weekendActive = 0;
56        var weekdayActive = 0;
57        var bingeDays = 0;
58
59        for (var i = 0; i < 14; i++) {
60          var ds = _dateStr(i);
61          var entry = stats[ds];
62          if (!entry) continue;
63          var hasActivity = (entry.learned || 0) + (entry.reviewed || 0) +
64            (entry.shadowTotal || 0) + (entry.talkComplete || 0) +
65            (entry.readCount || 0) > 0;
66          if (!hasActivity) continue;
67          activeDays++;
68          var dow = new Date(Date.now() + 8 * 3600000 - i * 86400000).getUTCDay();
69          if (dow === 0 || dow === 6) weekendActive++;
70          else weekdayActive++;
71          if ((entry.activeMinutes || 0) > 60) bingeDays++;
72        }
73
74        if (activeDays >= 12) {
75          profile.rhythm = 'daily-grinder';
76        } else if (activeDays < 3) {
77          var mem = this.foxMemory || {};
78          if (mem.firstDay) {
79            var total = Math.floor((new Date(tdy) - new Date(mem.firstDay)) / 86400000);
80            profile.rhythm = total < 7 ? 'newcomer' : 'sporadic';
81          } else {
82            profile.rhythm = 'newcomer';
83          }
84        } else if (weekendActive > 0 && weekendActive > weekdayActive * 2) {
85          profile.rhythm = 'weekend-warrior';
86        } else if (bingeDays >= 3) {
87          profile.rhythm = 'binge-learner';
88        } else {
89          profile.rhythm = 'sporadic';
90        }
91
92        /* ── timePreference ── */
93        var hasTimeData = false;
94        var buckets = { morning: 0, lunch: 0, evening: 0, night: 0 };
95        for (var dk in stats) {
96          var e = stats[dk];
97          if (e && e.timeSlots) {
98            hasTimeData = true;
99            buckets.morning += (e.timeSlots.morning || 0);
100            buckets.lunch += (e.timeSlots.lunch || 0);
101            buckets.evening += (e.timeSlots.evening || 0);
102            buckets.night += (e.timeSlots.night || 0);
103          }
104        }
105        if (!hasTimeData) {
106          profile.timePreference = 'mixed';
107        } else {
108          var bestKey = 'mixed';
109          var bestVal = 0;
110          var mapping = { morning: 'early-bird', lunch: 'lunch-learner', evening: 'evening-learner', night: 'night-owl' };
111          for (var bk in buckets) {
112            if (buckets[bk] > bestVal) { bestVal = buckets[bk]; bestKey = mapping[bk] || 'mixed'; }
113          }
114          profile.timePreference = bestVal > 0 ? bestKey : 'mixed';
115        }
116
117        /* ── modePreference (近 14 天) ── */
118        var tLearn = 0, tReview = 0, tShadow = 0, tTalk = 0;
119        for (var j = 0; j < 14; j++) {
120          var ent = stats[_dateStr(j)];
121          if (!ent) continue;
122          tLearn += (ent.learned || 0);
123          tReview += (ent.reviewed || 0);
124          tShadow += (ent.shadowTotal || 0);
125          tTalk += (ent.talkComplete || 0);
126        }
127        var tAll = tLearn + tReview + tShadow + tTalk || 1;
128        var ratio = {
129          learn: tLearn / tAll,
130          review: tReview / tAll,
131          shadow: tShadow / tAll,
132          talk: tTalk / tAll
133        };
134        var primary = 'learn', maxR = ratio.learn;
135        if (ratio.review > maxR) { maxR = ratio.review; primary = 'review'; }
136        if (ratio.shadow > maxR) { maxR = ratio.shadow; primary = 'shadow'; }
137        if (ratio.talk > maxR) { primary = 'talk'; }
138        profile.modePreference = { primary: primary, ratio: ratio };
139
140        /* ── weakSpots ── */
141        var phonemes = this.shadowPhonemeStats || {};
142        var prevProfile = _readJSON('hw_fox_behavior');
143        var prevMap = {};
144        if (prevProfile && prevProfile.weakSpots) {
145          for (var pi = 0; pi < prevProfile.weakSpots.length; pi++) {
146            prevMap[prevProfile.weakSpots[pi].key] = prevProfile.weakSpots[pi].failRate;
147          }
148        }
149        var spots = [];
150        for (var ph in phonemes) {
151          var ps = phonemes[ph];
152          var samples = ps.seen || 0;
153          if (samples < 10) continue;
154          var fr = (ps.failed || 0) / samples;
155          if (fr <= 0.2) continue;
156          var trend = 'stable';
157          if (prevMap[ph] !== undefined) {
158            var diff = fr - prevMap[ph];
159            if (diff < -0.05) trend = 'improving';
160            else if (diff > 0.05) trend = 'worsening';
161          }
162          spots.push({
163            type: 'phoneme',
164            key: ph,
165            failRate: Math.round(fr * 100) / 100,
166            trend: trend,
167            samples: samples
168          });
169        }
170        spots.sort(function (a, b) { return b.failRate - a.failRate; });
171        profile.weakSpots = spots.slice(0, 5);
172
173        /* ── momentum (近 6 天: 3 recent + 3 prior) ── */
174        var recent3 = 0, prior3 = 0, totalDataDays = 0;
175        for (var k = 0; k < 14; k++) {
176          if (stats[_dateStr(k)]) totalDataDays++;
177        }
178        for (var m = 0; m < 6; m++) {
179          var me = stats[_dateStr(m)];
180          var mins = me ? (me.activeMinutes || 0) : 0;
181          if (m < 3) recent3 += mins; else prior3 += mins;
182        }
183        var r3 = recent3 / 3;
184        var p3 = prior3 / 3;
185
186        if (totalDataDays < 3) {
187          profile.momentum = 'newcomer';
188        } else if (p3 === 0 && r3 > 0) {
189          profile.momentum = 'returning';
190        } else if (r3 < 1) {
191          profile.momentum = 'stalled';
192        } else if (p3 > 0 && r3 > p3 * 1.3) {
193          profile.momentum = 'accelerating';
194        } else if (p3 > 0 && r3 < p3 * 0.5) {
195          profile.momentum = 'decelerating';
196        } else {
197          profile.momentum = 'steady';
198        }
199
200        /* ── relationship ── */
201        var fm = this.foxMemory || {};
202        if (fm.firstDay) {
203          var rd = Math.floor((new Date(tdy) - new Date(fm.firstDay)) / 86400000);
204          if (rd <= 3) profile.relationship = 'stranger';
205          else if (rd <= 14) profile.relationship = 'acquaintance';
206          else if (rd <= 60) profile.relationship = 'friend';
207          else profile.relationship = 'companion';
208        } else {
209          profile.relationship = 'stranger';
210        }
211
212        profile.computedAt = new Date().toISOString();
213
214        /* persist */
215        this.behaviorProfile = profile;
216        _writeJSON('hw_fox_behavior', profile);
217        return profile;
218      },
219
220      /* ────────────────────────────────────────────
221       * 2. _buildSmartCandidates(candidates)
222       *    往 candidates 数组 push 智能候选
223       *    @deprecated: replaced by FoxContextEngineMixin
224       * ──────────────────────────────────────────── */
225      _buildSmartCandidates: function (candidates) {
226        // @deprecated: replaced by fox-context-engine
227        if (window.FoxContextEngineMixin) return;
228        var bp = this.behaviorProfile || {};
229        var lang = this.LANG || 'en';
230        var zh = lang === 'zh';
231
232        /* ── a) 智能问候 (priority 85) ── */
233        if (this._checkSmartCooldown('greeting')) {
234          var hour = _utcHour();
235          var tp, ms;
236          if (zh) {
237            tp = hour < 6 ? '这么早!' : hour < 12 ? '早上好!' : hour < 18 ? '下午好!' : hour < 23 ? '晚上好!' : '夜深了!';
238            switch (bp.momentum) {
239              case 'accelerating': ms = '最近学习势头很猛,继续保持 🔥'; break;
240              case 'returning':    ms = '好久不见,欢迎回来 ☀️'; break;
241              case 'steady':       ms = '今天也一起加油吧'; break;
242              case 'decelerating': ms = '最近是不是有点忙?慢慢来也没关系'; break;
243              case 'stalled':      ms = '回来啦?随时可以开始'; break;
244              default:             ms = '准备好开始学习了吗?'; break;
245            }
246          } else {
247            tp = hour < 6 ? 'Up early! ' : hour < 12 ? 'Good morning! ' : hour < 18 ? 'Good afternoon! ' : hour < 23 ? 'Good evening! ' : 'Burning the midnight oil! ';
248            switch (bp.momentum) {
249              case 'accelerating': ms = "You've been on fire lately, keep it up 🔥"; break;
250              case 'returning':    ms = 'Long time no see, welcome back ☀️'; break;
251              case 'steady':       ms = "Let's keep going today!"; break;
252              case 'decelerating': ms = 'Been busy lately? No pressure, take your time'; break;
253              case 'stalled':      ms = 'Good to see you! Ready when you are'; break;
254              default:             ms = 'Ready to learn?'; break;
255            }
256          }
257          candidates.push({
258            type: 'smart', subtype: 'greeting', priority: 85,
259            msg: tp + ms, expr: 'hopeful', anim: 'wave', cta: null
260          });
261        }
262
263        /* ── b) 弱点教练 (priority 78) ── */
264        if (bp.weakSpots && bp.weakSpots.length > 0 && this._checkSmartCooldown('weakSpot')) {
265          var ws = bp.weakSpots[0];
266          candidates.push({
267            type: 'smart', subtype: 'weakSpot', priority: 78,
268            msg: zh
269              ? ws.key + ' 发音还在练,今天 Shadow 时多注意?'
270              : ws.key + ' needs practice — focus on it in Shadow today?',
271            expr: 'thinking', anim: 'point',
272            cta: { label: zh ? '去练习' : 'Practice', action: 'goShadow' }
273          });
274        }
275
276        /* ── c) 趋势洞察 (priority 75) ── */
277        if (bp.momentum === 'accelerating' && this._checkSmartCooldown('trendInsight')) {
278          var st = this.dailyStats || {};
279          var rc = 0, pc = 0;
280          for (var i = 0; i < 6; i++) {
281            var mn = (st[_dateStr(i)] || {}).activeMinutes || 0;
282            if (i < 3) rc += mn; else pc += mn;
283          }
284          var pct = pc > 0 ? Math.round((rc - pc) / pc * 100) : 100;
285          candidates.push({
286            type: 'smart', subtype: 'trendInsight', priority: 75,
287            msg: zh
288              ? '最近 3 天学习时间增长了 ' + pct + '%,势头不错!'
289              : 'Study time grew ' + pct + '% in the last 3 days. Great momentum!',
290            expr: 'excited', anim: 'bounce', cta: null
291          });
292        }
293
294        /* ── d) 模式建议 (priority 70) ── */
295        if (bp.modePreference && (this.masteredCount || 0) > 20 && this._checkSmartCooldown('modeSuggestion')) {
296          var r = bp.modePreference.ratio || {};
297          var sug = null;
298          if ((r.shadow || 0) < 0.1) sug = { action: 'goShadow', zh: 'Shadow 跟读', en: 'Shadow Reading' };
299          else if ((r.talk || 0) < 0.1) sug = { action: 'goTalk', zh: 'AI 对话', en: 'AI Talk' };
300          else if ((r.review || 0) < 0.1) sug = { action: 'goReview', zh: '复习', en: 'Review' };
301          if (sug) {
302            candidates.push({
303              type: 'smart', subtype: 'modeSuggestion', priority: 70,
304              msg: zh
305                ? '试试 ' + sug.zh + '?用得不多,但对记忆很有帮助'
306                : 'Try ' + sug.en + "? You haven't used it much, but it really helps retention",
307              expr: 'curious', anim: 'tilt',
308              cta: { label: zh ? '去试试' : 'Try it', action: sug.action }
309            });
310          }
311        }
312
313        /* ── e) 记忆回调增强 (priority 72) ── */
314        var mem = this.foxMemory || {};
315        if ((bp.relationship === 'friend' || bp.relationship === 'companion') &&
316            this._checkSmartCooldown('memoryRecall')) {
317          var rm = '';
318          if (mem.firstMasteredWord) {
319            rm = zh
320              ? '还记得吗?你掌握的第一个词是「' + mem.firstMasteredWord + '」'
321              : 'Remember? Your first mastered word was "' + mem.firstMasteredWord + '"';
322          } else if (mem.longestStreak) {
323            rm = zh
324              ? '你最长连续学习了 ' + mem.longestStreak + ' 天,太厉害了!'
325              : 'Your longest streak was ' + mem.longestStreak + ' days — impressive!';
326          } else if (mem.firstShadow) {
327            rm = zh
328              ? '你在 ' + mem.firstShadow + ' 完成了第一次跟读,进步很大!'
329              : 'You did your first Shadow on ' + mem.firstShadow + ' — look how far you\'ve come!';
330          }
331          if (rm) {
332            candidates.push({
333              type: 'smart', subtype: 'memoryRecall', priority: 72,
334              msg: rm, expr: 'proud', anim: 'nod', cta: null
335            });
336          }
337        }
338
339        /* ── f) 隐藏成就 (priority 80) ── */
340        var digest = this.foxDigest || {};
341        if (digest.hiddenAchievement &&
342            this._checkSmartCooldown('hiddenAchievement', digest.hiddenAchievement)) {
343          candidates.push({
344            type: 'smart', subtype: 'hiddenAchievement', priority: 80,
345            msg: digest.hiddenAchievement,
346            expr: 'celebrating', anim: 'sparkle', cta: null
347          });
348        }
349      },
350
351      /* ────────────────────────────────────────────
352       * 3. _digestToInsights(digest)
353       *    fox-digest 新字段 → foxInsightQueue 条目
354       * ──────────────────────────────────────────── */
355      _digestToInsights: function (digest) {
356        if (!digest) return;
357        var zh = (this.LANG || 'en') === 'zh';
358        var now = Date.now();
359        var DAY = 86400000;
360        var insights = [];
361
362        /* weeklyComparison → progress 卡片(注入前端真实数据) */
363        if (digest.weeklyComparison) {
364          var wc = digest.weeklyComparison;
365          // 注入薄弱音素(来自前端 shadowPhonemeStats,非 AI 生成)
366          var pStats = this.shadowPhonemeStats || {};
367          var phonemeBadges = Object.entries(pStats)
368            .filter(function(e) { return e[1].seen >= 3 && e[1].failed / e[1].seen > 0.35; })
369            .sort(function(a, b) { return (b[1].failed / b[1].seen) - (a[1].failed / a[1].seen); })
370            .slice(0, 4)
371            .map(function(e) { return { phoneme: e[0], rate: Math.round(e[1].failed / e[1].seen * 100) + '%' }; });
372          // 注入易错词(来自前端 difficultWords)
373          var hardWords = (this.difficultWords || []).slice(0, 5).map(function(dw) {
374            return { word: dw.word.word, count: dw.againCount };
375          });
376          var _wcCta = phonemeBadges.length > 0
377              ? { label: zh ? '去跟读练习' : 'Practice Shadow', action: 'shadow' }
378              : (hardWords.length > 0 ? { label: zh ? '去做题巩固' : 'Practice Quiz', action: 'practice' } : null);
379          insights.push({
380            id: 'weekly_' + today(),
381            type: 'progress',
382            theme: 'progress',
383            icon: '🎯',
384            title: zh ? '学习洞察' : 'Learning Insights',
385            content: wc.thisWeek || '',
386            detail: wc.vsLastWeek || '',
387            trend: wc.trend || 'stable',
388            phonemes: phonemeBadges,
389            hardWords: hardWords,
390            cta: _wcCta,
391            actionable: !!_wcCta,
392            actions: _wcCta ? [_wcCta] : [],
393            priority: 'high',
394            source: 'digest',
395            createdAt: now,
396            expiresAt: now + 3 * DAY,
397            shown: false,
398            shownAt: null
399          });
400        }
401
402        /* specificAdvice[0] → tip 卡片 (带 CTA) */
403        if (digest.specificAdvice && digest.specificAdvice.length > 0) {
404          var adv = digest.specificAdvice[0];
405          var _advCta = { label: zh ? '去试试' : 'Try it', action: _adviceA
405ction(adv.area) };
406          insights.push({
407            id: 'advice_' + today(),
408            type: 'tip',
409            theme: 'tip',
410            icon: '💡',
411            title: adv.area || (zh ? '学习建议' : 'Study Tip'),
412            content: adv.advice || '',
413            cta: _advCta,
414            actionable: true,
415            actions: [_advCta],
416            priority: 'normal',
417            source: 'digest',
418            createdAt: now,
419            expiresAt: now + 2 * DAY,
420            shown: false,
421            shownAt: null
422          });
423        }
424
425        /* hiddenAchievement → celebrate 卡片 */
426        if (digest.hiddenAchievement) {
427          insights.push({
428            id: 'achieve_' + today(),
429            type: 'celebrate',
430            theme: 'celebrate',
431            icon: '🏆',
432            title: zh ? '隐藏成就' : 'Hidden Achievement',
433            content: digest.hiddenAchievement,
434            cta: null,
435            actionable: false,
436            actions: [],
437            priority: 'normal',
438            source: 'digest',
439            createdAt: now,
440            expiresAt: now + 3 * DAY,
441            shown: false,
442            shownAt: null
443          });
444        }
445
446        /* 最多 3 条 */
447        insights = insights.slice(0, 3);
448
449        this.foxInsightQueue = insights;
450        _writeJSON('hw_fox_insights', insights);
451        return insights;
452      },
453
454      /* ────────────────────────────────────────────
455       * 4. _cleanExpiredInsights()
456       * ──────────────────────────────────────────── */
457      _cleanExpiredInsights: function () {
458        var insights = _readJSON('hw_fox_insights') || [];
459        var now = Date.now();
460        var DAY = 86400000;
461
462        insights = insights.filter(function (item) {
463          if (item.expiresAt && item.expiresAt < now) return false;
464          if (item.shown && item.shownAt && (now - new Date(item.shownAt).getTime() > DAY)) return false;
465          return true;
466        });
467
468        if (insights.length > 5) insights = insights.slice(0, 5);
469
470        this.foxInsightQueue = insights;
471        _writeJSON('hw_fox_insights', insights);
472      },
473
474      /* ────────────────────────────────────────────
475       * 5. _checkSmartCooldown(type, key?)
476       *    true = 可展示(并标记已用),false = 冷却中
477       *    @deprecated: replaced by FoxContextEngineMixin
478       * ──────────────────────────────────────────── */
479      _checkSmartCooldown: function (type, key) {
480        // @deprecated: replaced by fox-context-engine
481        if (window.FoxContextEngineMixin) return;
482        var COOLDOWNS = {
483          greeting: 1,
484          weakSpot: 1,
485          trendInsight: 3,
486          modeSuggestion: 7,
487          memoryRecall: 3,
488          hiddenAchievement: 0   // keyed, handled separately
489        };
490
491        var log = _readJSON('hw_fox_smart_log') || {};
492        var tdy = today();
493        var entry = log[type] || {};
494
495        /* hiddenAchievement: 同一 key 只展示一次 */
496        if (type === 'hiddenAchievement' && key) {
497          var shown = entry.shownKeys || [];
498          if (shown.indexOf(key) !== -1) return false;
499          shown.push(key);
500          entry.shownKeys = shown;
501          entry.lastDate = tdy;
502          log[type] = entry;
503          _writeJSON('hw_fox_smart_log', log);
504          return true;
505        }
506
507        /* 普通日期冷却 */
508        var lastDate = entry.lastDate;
509        if (lastDate) {
510          var daysDiff = Math.floor((new Date(tdy) - new Date(lastDate)) / 86400000);
511          if (daysDiff < (COOLDOWNS[type] || 1)) return false;
512        }
513
514        entry.lastDate = tdy;
515        log[type] = entry;
516        _writeJSON('hw_fox_smart_log', log);
517        return true;
518      }
519    }
520  };
521
522})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.