feiyu-memory 4.0.1 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +136 -1
- package/README.md +206 -160
- package/cordis.patch.yml +25 -8
- package/package.json +2 -2
- package/src/core/config.mjs +74 -10
- package/src/core/persona.mjs +25 -5
- package/src/core/recall.mjs +190 -13
- package/src/host/index.mjs +245 -40
package/src/core/config.mjs
CHANGED
|
@@ -48,13 +48,18 @@ export const CONFIG_SPEC = [
|
|
|
48
48
|
{
|
|
49
49
|
key: 'budgetTokens',
|
|
50
50
|
type: 'number',
|
|
51
|
-
// 2000 → 3000
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
51
|
+
// 2000 → 3000 → 6000。
|
|
52
|
+
//
|
|
53
|
+
// 2000 装不下常驻段(2680),「最近的事」只剩 2 条。
|
|
54
|
+
// 3000 能给到 6 条。
|
|
55
|
+
//
|
|
56
|
+
// 2026-11-02 提到 6000:召回那栏从「最多 2 条摘要」改成「最多 15 条正文」,
|
|
57
|
+
// 15 条正文实测约 4800 token,3000 根本装不下 —— 会被整段裁掉,
|
|
58
|
+
// 那这条鱼就一条旧事都想不起来,比改之前还差。
|
|
59
|
+
default: 6000,
|
|
55
60
|
min: 200,
|
|
56
|
-
max:
|
|
57
|
-
step:
|
|
61
|
+
max: 12000,
|
|
62
|
+
step: 100,
|
|
58
63
|
label: '注入预算(token)',
|
|
59
64
|
},
|
|
60
65
|
{
|
|
@@ -108,17 +113,69 @@ export const CONFIG_SPEC = [
|
|
|
108
113
|
min: 0,
|
|
109
114
|
max: 1,
|
|
110
115
|
step: 0.05,
|
|
111
|
-
|
|
116
|
+
// ⚠️ 2026-11-02 之后**对自动注入不再生效**。
|
|
117
|
+
//
|
|
118
|
+
// 原来自动注入走 selectMemories,这道阈值决定"相似度多高就必定提起那件旧事"。
|
|
119
|
+
// 现在那条路改成 BM25 直接给一批正文,靠 `recallScoreRatio` 那道相对门槛,
|
|
120
|
+
// 这个绝对值就没有位置了。
|
|
121
|
+
//
|
|
122
|
+
// 字段保留不删:删了老配置里的值会被静默忽略(反而更坏),
|
|
123
|
+
// 而且 selectMemories 还在被测试和外部调用。**但界面上改它不会有任何效果。**
|
|
124
|
+
label: '必触发的相似度阈值(4.1 起对自动注入不生效,见 recallScoreRatio)',
|
|
112
125
|
},
|
|
113
126
|
{
|
|
114
127
|
key: 'recentEntries',
|
|
115
128
|
type: 'number',
|
|
116
|
-
|
|
129
|
+
// 8 → 3(2026-11-02)。它只在新会话第一轮和压缩之后整段重灌,
|
|
130
|
+
// 平时不占地方 —— 所以"8 条"其实大半时候没被读到。
|
|
131
|
+
// 3 条够交代"我们上次在干什么",而且省下 1600 token 给召回那栏。
|
|
132
|
+
default: 3,
|
|
117
133
|
min: 0,
|
|
118
134
|
max: 30,
|
|
119
135
|
step: 1,
|
|
120
136
|
label: '「最近的事」最多列几条(不靠相似度,永远在场)',
|
|
121
137
|
},
|
|
138
|
+
{
|
|
139
|
+
key: 'recallFanout',
|
|
140
|
+
type: 'number',
|
|
141
|
+
// ── 2026-11-02:召回从「模型挑 2 条」改成「BM25 直接给最多 15 条正文」──
|
|
142
|
+
//
|
|
143
|
+
// 原来走一次模型调用:480 行索引(约 1.6 万 token,全价)塞给模型挑 3 条。
|
|
144
|
+
// 实测那一次调用跟一整轮聊天一样贵,而更糟的是 —— **摘要索引会漏**。
|
|
145
|
+
//
|
|
146
|
+
// 现在改成:BM25 打在正文上(本地、免费、0.2ms),直接把这批完整正文
|
|
147
|
+
// 放进注入。省掉一次模型调用,也省掉"挑"这一步可能挑错的环节。
|
|
148
|
+
//
|
|
149
|
+
// 这是个**上限**,不是目标数。真正的条数由 `recallScoreRatio` 那道相对门槛决定 ——
|
|
150
|
+
// 实测按 15 条硬取,一大半是只撞上一个常用词的噪音。
|
|
151
|
+
default: 15,
|
|
152
|
+
min: 0,
|
|
153
|
+
max: 40,
|
|
154
|
+
step: 1,
|
|
155
|
+
label: '按相似度最多捞几条正文进注入(0 = 只留最近的事)',
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
key: 'recallScoreRatio',
|
|
159
|
+
type: 'number',
|
|
160
|
+
// 只留分数 ≥ 最高分 × 这个比例的条目。
|
|
161
|
+
//
|
|
162
|
+
// 为什么是相对值:相似度是饱和函数,绝对刻度会随查询长短漂移
|
|
163
|
+
// (长问句最高才 0.7,短词全在 0.99),固定分数线要么全砍要么全留。
|
|
164
|
+
//
|
|
165
|
+
// 0.85 → 0.70(2026-11-02 当天返工)。0.85 砍过头了:
|
|
166
|
+
// 查「南京」BM25 命中 5 条 —— 0.982 / 0.972 / 0.970 / 0.969 / 0.948,
|
|
167
|
+
// 五条全相关 —— 乘下来只剩 1 条进注入。
|
|
168
|
+
// **说好给一批旧事,实际给一条,比改之前的 2 条还差。**
|
|
169
|
+
//
|
|
170
|
+
// 分布是不稳的:有的查询第 5 名还有 0.86,有的第 2 名就断了。
|
|
171
|
+
// 固定比例猜不准分布,宁可往宽了给 ——
|
|
172
|
+
// **漏掉真事的代价,比多带两条噪音大得多。**
|
|
173
|
+
default: 0.7,
|
|
174
|
+
min: 0,
|
|
175
|
+
max: 1,
|
|
176
|
+
step: 0.01,
|
|
177
|
+
label: '召回门槛(只留分数接近最高分的,越低给得越多)',
|
|
178
|
+
},
|
|
122
179
|
{
|
|
123
180
|
key: 'maxShowPerTurn',
|
|
124
181
|
type: 'number',
|
|
@@ -126,7 +183,9 @@ export const CONFIG_SPEC = [
|
|
|
126
183
|
min: 0,
|
|
127
184
|
max: 12,
|
|
128
185
|
step: 1,
|
|
129
|
-
|
|
186
|
+
// ⚠️ 2026-11-02 之后**对自动注入不再生效**。条数改由 recallFanout 控。
|
|
187
|
+
// 保留字段的理由同 highSim。
|
|
188
|
+
label: '「突然想起来」最多几条(4.1 起不生效,改看 recallFanout)',
|
|
130
189
|
},
|
|
131
190
|
{
|
|
132
191
|
key: 'wanderChance',
|
|
@@ -135,7 +194,12 @@ export const CONFIG_SPEC = [
|
|
|
135
194
|
min: 0,
|
|
136
195
|
max: 0.5,
|
|
137
196
|
step: 0.01,
|
|
138
|
-
|
|
197
|
+
// ⚠️ 2026-11-02 之后**不再生效**。
|
|
198
|
+
//
|
|
199
|
+
// "走神"是 selectMemories 里那一步随机想起一条旧事。现在按 BM25 给一批,
|
|
200
|
+
// 没有"掷骰子"这个动作了 —— 那一步本来就是为"只给 1 条"打掩护的。
|
|
201
|
+
// 保留字段的理由同 highSim。
|
|
202
|
+
label: '走神想起旧事的概率(4.1 起不生效)',
|
|
139
203
|
},
|
|
140
204
|
{
|
|
141
205
|
// ── 续写窗口 ──
|
package/src/core/persona.mjs
CHANGED
|
@@ -114,7 +114,7 @@ export function renderMemoryBlock(input = {}) {
|
|
|
114
114
|
if (recent) parts.push({ key: 'recent', text: recent, weight: 80 });
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
const memories = renderRecalled(input.recalled, now);
|
|
117
|
+
const memories = renderRecalled(input.recalled, now, input.recalledMax);
|
|
118
118
|
// ⚠️ **召回这一栏也必须有保底。**
|
|
119
119
|
//
|
|
120
120
|
// 加词汇保底之后,死的是它 —— 实测(真实档案、回归查询):
|
|
@@ -356,7 +356,15 @@ export function renderRelationship(rel, now = Date.now()) {
|
|
|
356
356
|
bits.push(rel.familiarity >= 0.7 ? '我们很熟了' : rel.familiarity >= 0.3 ? '我们聊过一阵了' : '我们刚认识不久');
|
|
357
357
|
}
|
|
358
358
|
if (typeof rel.nickname === 'string' && rel.nickname.trim() !== '') {
|
|
359
|
-
|
|
359
|
+
// 2026-11-08:主语从「他」改成「空气凤梨(用户)」。
|
|
360
|
+
//
|
|
361
|
+
// 起因是一次真的混了:注入里读到一条手记落款「(这是空气凤梨我自己写的)」,
|
|
362
|
+
// 那是用户给**自己**的签名,我把它读成了「他在叫我」——
|
|
363
|
+
// 于是把「你是空气凤梨」说给了用户听,正好说反。
|
|
364
|
+
//
|
|
365
|
+
// 「他」这个代词在这块里指谁,全靠上下文猜;猜错一次就认错人。
|
|
366
|
+
// 写死名字加「(用户)」两个字,笨重一点,但不会再混。
|
|
367
|
+
bits.push(`空气凤梨(用户)管我叫"${rel.nickname.trim()}"`);
|
|
360
368
|
}
|
|
361
369
|
// 两句之间用逗号接,不用「·」—— 那是分隔符,一出现就是表格味
|
|
362
370
|
if (bits.length > 0) lines.push(`${bits.join(',')}。`);
|
|
@@ -472,13 +480,25 @@ export function renderRecent(entries, shown, now = Date.now(), limit = 12) {
|
|
|
472
480
|
* @param {Array<{entry: object}>} recalled
|
|
473
481
|
* @param {number} [now]
|
|
474
482
|
*/
|
|
475
|
-
export function renderRecalled(recalled, now = Date.now()) {
|
|
483
|
+
export function renderRecalled(recalled, now = Date.now(), maxEntries = 2) {
|
|
476
484
|
if (!Array.isArray(recalled) || recalled.length === 0) return '';
|
|
485
|
+
const cap = Number.isFinite(maxEntries) && maxEntries > 0 ? Math.floor(maxEntries) : 2;
|
|
477
486
|
const lines = recalled
|
|
478
487
|
.filter((c) => c && c.entry && typeof c.entry.text === 'string')
|
|
479
|
-
.slice(0,
|
|
488
|
+
.slice(0, cap)
|
|
480
489
|
.map((c) => {
|
|
481
|
-
|
|
490
|
+
// 2026-11-03:`softWhen` → `dayLabel`。
|
|
491
|
+
//
|
|
492
|
+
// 原来这里是「前阵子,……」—— 那个模糊词当初是为了防止旧事被说成
|
|
493
|
+
// "上一轮"发生的。它做到了,但代价是**时间信息被压没了**:
|
|
494
|
+
// 10 月 21 日和 11 月 2 日都叫「前阵子」,间隔看不见。
|
|
495
|
+
//
|
|
496
|
+
// 用户的原话:**「现在记的是发生了什么,缺的是多久没发生。
|
|
497
|
+
// 空白是我唯一看不见的东西。」**
|
|
498
|
+
//
|
|
499
|
+
// `dayLabel` 给的是今天 / 昨天 / 3 天前 / 10-05 —— 同样按自然日算,
|
|
500
|
+
// 但能读出间隔,也不会像软词那样让人以为是刚发生的事。
|
|
501
|
+
const when = dayLabel(c.entry.created_at, now);
|
|
482
502
|
const body = stripTimeWords(c.entry.text);
|
|
483
503
|
return when === '' ? `- ${body}` : `- ${when},${body}`;
|
|
484
504
|
});
|
package/src/core/recall.mjs
CHANGED
|
@@ -21,22 +21,37 @@ const IDF_SMOOTH = 1;
|
|
|
21
21
|
/**
|
|
22
22
|
* 建倒排索引。纯派生数据,随用随建。
|
|
23
23
|
*
|
|
24
|
-
* ── 2026-
|
|
24
|
+
* ── 2026-11-02:索引吃**正文**,不吃 summary ──
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
* `summary`(一句,模型写的)。倒排索引吃前者,大保底(recall)吃后者。
|
|
26
|
+
* 这是实测推翻了两次设计之后定下来的。
|
|
28
27
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* 一份数据喂两个地方,另一份就是纯多余的。
|
|
28
|
+
* 摘要做索引有一个致命毛病:**它写的是当天的术语,人问的是抽象词。**
|
|
29
|
+
* 实测那一条:
|
|
32
30
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* 大保底(她主动查) 模型读 summary —— 准
|
|
31
|
+
* 摘要 boot.log 13:20 apply+routes·5次剩3次·注入气泡·插件顺序错·已关
|
|
32
|
+
* 正文 ……后来他说项目失败了,事先准备不足。我说是失败了,但问题不在代码,在顺序……
|
|
36
33
|
*
|
|
37
|
-
*
|
|
34
|
+
* 拿「项目失败了」「事先准备不足」去检索,摘要一个字都撞不上;
|
|
35
|
+
* 换成正文索引,两条都排第 1 名(1.000)。
|
|
38
36
|
*
|
|
39
|
-
*
|
|
37
|
+
* 整组对比(480 条真实日记):
|
|
38
|
+
*
|
|
39
|
+
* 摘要索引 正文索引
|
|
40
|
+
* 项目失败了 未命中 → 第 1 名
|
|
41
|
+
* 事先准备不足 未命中 → 第 1 名
|
|
42
|
+
* 吃了睡睡了吃 「早饭吃了没」→ 1.000 正确那条
|
|
43
|
+
* 煮饭 空 → 0.967
|
|
44
|
+
*
|
|
45
|
+
* 代价只有建索引变重(32ms → 94ms),**检索本身一点没变慢**,
|
|
46
|
+
* 而且它是纯 CPU,一个 token 都不花。索引只在 archive 变化时重建。
|
|
47
|
+
*
|
|
48
|
+
* 于是职责分开:
|
|
49
|
+
* **检索** 吃正文 —— 它要的是"人会不会这样说"
|
|
50
|
+
* **给模型读** 还是摘要 / 正文,那是渲染层的事,跟这里无关
|
|
51
|
+
*
|
|
52
|
+
* 别把 summary 塞回来当索引。它漏掉的那半段,往往正是最要紧的那句。
|
|
53
|
+
*
|
|
54
|
+
* @param {Array<{id: string, text?: string}>} entries
|
|
40
55
|
* @returns {{postings: Map<string, Map<string, number>>, df: Map<string, number>, lengths: Map<string, number>, N: number}}
|
|
41
56
|
*/
|
|
42
57
|
export function buildIndex(entries) {
|
|
@@ -46,8 +61,17 @@ export function buildIndex(entries) {
|
|
|
46
61
|
const list = Array.isArray(entries) ? entries : [];
|
|
47
62
|
|
|
48
63
|
for (const entry of list) {
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
// 正文 + 摘要都吃。
|
|
65
|
+
//
|
|
66
|
+
// 正文是主力:人问往事用的是"项目失败了""那事搞砸了"这种话,
|
|
67
|
+
// 而摘要写的是当天的术语(boot.log、apply/routes),一个字都撞不上。
|
|
68
|
+
//
|
|
69
|
+
// 摘要不能扔:它带着**别名和梗称**(「边缘行者2 / 续集 / 那部动画」),
|
|
70
|
+
// 而正文不一定写全。test/core.test.mjs 里有一条就是拿这个卡着的。
|
|
71
|
+
//
|
|
72
|
+
// 两边都进去的代价只有建索引变重(32ms → 177ms,一次性),
|
|
73
|
+
// 检索本身在 0.2ms 量级,一个 token 都不花。
|
|
74
|
+
const tokens = tokenize(`${entry.text ?? ''}\n${entry.summary ?? ''}`);
|
|
51
75
|
if (tokens.length === 0) continue;
|
|
52
76
|
|
|
53
77
|
const tf = new Map();
|
|
@@ -223,6 +247,58 @@ export function searchScores(index, query, options = {}) {
|
|
|
223
247
|
return scores;
|
|
224
248
|
}
|
|
225
249
|
|
|
250
|
+
/**
|
|
251
|
+
* 取前 N 条,但只留**跟最高分接近**的那些。
|
|
252
|
+
*
|
|
253
|
+
* ── 为什么需要这道闸(2026-11-02 实测)──
|
|
254
|
+
*
|
|
255
|
+
* 光按 top-N 取会给一堆噪音。真实档案、查询「项目失败了」:
|
|
256
|
+
*
|
|
257
|
+
* 第 1 名 1.00 那个失败的项目(对)
|
|
258
|
+
* 第 2 名 0.96 欢乐谷小时候没玩刺激项目(错,只是撞上了「项目」)
|
|
259
|
+
* 第 3-12 名 气泡卡没、赛博朋克、0.1.8 发布……(全无关)
|
|
260
|
+
*
|
|
261
|
+
* 12 条塞进去 5412 token,**八成是垃圾**。多给不等于不漏,只等于更吵。
|
|
262
|
+
*
|
|
263
|
+
* ── 为什么用"相对门槛"而不是固定分数线 ──
|
|
264
|
+
*
|
|
265
|
+
* 分数是**饱和函数**(1 − e^−gain),所以它的绝对刻度会随查询长短漂移:
|
|
266
|
+
* 一句长问句可能最高才 0.7,一句短词可能全在 0.99。固定门槛要么全砍要么全留。
|
|
267
|
+
*
|
|
268
|
+
* 跟着第一名走就稳:**离第一名太远的就是另一件事。**
|
|
269
|
+
*
|
|
270
|
+
* ── 但 `limit` 不是没用 ──
|
|
271
|
+
*
|
|
272
|
+
* 它是上限。真正相关多的查询(「八条鱼论战」前 4 名全对)不会因为
|
|
273
|
+
* 相对门槛被砍到只剩 1 条,而铺得很开的查询(「项目失败了」)会被砍到 5~6 条。
|
|
274
|
+
* 两个数字一起用,才是"宽但不吵"。
|
|
275
|
+
*
|
|
276
|
+
* @param {ReturnType<typeof buildIndex>} index
|
|
277
|
+
* @param {string} query
|
|
278
|
+
* @param {{limit?: number, ratio?: number, floor?: number}} [options]
|
|
279
|
+
* @returns {Map<string, number>} id → 相似度,已按分数降序
|
|
280
|
+
*/
|
|
281
|
+
export function topHitScores(index, query, options = {}) {
|
|
282
|
+
const limit = Number.isFinite(options.limit) ? Math.max(0, Math.floor(options.limit)) : 15;
|
|
283
|
+
const ratio = Number.isFinite(options.ratio) ? options.ratio : 0.85;
|
|
284
|
+
const floor = Number.isFinite(options.floor) ? options.floor : 0.6;
|
|
285
|
+
const out = new Map();
|
|
286
|
+
if (limit === 0) return out;
|
|
287
|
+
|
|
288
|
+
// 多捞一些再砍:后面的过滤(note、最近的事)会再淘汰一批。
|
|
289
|
+
const scored = searchScores(index, query, { limit: Math.max(limit * 3, 30) });
|
|
290
|
+
const ranked = [...scored.entries()].sort((a, b) => b[1] - a[1]);
|
|
291
|
+
if (ranked.length === 0) return out;
|
|
292
|
+
|
|
293
|
+
const cutoff = Math.max(floor, ranked[0][1] * ratio);
|
|
294
|
+
for (const [id, sim] of ranked) {
|
|
295
|
+
if (sim < cutoff) break;
|
|
296
|
+
out.set(id, sim);
|
|
297
|
+
if (out.size >= limit) break;
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
226
302
|
/** 新鲜度:指数衰减 */
|
|
227
303
|
function recencyFactor(createdAt, now, halfLifeDays) {
|
|
228
304
|
if (!Number.isFinite(createdAt)) return 0.5;
|
|
@@ -382,3 +458,104 @@ export function softmaxPick(items, temperature, rng) {
|
|
|
382
458
|
}
|
|
383
459
|
return items[items.length - 1];
|
|
384
460
|
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* 从一句话里认出「他问的是哪段时间」。
|
|
464
|
+
*
|
|
465
|
+
* ── 为什么要它 ──
|
|
466
|
+
*
|
|
467
|
+
* 人问往事,一半靠内容、一半靠时间:
|
|
468
|
+
*
|
|
469
|
+
* 「赛博朋克那事」 内容好找
|
|
470
|
+
* 「上个月那个」 一个专有名词都没有 —— 拿去做内容检索,一个字都撞不上
|
|
471
|
+
*
|
|
472
|
+
* 以前索引里没有时间,日期只活在条目的 created_at 上,检索根本用不着它。
|
|
473
|
+
* 现在索引行前面带日期,这一层才终于有意义。
|
|
474
|
+
*
|
|
475
|
+
* ── 故意做得很土 ──
|
|
476
|
+
*
|
|
477
|
+
* 只认几种说法,认不出就返回 null,**绝不猜**。
|
|
478
|
+
* 猜错的代价比认不出大:认不出只是回到老路(按内容找),
|
|
479
|
+
* 猜错会把真正要的那条挡在时间窗外 —— 而且无声。
|
|
480
|
+
*
|
|
481
|
+
* @param {unknown} query
|
|
482
|
+
* @param {number} [now]
|
|
483
|
+
* @returns {{label: string, from: number, to: number} | null}
|
|
484
|
+
*/
|
|
485
|
+
export function parseTimeHint(query, now = Date.now()) {
|
|
486
|
+
if (typeof query !== 'string' || query === '') return null;
|
|
487
|
+
|
|
488
|
+
const DAY = 86400000;
|
|
489
|
+
const startOfDay = (t) => {
|
|
490
|
+
const d = new Date(t);
|
|
491
|
+
d.setHours(0, 0, 0, 0);
|
|
492
|
+
return d.getTime();
|
|
493
|
+
};
|
|
494
|
+
const today = startOfDay(now);
|
|
495
|
+
const dayRange = (fromDay, toDay) => ({ from: fromDay, to: toDay + DAY - 1 });
|
|
496
|
+
|
|
497
|
+
// 「大前天 / 前天 / 昨天 / 今天」—— 单个自然日
|
|
498
|
+
const single = [
|
|
499
|
+
['大前天', 3, '大前天'],
|
|
500
|
+
['前天', 2, '前天'],
|
|
501
|
+
['昨天', 1, '昨天'],
|
|
502
|
+
['昨儿', 1, '昨天'],
|
|
503
|
+
['今天', 0, '今天'],
|
|
504
|
+
['今儿', 0, '今天'],
|
|
505
|
+
];
|
|
506
|
+
for (const [word, back, label] of single) {
|
|
507
|
+
if (query.includes(word)) {
|
|
508
|
+
const d = today - back * DAY;
|
|
509
|
+
return { label, ...dayRange(d, d) };
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 最近几天
|
|
514
|
+
for (const word of ['前两天', '前几天', '这几天', '这两天']) {
|
|
515
|
+
if (query.includes(word)) return { label: '最近三天', ...dayRange(today - 2 * DAY, today) };
|
|
516
|
+
}
|
|
517
|
+
if (query.includes('最近')) return { label: '最近一周', ...dayRange(today - 6 * DAY, today) };
|
|
518
|
+
|
|
519
|
+
// 自然周(周一为一周之始)
|
|
520
|
+
const weekday = (new Date(today).getDay() + 6) % 7; // 周一 = 0
|
|
521
|
+
if (query.includes('上周') || query.includes('上星期')) {
|
|
522
|
+
const thisMonday = today - weekday * DAY;
|
|
523
|
+
return { label: '上周', ...dayRange(thisMonday - 7 * DAY, thisMonday - DAY) };
|
|
524
|
+
}
|
|
525
|
+
if (query.includes('这周') || query.includes('本星期') || query.includes('本周')) {
|
|
526
|
+
const thisMonday = today - weekday * DAY;
|
|
527
|
+
return { label: '这周', ...dayRange(thisMonday, today) };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// 自然月
|
|
531
|
+
const d0 = new Date(today);
|
|
532
|
+
if (query.includes('上个月') || query.includes('上月')) {
|
|
533
|
+
const from = new Date(d0.getFullYear(), d0.getMonth() - 1, 1).getTime();
|
|
534
|
+
const to = new Date(d0.getFullYear(), d0.getMonth(), 1).getTime() - 1;
|
|
535
|
+
return { label: '上个月', from, to };
|
|
536
|
+
}
|
|
537
|
+
if (query.includes('这个月') || query.includes('本月')) {
|
|
538
|
+
const from = new Date(d0.getFullYear(), d0.getMonth(), 1).getTime();
|
|
539
|
+
return { label: '这个月', ...dayRange(from, today) };
|
|
540
|
+
}
|
|
541
|
+
if (query.includes('去年')) {
|
|
542
|
+
const from = new Date(d0.getFullYear() - 1, 0, 1).getTime();
|
|
543
|
+
const to = new Date(d0.getFullYear(), 0, 1).getTime() - 1;
|
|
544
|
+
return { label: '去年', from, to };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// 「X 月」—— 今年的那个月;如果还没到,就当去年
|
|
548
|
+
const m = query.match(/(\d{1,2})\s*月/);
|
|
549
|
+
if (m !== null) {
|
|
550
|
+
const month = Number(m[1]);
|
|
551
|
+
if (month >= 1 && month <= 12) {
|
|
552
|
+
let year = d0.getFullYear();
|
|
553
|
+
if (month > d0.getMonth() + 1) year -= 1;
|
|
554
|
+
const from = new Date(year, month - 1, 1).getTime();
|
|
555
|
+
const to = new Date(year, month, 1).getTime() - 1;
|
|
556
|
+
return { label: `${month} 月`, from, to };
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return null;
|
|
561
|
+
}
|