-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
5809 lines (5686 loc) · 294 KB
/
Copy pathapp.js
File metadata and controls
5809 lines (5686 loc) · 294 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* ============================================================
TimeAgent Web · 应用逻辑
- 克隆自参考鸿蒙项目 TimeAgent_FuBen 的核心功能
- 纯前端(原生 JS),可直接双击 index.html 运行
- 商业级 UI / 完善边界处理 / 跨页面刷新联动
============================================================ */
(function () {
"use strict";
/* ----------------------- 小工具 ----------------------- */
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
const pad = (n) => (n < 10 ? "0" + n : "" + n);
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const esc = (s) =>
String(s == null ? "" : s)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
const uid = () =>
Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
const todayStr = () => {
const d = new Date();
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
// 实时时间信息(注入 AI prompt):日期+星期+时分,让模型知道"现在是几点",日报/复盘更贴合实际
const nowInfo = () => {
const d = new Date();
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${weekLabel(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const weekLabel = (d = new Date()) =>
["周日", "周一", "周二", "周三", "周四", "周五", "周六"][d.getDay()];
const formatTodayLabel = () => {
const d = new Date();
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${weekLabel(d)}`;
};
/* ----------------------- SVG 图标库 ----------------------- */
const I = {
home: '<path d="M3 11.5 12 4l9 7.5"/><path d="M5 10v9a1 1 0 0 0 1 1h3v-6h6v6h3a1 1 0 0 0 1-1v-9"/>',
calendar: '<rect x="3" y="4.5" width="18" height="16" rx="2.5"/><path d="M3 9h18M8 2.5v4M16 2.5v4"/>',
chart: '<path d="M4 20V10M10 20V4M16 20v-7M21 20H3"/>',
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-6.5 8-6.5S20 17 20 21"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
check: '<path d="M4 12.5 9 17.5 20 6.5"/>',
trash: '<path d="M4 7h16M9 7V4.5h6V7M6 7l1 13h10l1-13"/><path d="M10 11v6M14 11v6"/>',
tag: '<path d="M3 12V4.5h7.5L21 15l-7.5 7z"/><circle cx="7.5" cy="8" r="1.3"/>',
sparkle: '<path d="M12 3l1.9 4.6L18.5 9l-4.6 1.9L12 15l-1.9-4.1L5.5 9l4.6-1.4L12 3z"/><path d="M19 14l.9 2.1L22 17l-2.1.9L19 20l-.9-2.1L16 17l2.1-.9L19 14z"/>',
send: '<path d="M21 3 10 14M21 3l-6.5 18-4-8-8-4L21 3z"/>',
back: '<path d="M15 5l-7 7 7 7"/>',
close: '<path d="M6 6l12 12M18 6 6 18"/>',
chevron: '<path d="M9 6l6 6-6 6"/>',
bulb: '<path d="M9 18h6M10 21h4"/><path d="M12 3a6 6 0 0 0-3.5 10.9c.5.4.9 1 .9 1.6V16h5.2v-.5c0-.6.4-1.2.9-1.6A6 6 0 0 0 12 3z"/>',
target: '<circle cx="12" cy="12" r="8.5"/><circle cx="12" cy="12" r="4.5"/><circle cx="12" cy="12" r="1"/>',
bolt: '<path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/>',
list: '<path d="M8 6h13M8 12h13M8 18h13M3.5 6h.01M3.5 12h.01M3.5 18h.01"/>',
report: '<path d="M6 3h9l5 5v13a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><path d="M14 3v6h6M9 13h6M9 17h6"/>',
settings: '<circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.5-2-3.4-2.3 1a7 7 0 0 0-2-1.2l-.3-2.5h-4l-.3 2.5a7 7 0 0 0-2 1.2l-2.3-1-2 3.4 2 1.5A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.5 2 3.4 2.3-1a7 7 0 0 0 2 1.2l.3 2.5h4l.3-2.5a7 7 0 0 0 2-1.2l2.3 1 2-3.4-2-1.5c.1-.4.1-.8.1-1.2z"/>',
sun: '<circle cx="12" cy="12" r="4.5"/><path d="M12 2v2.5M12 19.5V22M4.2 4.2l1.8 1.8M18 18l1.8 1.8M2 12h2.5M19.5 12H22M4.2 19.8 6 18M18 6l1.8-1.8"/>',
moon: '<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3a6.5 6.5 0 0 0 9.8 9.8z"/>',
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
chat: '<path d="M21 12a8 8 0 0 1-11.5 7.2L4 20l1-4.5A8 8 0 1 1 21 12z"/>',
heart: '<path d="M12 20s-7-4.5-9.2-9C1.3 8 2.8 4.5 6.2 4.5c2 0 3.3 1.2 4 2.3.7-1.1 2-2.3 4-2.3 3.4 0 4.9 3.5 3.4 6.5C19 15.5 12 20 12 20z"/>',
brain: '<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-1 5.5A3 3 0 0 0 7 18a3 3 0 0 0 5 1V5a3 3 0 0 0-3-1zM15 4a3 3 0 0 1 3 3 3 3 0 0 1 1 5.5A3 3 0 0 1 17 18a3 3 0 0 1-5 1"/>',
headphones: '<path d="M4 13v-1a8 8 0 0 1 16 0v1"/><rect x="3" y="13" width="4" height="7" rx="2"/><rect x="17" y="13" width="4" height="7" rx="2"/>',
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
edit: '<path d="M4 20h4L19 9l-4-4L4 16zM14 6l4 4"/>',
repeat: '<path d="M17 2l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 22l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
palette: '<path d="M12 3a9 9 0 1 0 0 18c1.1 0 1.7-1 .9-1.8-.8-.8-.3-2.2.9-2.2H17a4 4 0 0 0 4-4c0-5-4-10-9-10z"/><circle cx="7.5" cy="11" r="1.1" fill="currentColor" stroke="none"/><circle cx="12" cy="7.5" r="1.1" fill="currentColor" stroke="none"/><circle cx="16.5" cy="11" r="1.1" fill="currentColor" stroke="none"/>',
save: '<path d="M5 3h11l3 3v15H5z"/><path d="M8 3v6h7"/><path d="M8 21v-7h8v7"/>',
share: '<circle cx="6" cy="12" r="2.6"/><circle cx="17" cy="5.5" r="2.6"/><circle cx="17" cy="18.5" r="2.6"/><path d="M8.3 10.8l6.5-3.8M8.3 13.2l6.5 3.8"/>',
shield: '<path d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6z"/><path d="M9 12l2 2 4-4"/>',
doc: '<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v4h4M9 12h6M9 16h6"/>',
};
const svg = (name, cls = "") =>
`<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true">${I[name] || ""}</svg>`;
/* ----------------------- 标签 / 分类系统 ----------------------- */
const DEFAULT_TAGS = [
{ tag: "学习", color: "#2563EB" },
{ tag: "工作", color: "#0891B2" },
{ tag: "运动", color: "#EF4444" },
{ tag: "社交", color: "#8B5CF6" },
{ tag: "休息", color: "#10B981" },
{ tag: "生活", color: "#F59E0B" },
{ tag: "饮食", color: "#EC4899" },
{ tag: "外出", color: "#64748B" },
{ tag: "其他", color: "#A1A1AA" },
];
const TAG_MAP = Object.fromEntries(DEFAULT_TAGS.map((t) => [t.tag, t.color]));
// 精选调色板:按色相均匀铺开,相邻色差明显,便于区分
const TAG_PALETTE = [
"#DC2626", "#F97316", "#F59E0B", "#EAB308", "#84CC16", "#22C55E",
"#10B981", "#14B8A6", "#06B6D4", "#0EA5E9", "#3B82F6", "#6366F1",
"#8B5CF6", "#A855F7", "#D946EF", "#EC4899", "#F43F5E", "#64748B",
];
// 预设色块 + 自定义取色器,三处(添加/编辑/新建分类)共用
function paletteDots(selected, withAct) {
return TAG_PALETTE.map(
(c) =>
`<span class="color-dot ${selected === c ? "sel" : ""}"${withAct ? ' data-act="pick-color"' : ""} data-color="${c}" style="--c:${c};background:${c}"></span>`
).join("");
}
function customColorInput(selected, ctx) {
const isCustom = !TAG_PALETTE.includes(selected);
return `<label class="color-custom ${isCustom ? "active" : ""}"${isCustom ? ` style="background:${selected}"` : ""} title="自定义颜色">
<input type="color" class="color-custom-input" data-ctx="${ctx}" value="${isCustom ? selected : "#3B82F6"}" />
${svg("palette")}
</label>`;
}
function colorPickerHTML(selected, ctx) {
return `<div class="color-dots">${paletteDots(selected)}${customColorInput(selected, ctx)}</div>`;
}
function getColorForTag(tag) {
if (TAG_MAP[tag]) return TAG_MAP[tag];
const custom = Store.state.customTags.find((t) => t.tag === tag);
return custom ? custom.color : "#A1A1AA";
}
// 标签 → 正经大类:内置标签大类=自身;自定义标签取 cat 字段(默认"其他"),
// 让"中二风格"的自定义标签也能归到学习/工作等正经大类统计
const CATS = ["学习", "工作", "运动", "饮食", "休息", "社交", "其他"];
function tagCategory(tag) {
if (TAG_MAP[tag]) return tag;
const custom = Store.state.customTags.find((t) => t.tag === tag);
return custom && CATS.includes(custom.cat) ? custom.cat : "其他";
}
function tagIcon(tag, color) {
return `<span class="tag-icon" style="background:${color}" aria-hidden="true">${esc(tag.charAt(0))}</span>`;
}
// 是否已完成:普通日程看 isCompleted;重复日程看该出现日期是否在 doneDates 中(按次完成)
// 回迁鸿蒙时可对应「单个重复实例的完成态」
function isDone(it) {
if (it && it.repeat && it.repeat !== "none") return !!(it.doneDates && it.doneDates.indexOf(it.date) >= 0);
return !!(it && it.isCompleted);
}
function allTags() {
return DEFAULT_TAGS.concat(Store.state.customTags);
}
function contrastText(hex) {
if (!hex) return "#fff";
let h = hex.replace("#", "");
if (h.length === 3)
h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
if (h.length !== 6) return "#fff";
const r = parseInt(h.slice(0, 2), 16),
g = parseInt(h.slice(2, 4), 16),
b = parseInt(h.slice(4, 6), 16);
const br = (r * 299 + g * 587 + b * 114) / 1000;
return br > 150 ? "#16223A" : "#fff";
}
// 颜色相似度:返回 [0,1],1=完全相同(RGB 归一化欧氏距离)
function colorSimilarity(a, b) {
const parse = (hex) => {
let h = String(hex || "").replace("#", "");
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
if (h.length !== 6) return null;
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
};
const pa = parse(a),
pb = parse(b);
if (!pa || !pb) return 0;
const dist = Math.sqrt(pa.reduce((s, v, i) => s + (v - pb[i]) * (v - pb[i]), 0));
return Math.max(0, 1 - dist / Math.sqrt(3 * 255 * 255));
}
// 标签重名/近色检测:返回所有需要提醒的文案(供 agent 与用户共用)
// mode: "create"(新建,查重名+近色)/ "recolor"(改色,只查近色)
function tagDupeWarnings(name, color, mode) {
const warns = [];
if (!name) return warns;
const tags = allTags();
if (mode !== "recolor") {
const same = tags.find((t) => t.tag === name);
if (same) warns.push(`标签「${name}」已存在,无需重复创建`);
}
const near = tags.filter((t) => t.tag !== name && colorSimilarity(t.color, color) > 0.88);
near.forEach((t) => warns.push(`标签「${t.tag}」的颜色与「${name}」的 ${color} 很接近(${t.color}),区分度不高,建议换个颜色`));
return warns;
}
/* ============================================================
时间自然语言解析(移植并增强自参考项目)
============================================================ */
function detectPeriod(text) {
let isPm = false,
isNoon = false;
if (/凌晨|清晨|早上|早晨|上午/.test(text)) {
} else if (/中午/.test(text)) {
isNoon = true;
} else if (/下午|傍晚|晚上|夜里|夜晚|深夜/.test(text)) {
isPm = true;
}
return { isPm, isNoon };
}
function cnHourToNum(s) {
if (/^\d+$/.test(s)) return +s;
const d = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 };
if (d[s] !== undefined) return d[s];
if (s === "十") return 10;
if (s.startsWith("二十")) return s.length > 2 ? 20 + (d[s[s.length - 1]] ?? 0) : 20; // 二十 / 二十一~二十三
if (s.startsWith("十")) return 10 + (d[s[1]] ?? 0); // 十一~十九
if (s.endsWith("十")) return (d[s[0]] ?? 0) * 10; // 二十/三十…
if (s.length === 2) return (d[s[0]] ?? 0) * 10 + (d[s[1]] ?? 0);
return NaN;
}
function parseTime(text, ctx) {
const periodCtx = ctx ? ctx : detectPeriod(text);
const isPm = periodCtx.isPm,
isNoon = periodCtx.isNoon;
const H = "(\\d{1,2}|[零一二两三四五六七八九十]+)"; // 小时:阿拉伯数字或中文数字
let hour = -1,
minute = 0,
raw = "",
m;
m = text.match(H + "\\s*[::]\\s*(\\d{1,2})");
if (m) {
hour = cnHourToNum(m[1]);
minute = +m[2];
raw = m[0];
} else {
m = text.match(H + "\\s*点\\s*(\\d{1,2}|[零一二两三四五六七八九十]+)\\s*分");
if (m) {
hour = cnHourToNum(m[1]);
minute = cnHourToNum(m[2]);
raw = m[0];
} else {
m = text.match(H + "\\s*点\\s*([一二三四])\\s*刻");
if (m) {
hour = cnHourToNum(m[1]);
const q = m[2];
minute = q === "一" ? 15 : q === "二" ? 30 : q === "三" ? 45 : 60;
raw = m[0];
} else {
m = text.match(H + "\\s*点\\s*半");
if (m) {
hour = cnHourToNum(m[1]);
minute = 30;
raw = m[0];
} else {
m = text.match(H + "\\s*(?:点|时(?!\s*间))");
if (m) {
hour = cnHourToNum(m[1]);
minute = 0;
raw = m[0];
}
}
}
}
}
if (hour === -1 || isNaN(hour)) return { found: false, valid: false, hour: -1, minute: 0, raw: "" };
if (isPm) {
if (hour === 12) hour = 0;
else if (hour < 12) hour += 12;
}
// 中午:不偏移("中午11点半"=11:30,"中午12点"=12:00;用户已用时段词限定,无需再 +12)
const valid = hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59;
return { found: true, valid, hour, minute, raw };
}
function cnToNum(s) {
const map = {
一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10,
};
return map[s] ?? 1;
}
function parseDuration(text) {
let m;
m = text.match(/(\d+(?:\.\d+)?)\s*小时/);
if (m) return { found: true, minutes: Math.round(parseFloat(m[1]) * 60), raw: m[0] };
m = text.match(/([一两二三四五六七八九十])\s*小时/);
if (m) return { found: true, minutes: cnToNum(m[1]) * 60, raw: m[0] };
m = text.match(/([一两二三四五六七八九十]?)\s*个半小时/);
if (m) {
const n = m[1] ? cnToNum(m[1]) : 1;
return { found: true, minutes: n * 60 + 30, raw: m[0] };
}
if (/半小时/.test(text)) return { found: true, minutes: 30, raw: "半小时" };
m = text.match(/(\d+)\s*分钟/);
if (m) return { found: true, minutes: +m[1], raw: m[0] };
return { found: false, minutes: 0, raw: "" };
}
function parseEndTime(text, fullText) {
const m = text.match(/(?:到|至|直到|—|-)\s*(.+)$/);
if (!m) return { found: false, valid: false, hour: -1, minute: 0, raw: "" };
return parseTime(m[1], detectPeriod(fullText));
}
const isTimeValid = (s) => /^([01]\d|2[0-3]):[0-5]\d$/.test(s);
function isRangeValid(t) {
if (!t || !isTimeValid(t.startTime) || !isTimeValid(t.endTime)) return false;
const s = +t.startTime.split(":")[0] * 60 + +t.startTime.split(":")[1];
const e = +t.endTime.split(":")[0] * 60 + +t.endTime.split(":")[1];
return e - s > 0;
}
// 免费演示模式:本地关键词解析 + 多轮补齐(保持与参考项目一致的鲁棒规则)
function buildFreeDemoTasks(userInput, fallbackNote, draft) {
const text = (userInput || "").trim();
if (text.length === 0)
return { tasks: [], question: "请输入您想安排的日程内容,例如「下午3点去健身房运动2小时」。" };
let title,
tag = "其他",
tagColor = "#A1A1AA",
startTime = "";
const dateFor = draft ? draft.date || todayStr() : relDateFromText(text) || todayStr();
if (draft) {
title = draft.title;
tag = draft.tag;
tagColor = draft.tagColor;
startTime = draft.startTime || "";
const durAns = parseDuration(text);
const endAns = parseEndTime(text, text);
if (!(durAns.found || endAns.found)) {
const t = parseTime(text);
if (t.found && t.valid) {
startTime = `${pad(t.hour)}:${pad(t.minute)}`;
} else if (t.found && !t.valid) {
return {
tasks: [],
question: `您补充的「${t.raw}」不是有效时间哦(小时 0–23,分钟 0–59)。请重新告诉我,例如:「下午3点」或「15:00」。`,
pending: { title, tag, tagColor, startTime, date: dateFor },
};
}
}
} else {
const tagRules = {
学习: ["学习", "看书", "读书", "复习", "自习", "考研", "考试", "网课", "背单词"],
工作: ["工作", "开会", "项目", "开发", "写代码", "办公", "需求", "汇报"],
运动: ["运动", "跑步", "健身", "打球", "瑜伽", "游泳", "锻炼", "跳绳"],
饮食: ["吃饭", "午餐", "晚餐", "早餐", "聚餐", "吃点", "喝点", "吃", "喝"],
休息: ["休息", "睡觉", "午睡", "放松", "摸鱼", "躺平"],
社交: ["聚会", "聊天", "约", "社交", "见朋友", "团建", "逛街"],
};
for (const key of Object.keys(tagRules)) {
if (tagRules[key].some((k) => text.includes(k))) {
tag = key;
break;
}
}
tagColor = getColorForTag(tag);
let clean = text
.replace(/^(?:补录|补上|补记|补一下|补个|录一下|记一下|添加|新建|加个|安排)/, "")
.replace(/^[我请帮想]*(?:要|想|帮忙)?/, "")
.replace(/(\d{1,2})\s*点\s*(\d{1,2})\s*分/g, "")
.replace(/(\d{1,2})\s*点\s*([一二三四])\s*刻/g, "")
.replace(/(\d{1,2})\s*点\s*半/g, "")
.replace(/(\d{1,2})\s*[::]\s*(\d{2})/g, "")
.replace(/(\d{1,2})\s*(?:点|时)/g, "")
.replace(/(\d+(?:\.\d+)?)\s*小时/g, "")
.replace(/(\d+)\s*分钟/g, "")
.replace(/[一两二三四五六七八九十]\s*个?(?:小时|分钟)/g, "")
.replace(/半小时/g, "")
// 相对日期词不进入标题(补录昨天/前天/上周X → 标题只留事项名)
.replace(/大前天|前天|昨天|昨日|今天|今日|明天|明早|明晚|后天|大后天|上周[一二三四五六日天]|下周[一二三四五六日天]|星期[一二三四五六日天]|礼拜[一二三四五六日天]/g, "")
.replace(/[到至直到]/g, "")
.replace(/[。.,,!!??\s]/g, "")
.trim();
title = clean.length > 0 ? (clean.length > 10 ? clean.slice(0, 10) : clean) : `${tag}待办`;
// 解析开始时间:先剔除「到X点/至X点」的结束时间片段,避免把结束时间误当开始时间
// (例:「读书到中午11点半」→ 只给结束时间,开始时间留空待追问,而不是 start=end 报错)
const endFirst = parseEndTime(text, text);
const startSearch = endFirst.found && endFirst.raw ? text.replace(endFirst.raw, " ") : text;
const t = parseTime(startSearch, detectPeriod(text));
if (!t.found)
return {
tasks: [],
question: `好的,我已记下「${title}」(${tag})。不过还缺少一个开始时间,请告诉我几点开始?\n例如:「下午3点」或「15:00」。`,
pending: { title, tag, tagColor, date: dateFor },
};
if (!t.valid)
return {
tasks: [],
question: `您输入的「${t.raw}」不是有效时间哦(小时 0–23,分钟 0–59)。请重新告诉我,例如:「下午3点」或「15:00」。`,
pending: { title, tag, tagColor, date: dateFor },
};
startTime = `${pad(t.hour)}:${pad(t.minute)}`;
}
if (!startTime)
return {
tasks: [],
question: `还差最后一步~请告诉我「${title}」从几点开始?例如:「下午3点」或「15:00」。`,
pending: { title, tag, tagColor, date: dateFor },
};
const sh = +startTime.slice(0, 2),
sm = +startTime.slice(3, 5);
let eh = -1,
em = -1,
resolved = false;
const end = parseEndTime(text, text);
if (end.found) {
if (!end.valid)
return {
tasks: [],
question: `您补充的结束时间「${end.raw}」不是有效时间哦。请重新告诉我,例如:「到17:00」或「2小时」。`,
pending: { title, tag, tagColor, startTime, date: dateFor },
};
if (end.hour * 60 + end.minute <= sh * 60 + sm)
return {
tasks: [],
question: `结束时间需要晚于开始时间 ${startTime} 哦。请重新告诉我,例如:「到17:00」或「2小时」。`,
pending: { title, tag, tagColor, startTime, date: dateFor },
};
eh = end.hour;
em = end.minute;
resolved = true;
} else {
const dur = parseDuration(text);
if (dur.found && dur.minutes > 0) {
const total = sh * 60 + sm + dur.minutes;
if (total > 1439)
return {
tasks: [],
question: `按您说的时长,结束时间会超过当天 23:59 啦。请告诉我一个更合适的时长,例如:「1.5小时」或「到22:00」。`,
pending: { title, tag, tagColor, startTime, date: dateFor },
};
eh = Math.floor(total / 60);
em = total % 60;
resolved = true;
}
}
if (!resolved)
return {
tasks: [],
question: `好的,已记下「${title}」(${tag})从 ${startTime} 开始。还需要多久呢?\n例如:「2小时」「90分钟」或「到17:00」。`,
pending: { title, tag, tagColor, startTime, date: dateFor },
};
const endTime = `${pad(eh)}:${pad(em)}`;
const descParts = [];
if (fallbackNote) descParts.push(fallbackNote);
descParts.push("(离线演示模式)已根据您提供的时间与时长自动排期。");
return {
tasks: [
{ title, startTime, endTime, desc: descParts.join(" "), tag, tagColor, date: dateFor },
],
};
}
/* ============================================================
全局状态仓库(发布订阅 + 本地持久化 + 安全解析)
============================================================ */
const STORE_KEY = "timeagent_web_v1";
const Store = {
state: {
schedule: [],
chat: [],
apiKey: "",
apiBase: "https://api.siliconflow.cn/v1",
apiModel: "deepseek-ai/DeepSeek-V3",
advice: "",
customTags: [],
prefs: { defaultView: "day", freshHighlight: true },
},
subs: [],
load() {
try {
const raw = localStorage.getItem(STORE_KEY);
if (raw) {
const p = JSON.parse(raw);
if (p && typeof p === "object") {
this.state.schedule = Array.isArray(p.schedule)
? p.schedule
.filter((i) => i && i.title != null)
.map((i) => Object.assign({ date: todayStr(), isCompleted: false, isFresh: false, desc: "", repeat: "none", remind: false, remindOffset: 10, doneDates: [], priority: "中", doneAt: null, doneAtMap: null }, i))
: [];
this.state.chat = Array.isArray(p.chat) ? p.chat : [];
this.state.apiKey = typeof p.apiKey === "string" ? p.apiKey : "";
this.state.apiBase = typeof p.apiBase === "string" && p.apiBase ? p.apiBase : "https://api.siliconflow.cn/v1";
this.state.apiModel = typeof p.apiModel === "string" && p.apiModel ? p.apiModel : "deepseek-ai/DeepSeek-V3";
this.state.advice = typeof p.advice === "string" ? p.advice : "";
this.state.customTags = Array.isArray(p.customTags) ? p.customTags : [];
this.state.prefs = Object.assign(
{ defaultView: "day", freshHighlight: true },
p.prefs && typeof p.prefs === "object" ? p.prefs : {}
);
}
}
} catch (e) {
console.warn("本地数据解析失败,已重置:", e);
toast("本地存档已损坏,已安全重置", "warn");
}
},
save() {
try {
localStorage.setItem(STORE_KEY, JSON.stringify(this.state));
} catch (e) {
toast("存储空间不足,部分数据可能未保存", "err");
}
},
notify() {
this.save();
this.subs.forEach((fn) => {
try {
fn();
} catch (e) {
console.error(e);
}
});
},
subscribe(fn) {
this.subs.push(fn);
},
addSchedule(item) {
// 兜底防线:拒绝非法时间(小时 ≥24 / 格式错),杜绝"25点/26点"日程(此前模板顺延会生成)
if (item && item.startTime && !isTimeValid(item.startTime)) {
console.warn("addSchedule 拦截非法开始时间:", item.startTime, item.title);
return null;
}
if (item && item.endTime && !isTimeValid(item.endTime)) {
console.warn("addSchedule 拦截非法结束时间:", item.endTime, item.title);
return null;
}
const it = Object.assign(
{ id: uid(), isCompleted: false, date: todayStr(), isFresh: false, desc: "", repeat: "none", remind: false, remindOffset: 10, doneDates: [], priority: "中", doneAt: null, doneAtMap: null },
item
);
this.state.schedule.push(it);
this.notify();
return it;
},
updateSchedule(id, patch) {
const i = this.state.schedule.findIndex((x) => x.id === id);
if (i >= 0) {
this.state.schedule[i] = Object.assign({}, this.state.schedule[i], patch);
this.notify();
}
},
removeSchedule(id) {
const i = this.state.schedule.findIndex((x) => x.id === id);
if (i >= 0) {
const [removed] = this.state.schedule.splice(i, 1);
this.notify();
return removed;
}
},
toggleSchedule(id, date) {
const i = this.state.schedule.findIndex((x) => x.id === id);
if (i < 0) return;
const it = this.state.schedule[i];
const ts = Date.now();
if (it.repeat && it.repeat !== "none") {
// 重复日程:按出现日期切换完成态,互不影响
const arr = it.doneDates ? it.doneDates.slice() : [];
const k = arr.indexOf(date);
if (k >= 0) arr.splice(k, 1);
else arr.push(date);
it.doneDates = arr;
// 记录本次完成时间(按日期键),供计划偏差分析
const m = it.doneAtMap ? Object.assign({}, it.doneAtMap) : {};
if (k >= 0) delete m[date];
else m[date] = ts;
it.doneAtMap = m;
} else {
it.isCompleted = !it.isCompleted;
it.doneAt = it.isCompleted ? ts : null;
}
this.notify();
},
};
/* ============================================================
统计计算(首页 / 统计页共用)
============================================================ */
function parseHM(t) {
const p = t.split(":");
return +p[0] + +p[1] / 60;
}
/* ============ 日期 / 范围 工具 ============ */
function fmtDate(d) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function parseDate(s) {
const [y, m, d] = s.split("-").map(Number);
return new Date(y, m - 1, d);
}
function addDays(s, n) {
const d = parseDate(s);
d.setDate(d.getDate() + n);
return fmtDate(d);
}
const WK = { 日: 0, 天: 0, 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6 };
function weekBounds(s) {
const d = parseDate(s);
const diff = d.getDay() === 0 ? -6 : 1 - d.getDay();
const mon = new Date(d);
mon.setDate(d.getDate() + diff);
const sun = new Date(mon);
sun.setDate(mon.getDate() + 6);
return [fmtDate(mon), fmtDate(sun)];
}
function monthBounds(s) {
const d = parseDate(s);
const first = new Date(d.getFullYear(), d.getMonth(), 1);
const last = new Date(d.getFullYear(), d.getMonth() + 1, 0);
return [fmtDate(first), fmtDate(last)];
}
function mdShort(s) {
const d = parseDate(s);
return `${d.getMonth() + 1}/${d.getDate()}`;
}
function formatDateLabel(s) {
const d = parseDate(s);
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${weekLabel(d)}`;
}
function humanDateLabel(s) {
const t = todayStr();
if (s === t) return "今天";
if (s === addDays(t, 1)) return "明天";
if (s === addDays(t, 2)) return "后天";
const d = parseDate(s);
return `${d.getMonth() + 1}月${d.getDate()}日`;
}
function relDateFromText(text) {
if (/今天|今日|当天|当日|今儿/.test(text)) return todayStr();
if (/大后天/.test(text)) return addDays(todayStr(), 3);
if (/明[早早晚日天]/.test(text)) return addDays(todayStr(), 1);
if (/后[天日]/.test(text)) return addDays(todayStr(), 2);
// 过去日期:昨天/前天/上周X(补录被误删或漏记的日程)
if (/昨[天日]|昨日|前一天/.test(text)) return addDays(todayStr(), -1);
if (/前[天日]|大前天/.test(text)) return addDays(todayStr(), -2);
const pwk = text.match(/上周([一二三四五六日天])/);
if (pwk) {
const target = WK[pwk[1]];
const cur = new Date();
const diff = (target - cur.getDay() + 7) % 7;
return addDays(todayStr(), diff - 7);
}
const nwk = text.match(/下周([一二三四五六日天])/);
if (nwk) {
const target = WK[nwk[1]];
const cur = new Date();
const diff = (target - cur.getDay() + 7) % 7;
return addDays(todayStr(), diff === 0 ? 7 : diff + 7);
}
const wk = text.match(/(?:星期|周|礼拜)([一二三四五六日天])/);
if (wk) {
const target = WK[wk[1]];
const cur = new Date();
const diff = (target - cur.getDay() + 7) % 7;
return addDays(todayStr(), diff);
}
const md1 = text.match(/(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?/);
if (md1) {
const y = new Date().getFullYear(),
mo = +md1[1],
da = +md1[2];
if (mo >= 1 && mo <= 12 && da >= 1 && da <= 31) {
let r = new Date(y, mo - 1, da);
if (r < parseDate(todayStr())) r = new Date(y + 1, mo - 1, da);
return fmtDate(r);
}
}
const md2 = text.match(/(\d{1,2})[./](\d{1,2})/);
if (md2) {
const y = new Date().getFullYear(),
mo = +md2[1],
da = +md2[2];
if (mo >= 1 && mo <= 12 && da >= 1 && da <= 31) {
let r = new Date(y, mo - 1, da);
if (r < parseDate(todayStr())) r = new Date(y + 1, mo - 1, da);
return fmtDate(r);
}
}
const md3 = text.match(/(\d{1,2})\s*号/);
if (md3) {
const da = +md3[1];
const t = parseDate(todayStr());
let r = new Date(t.getFullYear(), t.getMonth(), da);
if (r < parseDate(todayStr())) r.setMonth(r.getMonth() + 1);
return fmtDate(r);
}
return "";
}
function scopeItems(list, sc) {
const norm = (i) => i.date || todayStr();
const [rs, re] = scopeRange(sc);
// 展开重复日程为范围内具体日期的实例(与鸿蒙端逻辑一致,便于回迁)
const expanded = [];
list.forEach((it) => {
if (!it.repeat || it.repeat === "none") {
expanded.push(it);
return;
}
let cur = norm(it);
if (cur < rs) cur = rs;
const baseDow = parseDate(norm(it)).getDay();
while (cur <= re) {
const ok = it.repeat === "daily" || parseDate(cur).getDay() === baseDow;
if (ok) expanded.push(Object.assign({}, it, { date: cur, repeatInstance: true }));
cur = addDays(cur, 1);
}
});
if (sc.mode === "day") return expanded.filter((i) => norm(i) === sc.anchor);
if (sc.mode === "week") {
const [a, b] = weekBounds(sc.anchor);
return expanded.filter((i) => {
const d = norm(i);
return d >= a && d <= b;
});
}
if (sc.mode === "month") {
const d = parseDate(sc.anchor);
const y = d.getFullYear(),
m = d.getMonth();
return expanded.filter((i) => {
const x = parseDate(norm(i));
return x.getFullYear() === y && x.getMonth() === m;
});
}
return expanded;
}
// 当前范围的起止日期(含)
function scopeRange(sc) {
if (sc.mode === "day") return [sc.anchor, sc.anchor];
if (sc.mode === "week") return weekBounds(sc.anchor);
const d = parseDate(sc.anchor);
const y = d.getFullYear(),
m = d.getMonth();
const first = `${y}-${pad(m + 1)}-01`;
const last = `${y}-${pad(m + 1)}-${pad(new Date(y, m + 1, 0).getDate())}`;
return [first, last];
}
function scopeTitle(sc) {
if (sc.mode === "day")
return {
title: sc.anchor === todayStr() ? "今日概览" : humanDateLabel(sc.anchor),
sub: formatDateLabel(sc.anchor),
};
if (sc.mode === "week") {
const [a, b] = weekBounds(sc.anchor);
const isThis = weekBounds(todayStr())[0] === a;
return { title: isThis ? "本周概览" : "周概览", sub: `${mdShort(a)} – ${mdShort(b)}` };
}
const d = parseDate(sc.anchor);
return { title: `${d.getFullYear()}年${d.getMonth() + 1}月概览`, sub: "" };
}
// 当前查看范围(日/周/月,anchor 为基准日期)
let scope = { mode: "day", anchor: todayStr() };
function computeStatsFor(list) {
const map = {};
let total = 0;
list.forEach((it) => {
const d = parseHM(it.endTime) - parseHM(it.startTime);
if (d > 0) {
map[it.tag] = (map[it.tag] || 0) + d;
total += d;
}
});
const dist = Object.keys(map)
.map((tag) => ({
tag,
hours: map[tag],
percent: total ? +((map[tag] / total) * 100).toFixed(1) : 0,
color: getColorForTag(tag),
}))
.sort((a, b) => b.hours - a.hours);
return {
timeDist: dist,
totalHours: total,
totalCount: list.length,
completedCount: list.filter((i) => isDone(i)).length,
efficiency: list.length ? Math.round((list.filter((i) => isDone(i)).length / list.length) * 100) : 0,
};
}
// 大类汇总:把 timeDist 按"标签所属正经大类"聚合(自定义/趣味标签归入大类)
function catDistOf(stats) {
const cm = {};
(stats.timeDist || []).forEach((d) => {
const c = tagCategory(d.tag);
cm[c] = (cm[c] || 0) + d.hours;
});
const total = Object.values(cm).reduce((s, v) => s + v, 0);
return Object.keys(cm)
.map((cat) => ({ cat, hours: cm[cat], percent: total ? Math.round((cm[cat] / total) * 100) : 0, color: getColorForTag(cat) }))
.sort((a, b) => b.hours - a.hours);
}
function computeStats() {
return computeStatsFor(Store.state.schedule); }
// 空闲时段推荐
function freeSlots() {
const nowH = new Date().getHours();
const today = Store.state.schedule.filter((i) => (i.date || todayStr()) === todayStr());
const sorted = [...today].sort((a, b) => a.startTime.localeCompare(b.startTime));
const future = sorted.filter((i) => +i.startTime.split(":")[0] >= nowH);
const slots = [];
if (future.length) {
const first = +future[0].startTime.split(":")[0];
if (first > nowH) slots.push(`${pad(nowH)}:00 - ${pad(first)}:00`);
for (let i = 0; i < future.length - 1; i++) {
const ce = +future[i].endTime.split(":")[0];
const ns = +future[i + 1].startTime.split(":")[0];
if (ce < ns) slots.push(`${pad(ce)}:00 - ${pad(ns)}:00`);
}
}
return slots;
}
/* ============================================================
目标系统(教练层):用户设定目标 → AI 守护进度
- prefs.goals = [{ id, title, cat(大类), period("day"|"week"), hours }]
- goalProgress() 统计当前周期(今天/本周)目标大类的投入时长与达标情况
============================================================ */
function goalProgress() {
const goals = Store.state.prefs.goals || [];
const t = todayStr();
return goals.map((gl) => {
const items =
gl.period === "day"
? scopeItems(Store.state.schedule, { mode: "day", anchor: t })
: scopeItems(Store.state.schedule, { mode: "week", anchor: t });
const hours = items
.filter((i) => tagCategory(i.tag) === gl.cat)
.reduce((s, i) => s + Math.max(0, parseHM(i.endTime) - parseHM(i.startTime)), 0);
return {
...gl,
hours,
done: hours >= gl.hours,
pct: gl.hours ? Math.min(100, Math.round((hours / gl.hours) * 100)) : 0,
remain: Math.max(0, Math.round((gl.hours - hours) * 10) / 10),
};
});
}
function goalText(g) {
return `${g.title}(${g.period === "day" ? "每天" : "每周"} ${g.hours}h · ${g.cat}类)`;
}
// 近 30 天完成时间偏差均值(≥15 分钟才返回,供动态提醒补偿)
function lateMinAvg() {
const t = todayStr();
const dev = [];
Store.state.schedule.forEach((i) => {
if (!isDone(i) || !i.doneAt) return;
const d = i.date || t;
if (d < addDays(t, -30) || d > t) return;
const dt = new Date(i.doneAt);
dev.push(dt.getHours() * 60 + dt.getMinutes() - parseHM(i.endTime));
});
if (dev.length < 3) return 0;
const avg = dev.reduce((s, v) => s + v, 0) / dev.length;
return avg >= 15 ? Math.round(avg) : 0;
}
// 生成日程上下文(供 AI 感知用户真实日程,避免模型臆造/撞期)
// range: "day"=仅今日 | "week"=本周 | "month"=本月 | "all"=全局摘要 | {from,to}=自定义区间
const CHAT_MEMORY_OPTS = [
{ key: "day", label: "仅当日" },
{ key: "week", label: "本周" },
{ key: "month", label: "本月" },
{ key: "all", label: "全局" },
{ key: "custom", label: "自定义" },
];
function chatMemoryLabel(key) {
const o = CHAT_MEMORY_OPTS.find((x) => x.key === key);
return o ? o.label : "本周";
}
function scheduleContext(range) {
const t = todayStr();
const dayLine = (d, label) => {
const items = scopeItems(Store.state.schedule, { mode: "day", anchor: d });
if (!items.length) return "";
const row = items
.slice()
.sort((a, b) => (a.startTime || "").localeCompare(b.startTime || ""))
.map((it) => `${it.startTime}-${it.endTime} ${it.title}${isDone(it) ? "(已完成)" : ""}${it.tag ? " /" + it.tag : ""}`)
.join(";");
return `${label}:${row}`;
};
// 区间明细:逐日展开,上限 40 条防 token 爆炸
const rangeLines = (from, to, label) => {
const out = [];
let d = from;
let guard = 0;
while (d <= to && guard < 800 && out.length < 40) {
const items = scopeItems(Store.state.schedule, { mode: "day", anchor: d });
items
.slice()
.sort((a, b) => (a.startTime || "").localeCompare(b.startTime || ""))
.forEach((it) =>
out.push(`${d} ${it.startTime}-${it.endTime} ${it.title}${isDone(it) ? "(已完成)" : ""}${it.tag ? " /" + it.tag : ""}`)
);
d = addDays(d, 1);
guard++;
}
const total = Store.state.schedule.length;
return out.length
? `${label}(共 ${total} 条记录,展示前 ${out.length} 条):\n${out.join("\n")}`
: `${label}暂无日程`;
};
if (range === "day") return dayLine(t, "今天") || "今天暂无日程";
if (range === "week") {
const [a, b] = weekBounds(t);
return rangeLines(a, b, `本周(${a}~${b})`);
}
if (range === "month") {
const [a, b] = monthBounds(t);
return rangeLines(a, b, `本月(${a}~${b})`);
}
if (range === "all") {
const total = Store.state.schedule.length;
const summary = `全局共 ${total} 条日程记录`;
const recent = rangeLines(addDays(t, -3), addDays(t, 14), "近期(前3天~后14天)");
return `${summary}\n${recent}`;
}
if (range && range.from && range.to) return rangeLines(range.from, range.to, `自定义区间(${range.from}~${range.to})`);
return dayLine(t, "今天") || "今天暂无日程";
}
function buildAdvice(stats, sc) {
const sc_ = sc || scope;
const isToday = sc_.mode === "day" && sc_.anchor === todayStr();
const label = sc_.mode === "week" ? "本周" : sc_.mode === "month" ? "本月" : "今日";
const scoped = scopeItems(Store.state.schedule, sc_);
if (!scoped.length) {
if (!Store.state.schedule.length)
return "还没有任何安排哦。点击右上角 AI 图标或直接告诉我,就能智能规划你的时间啦~";
return `${label}暂时还没有日程,去首页规划一件小事吧,比如「明早 8 点背单词 1 小时」。`;
}
const timeStr = (it) =>
it.startTime ? (it.endTime && it.endTime !== it.startTime ? `${it.startTime}-${it.endTime}` : it.startTime) : "";
const sorted = [...scoped].sort((a, b) => (a.startTime || "").localeCompare(b.startTime || ""));
const done = stats.completedCount,
total = stats.totalCount;
const parts = [];
if (total === 1) {
// 仅 1 项:聚焦该事项本身,不谈"分类失衡/多任务协调"
const it = sorted[0];
const itDone = isDone(it);
parts.push(`${label}只规划了「${it.title}」一项${timeStr(it) ? `(${timeStr(it)})` : ""}${itDone ? ",已完成,节奏不错" : ",还没完成"}。`);
parts.push(
itDone
? "可以再补 1-2 件小事,或留一段空白休息,让一天更从容。"
: `建议现在就做:把「${it.title}」拆成 25 分钟的小步骤开始,动起来就不会觉得难了。`
);
} else {
parts.push(`${label}已规划 ${stats.totalHours.toFixed(1)} 小时,完成 ${done}/${total} 项。`);
const nxt = sorted.find((i) => !isDone(i));
if (nxt) parts.push(`优先处理「${nxt.title}」${nxt.startTime ? `(${nxt.startTime} 开始)` : ""},先啃最要紧的一块。`);
else if (total > 0) parts.push("全部完成,执行力很棒,记得留点时间休息。");
}
const slots = isToday ? freeSlots() : [];
if (slots.length) parts.push(`空闲时段 ${slots.join("、")},适合休息或碎片化学习。`);
return parts.join(" ");
}
/* ============================================================
首页 AI 洞察(P0):API 优先生成"今日/本周/本月"一句话洞察
- 5 分钟缓存,避免反复切换范围重复调用浪费 token
- 无 Key / 调用失败 → 回退离线 buildAdvice,保证零依赖
============================================================ */
// 多槽缓存:按「日/周/月 + 日期 + 数据指纹」各自缓存,切换范围后切回能直接命中(避免离线闪屏 + 重复消耗 token)
const insightCache = new Map();
function insightKey(stats) {
const sc = scope;
// 仅用「当前查看范围」的维度,避免依赖全局 schedule.length 造成 stale 命中
return `${sc.mode}:${sc.anchor}:${stats.completedCount}:${stats.totalCount}:${Math.round(stats.totalHours * 10)}`;
}
async function genInsight(stats) {
const key = insightKey(stats);
const hit = insightCache.get(key);
// 缓存只存模型结果,命中即「AI 在线」
if (hit && Date.now() - hit.at < 90 * 1000) return { text: hit.text, via: "ai" };
if (!apiReady()) return { text: buildAdvice(stats, scope), via: "offline" };
const label = scope.mode === "week" ? "本周" : scope.mode === "month" ? "本月" : "今日";
const withDate = scope.mode !== "day";
const scoped = scopeItems(Store.state.schedule, scope);
const rows = scoped
.map(
(it) =>
`${withDate ? (it.date || todayStr()) + " " : ""}${it.startTime}-${it.endTime} ${it.title}${isDone(it) ? "(已完成)" : "(未完成)"}${it.tag ? " /" + it.tag : ""}`
)
.join("\n");
const prompt =
`你是用户的私人时间管理洞察助手。今天真实日期:${todayStr()}。\n` +
`关于用户的长期习惯观察(供参考,与下方日程矛盾时以下方日程为准):${buildUserProfile() || "(历史数据不足)"}\n` +
`用户当前查看「${label}」概览,该周期严格只有下面列出的 ${scoped.length} 个日程(${withDate ? "日期 " : ""}时间 事项 状态 /分类):\n${rows || "(该周期暂无日程)"}\n` +
`【硬性要求】你的分析必须完全基于上述真实日程,严禁臆造任何未列出的日程、数字或完成情况;若只有 1 个日程,就不要谈"分类失衡/多任务协调",请聚焦这一个事项本身给建议。` +
`用户的自定义标签可能是个性化/趣味命名(如中二风格),请依据日程标题理解其真实性质,并按标签所属的正经大类(学习/工作/运动/饮食/休息/社交/其他)归类分析,不要被标签名字迷惑。\n` +
`请输出最多 3 句:① 一句话贴合实际的总评;② 1 条针对现有日程的具体可执行改进建议(如把某事项提前、补全休息、降低密度);③ 如需,1 句鼓励。` +
`全文 90 字以内,自然中文,不用列表符号、不用加粗、不用 emoji、不夸张。`;
try {
const text = await callLLM(
[
{ role: "system", content: `你是严谨又温暖的私人时间管理洞察助手。${personaPromptLine()}` },
{ role: "user", content: prompt },
],
{ temperature: 0.6, maxTokens: 500, timeoutMs: 30000 }
);
const clean = text.replace(/\s*\n+\s*/g, " ").trim().slice(0, 200);
insightCache.set(key, { text: clean, at: Date.now() });
if (insightCache.size > 12) insightCache.clear(); // 防止跨天累积无限增长
logWeeklyInsight(clean);
return { text: clean, via: "ai" };
} catch (e) {
const fb = buildAdvice(stats, scope);
logWeeklyInsight(fb);