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.