Repository navigation
Expand file tree
/
Copy pathindex.js
More file actions
764 lines (721 loc) · 35.5 KB
/
Copy pathindex.js
File metadata and controls
764 lines (721 loc) · 35.5 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
/**
* dsh-study-desk —— 学习工作台(Host 半边)
*
* 三块能力,一个插件:
* 1. 待办墙:Notion 式三列(待办 / 进行中 / 已完成),卡片带科目、标签与投入时长。
* 2. 学习热力图与周报:按「当天实际复习多少分钟」点亮,不是按 token / 会话数。
* 3. 番茄钟:状态存在宿主侧,换面板、刷新、关掉再打开都按绝对截止时间恢复。
* 4. 间隔重复复习队列:卡片带 due / 间隔 / 难度,打分自动推进排期(简化版 SM-2)。
* 5. 每日复盘:一天一条短记录,会进系统提示摘要,让模型接得上「昨天卡在哪」。
* 6. Markdown 导出 / 读回:desk.md 落在数据旁边,能软链进 Obsidian vault 手改再读回。
*
* 对外两个面:
* - HTTP:/api/dsh-study-desk(桌面端 UI 跑在自定义协议上,只有 /api/* 会被转发到宿主,
* 所以插件 API 必须挂在这个前缀下,否则前端 fetch 不到、面板永远空白)。
* - 工具:study_desk(让模型能读写看板、记时长、查统计)。
*
* 数据落在 <DSH_HOME>/study-desk/desk.json,路径不写死在 patch 里(写死别人装完起不来)。
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import {
GRADE_LABEL,
GRADES,
STATUSES,
addTask,
allTags,
dayKey,
daysUntil,
deskDir,
deskFile,
dueQueue,
focusByName,
goalInfo,
gradeTask,
heatmap,
journalRecent,
logSession,
memoryCurve,
minutesByTask,
nextMilestone,
readDesk,
removeTask,
reorderTask,
reviewForecast,
reviewPlan,
reviewStats,
scheduleTask,
setJournal,
streak,
subjectBreakdown,
summary,
todayMinutes,
unscheduleTask,
updateSettings,
updateTask,
weeklyReport,
writeDesk,
} from './desk.js'
import { fromMarkdown, mergeMarkdown, toMarkdown } from './markdown.js'
import { bbdcView, readBbdc, readCache as readBbdcCache } from './bbdc.js'
import {
completeTimer,
countFocusToday,
pauseTimer,
reconcileTimer,
resetTimer,
resumeTimer,
startTimer,
} from './timer.js'
// ---------------------------------------------------------------------------
// defineTool
//
// @deepseek-ai/dsh-tools 是宿主运行时提供的(打包在 app.asar 里),任何 node_modules 里都没有。
// 启动时正常装配的 bundle 能按裸模块名 import 到它;但运行时热挂载进来的 entry 解析基准不同,
// 有可能 import 不到(ERR_MODULE_NOT_FOUND)。所以这里三级兜底:
// 1) 裸模块名(宿主给了自定义解析时就能中)
// 2) 直接从运行时目录里那个打包位置取
// 3) 本地补一份等价实现
//
// 关键在于 defineTool 不是简单透传:它把「友好 schema 描述」编译成 registry-ready 的 JSON Schema
// (parameterSchemaSpecToJsonSchema / valueSchemaSpecToJsonSchema),register 再断言编译结果。
// 少了这层编译,register 会抛
// JsonSchemaError: unsupported JSON schema: schema.type must be one of object/array/string/number/integer/boolean/null
// 所以本地兜底必须老老实实做转换。
// ---------------------------------------------------------------------------
function localValueSchema(spec) {
if (spec === undefined || spec === null) return {}
if (typeof spec !== 'object') return {}
const type = String(spec.type || '')
if (type === 'json' || type === 'any' || type === '') return {} // 无类型约束 = 注解式 schema,register 接受
if (type === 'string' || type === 'number' || type === 'integer' || type === 'boolean' || type === 'null') {
const out = { type }
if (Array.isArray(spec.enum) && spec.enum.length) out.enum = spec.enum.slice()
if (spec.description) out.description = String(spec.description)
return out
}
if (type === 'array') {
const out = { type: 'array', items: localValueSchema(spec.items) }
if (spec.description) out.description = String(spec.description)
return out
}
if (type === 'object') {
const out = { type: 'object', additionalProperties: true }
if (spec.description) out.description = String(spec.description)
return out
}
return {}
}
function localParameters(spec) {
const properties = {}
const required = []
for (const [key, value] of Object.entries(spec || {})) {
properties[key] = localValueSchema(value)
if (value && value.required === true) required.push(key)
}
const out = { type: 'object', properties, additionalProperties: false }
if (required.length) out.required = required
return out
}
function localDefineTool(options) {
const output = options.output || {}
const tool = {
name: options.name,
description: options.description,
parameters: localParameters(options.parameters),
output: {
schema: localValueSchema(output.schema),
render: (args, value) => output.render(args, value),
},
async execute(args, exec) { return options.execute(args, exec) },
}
if (typeof output.presentationMeta === 'function') {
tool.output.presentationMeta = (args, value) => output.presentationMeta(args, value)
}
if (options.timeoutMs !== undefined) tool.timeoutMs = options.timeoutMs
return tool
}
async function resolveDefineTool() {
const candidates = ['@deepseek-ai/dsh-tools']
try {
const resources = process.resourcesPath
if (resources) {
const base = pathToFileURL(String(resources).replace(/[\\/]+$/, '') + '/').href
candidates.push(base + 'app.asar/dsh/node_modules/@deepseek-ai/dsh-tools/lib/index.js')
candidates.push(base + 'app.asar.unpacked/dsh/node_modules/@deepseek-ai/dsh-tools/lib/index.js')
}
} catch { /* 非 Electron 环境没有 resourcesPath */ }
for (const spec of candidates) {
try {
const mod = await import(spec)
if (mod && typeof mod.defineTool === 'function') return { defineTool: mod.defineTool, from: spec }
} catch { /* 试下一个 */ }
}
return { defineTool: localDefineTool, from: 'local' }
}
const resolved = await resolveDefineTool()
const defineTool = resolved.defineTool
if (resolved.from === 'local') {
console.log('[dsh-study-desk] 没取到宿主的 @deepseek-ai/dsh-tools,改用本地等价的 defineTool(schema 转换行为一致)')
}
export const name = 'dsh-study-desk'
export const inject = ['tools', 'webServer']
const API_ROUTE = '/api/dsh-study-desk'
const pluginVersion = (() => {
try {
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'))
return String(pkg.version || '').trim()
} catch { return '' }
})()
// ---------------------------------------------------------------------------
// 串行化的状态访问:客户端会 POST,模型会调工具,两边可能同时改同一份 JSON。
// 用一条 promise 链把「读-改-写」串起来,避免后写的把先写的覆盖掉。
// ---------------------------------------------------------------------------
let chain = Promise.resolve()
function serial(fn) {
const run = chain.then(fn, fn)
chain = run.then(() => undefined, () => undefined)
return run
}
/**
* 在串行临界区里读状态 → 顺手结掉走完的计时器 → 跑 mutator → 需要时落盘。
* mutator 为 null 时是纯读,但计时器结账导致的改动仍会写回。
*/
function withDesk(mutator) {
return serial(() => {
const state = readDesk()
const reconciled = reconcileTimer(state)
let result
if (mutator) result = mutator(state)
if (mutator || reconciled) {
const saved = writeDesk(state)
return { state: saved, result }
}
return { state, result }
})
}
function snapshot(state) {
const byTask = minutesByTask(state)
const minutes = {}
for (const [id, m] of byTask) minutes[id] = m
const now = Date.now()
const queue = dueQueue(state, now)
return {
ok: true,
now,
state,
minutes,
todayMinutes: todayMinutes(state, now),
streak: streak(state, now),
focusRoundsToday: countFocusToday(state),
// 派生数据由 desk.js 单点算好:客户端那份是浏览器里的另一套代码,不该再实现一遍排序与聚合
review: reviewStats(state, now),
// 曲线也在这里算:一次快照最多 60 条,每条 31 个点的整数数组,几十 KB 以内,
// 换来的是「浏览器端不重复实现衰减模型」——算法只有 desk.js 一个出处。
due: queue.slice(0, 60).map((t) => ({ ...t, curve: memoryCurve(t.review, now) })),
forecast: reviewForecast(state, 7, now),
// 饼图:按「这一段的名字」聚合,今天和近 30 天各算一份,客户端只画
focusPie: { today: focusByName(state, 1, now), month: focusByName(state, 30, now) },
plan: reviewPlan(state, now, 3),
tags: allTags(state),
weekly: weeklyReport(state, 8, now),
journal: journalRecent(state, now, 14),
goal: goalInfo(state, now),
deskFile: deskFile(),
markdownFile: markdownFile(),
version: pluginVersion,
// 不背单词打卡:这里只发缓存(一次快照不该去拷 1.3GB 的虚拟机磁盘)。
// 真要刷新走 op=bbdc.refresh,采完写缓存,下一次快照就带上了。
// 过一遍 bbdcView:老版本缓存是另一种形状,这里统一成面板认的那份。
bbdc: bbdcView(readBbdcCache()),
}
}
// ---------------------------------------------------------------------------
// HTTP
// ---------------------------------------------------------------------------
function sendJson(res, code, payload) {
const body = JSON.stringify(payload)
res.writeHead(code, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store',
})
res.end(body)
}
function readBody(req) {
return new Promise((resolve, reject) => {
let raw = ''
req.on('data', (chunk) => {
raw += chunk
if (raw.length > 2_000_000) {
reject(new Error('请求体过大'))
req.destroy()
}
})
req.on('end', () => resolve(raw))
req.on('error', reject)
})
}
/** 导出的 Markdown 落在 desk.json 旁边:软链进 Obsidian vault 就能当笔记用。 */
function markdownFile() {
return join(deskDir(), 'desk.md')
}
function exportMarkdown(state) {
const now = Date.now()
const text = toMarkdown(state, now, {
minutesByTask: minutesByTask(state),
reviewStats: reviewStats(state, now),
weekly: weeklyReport(state, 8, now),
goal: goalInfo(state, now),
})
mkdirSync(deskDir(), { recursive: true })
writeFileSync(markdownFile(), text)
return { file: markdownFile(), bytes: Buffer.byteLength(text), markdown: text }
}
const MUTATIONS = {
'task.add': (state, p) => addTask(state, p),
'task.update': (state, p) => updateTask(state, p.id || p.title, p.patch || p),
'task.move': (state, p) => reorderTask(state, p.id || p.title, p.status, p.beforeId),
'task.delete': (state, p) => removeTask(state, p.id || p.title),
'session.log': (state, p) => logSession(state, p),
'settings.update': (state, p) => updateSettings(state, p.patch || p),
'review.grade': (state, p) => gradeTask(state, p.id || p.title, p.grade),
'review.schedule': (state, p) => scheduleTask(state, p.id || p.title, p.due),
'review.unschedule': (state, p) => unscheduleTask(state, p.id || p.title),
'journal.set': (state, p) => setJournal(state, p.text, p.date),
'markdown.export': (state) => exportMarkdown(state),
'markdown.import': (state, p) => mergeMarkdown(state, fromMarkdown(String(p.markdown || p.text || ''))),
'timer.start': (state, p) => startTimer(state, p),
'timer.pause': (state) => pauseTimer(state),
'timer.resume': (state) => resumeTimer(state),
'timer.reset': (state) => resetTimer(state),
'timer.complete': (state) => completeTimer(state),
}
// ---------------------------------------------------------------------------
// 客户端诊断回传:浏览器里没有可读的控制台,就把渲染/环境异常写到磁盘上
// 落在 <DSH_HOME>/study-desk/client-diag.json,只保留最近 200 条
// ---------------------------------------------------------------------------
function diagFile() {
return join(deskDir(), 'client-diag.json')
}
function writeDiag(payload) {
const file = diagFile()
let report = { updatedAt: 0, entries: [] }
try {
const parsed = JSON.parse(readFileSync(file, 'utf8'))
if (parsed && Array.isArray(parsed.entries)) report = parsed
} catch (error) { /* 文件不存在或坏了就重开一份 */ }
const { op, ...entry } = payload
report.entries.push({ ...entry, recordedAt: Date.now() })
if (report.entries.length > 200) report.entries = report.entries.slice(-200)
report.updatedAt = Date.now()
mkdirSync(deskDir(), { recursive: true })
writeFileSync(file, JSON.stringify(report, null, 2))
return report
}
async function handleApi(req, res) {
try {
const url = new URL(req.url || '/', 'http://127.0.0.1')
if (req.method === 'GET') {
const { state } = await withDesk(null)
sendJson(res, 200, snapshot(state))
return
}
const raw = await readBody(req)
let payload = {}
try { payload = raw ? JSON.parse(raw) : {} } catch { throw new Error('请求体不是合法 JSON') }
const op = String(payload.op || '')
if (op === 'state') {
const { state } = await withDesk(null)
sendJson(res, 200, snapshot(state))
return
}
if (op === 'diag.report') {
// 客户端诊断:不碰 desk.json,只追加到 client-diag.json
const report = writeDiag(payload)
sendJson(res, 200, { ok: true, at: Date.now(), entries: report.entries.length })
return
}
if (op === 'bbdc.refresh') {
// 采集要拷虚拟机磁盘、还要打几十个 HTTP 请求,所以不放在 withDesk 串行区里,
// 更不放在 snapshot 里——面板点「刷新」或轮询到点了才走这里。
const data = await readBbdc({ force: true, offline: !!payload.offline })
const { state } = await withDesk(null)
sendJson(res, 200, { ...snapshot(state), result: { ok: !!data.ok, error: data.error || null } })
return
}
const mutate = MUTATIONS[op]
if (!mutate) throw new Error('未知操作:' + op)
const { state, result } = await withDesk((s) => mutate(s, payload))
sendJson(res, 200, { ...snapshot(state), result: result ?? null })
} catch (error) {
sendJson(res, 400, { ok: false, error: error && error.message ? error.message : String(error) })
}
}
// ---------------------------------------------------------------------------
// 给模型看的摘要
// ---------------------------------------------------------------------------
function fmtMinutes(min) {
const n = Math.max(0, Math.round(Number(min) || 0))
if (n < 60) return n + ' 分钟'
const h = Math.floor(n / 60)
const m = n % 60
return m ? `${h} 小时 ${m} 分钟` : `${h} 小时`
}
function boardText(state) {
const s = summary(state)
const lines = []
const goal = s.goal
lines.push(`学习工作台(study_desk 工具可读写;整页看板在左侧栏「学习工作台」图标):`)
if (goal.date && goal.days !== null) {
const span = goal.end && goal.end !== goal.date ? `(${goal.date}~${goal.end})` : `(${goal.date})`
const wording = goal.days > 0 ? `距 ${goal.label || '目标日期'}${span}还有 ${goal.days} 天`
: goal.days === 0 ? `${goal.label || '目标'}${span}就是今天` : `${goal.label || '目标'}${span}已过 ${-goal.days} 天`
lines.push(`- ${wording}${goal.note ? `,${goal.note}` : ''}`)
}
const ms = nextMilestone(state)
if (ms) lines.push(`- 最近节点:${ms.label} ${ms.start}${ms.end !== ms.start ? '~' + ms.end : ''}(${ms.text}${ms.approx ? ',日期按往年惯例估' : ''})`)
lines.push(`- 今日专注 ${fmtMinutes(s.today.minutes)} / 目标 ${fmtMinutes(s.today.goal)},连续打卡 ${s.streak} 天`)
if (s.doing.length) {
lines.push('- 进行中 ' + s.counts.doing + ':' + s.doing.map((t) => `${t.title}${t.minutes ? `(已投入 ${fmtMinutes(t.minutes)})` : ''}`).join(' | '))
} else {
lines.push('- 进行中 0:现在没有正在做的卡')
}
if (s.todo.length) {
lines.push('- 待办 ' + s.counts.todo + ':' + s.todo.slice(0, 8).map((t) => t.pinned ? '★' + t.title : t.title).join(' | '))
} else {
lines.push('- 待办 0')
}
if (s.doneToday.length) lines.push('- 今天已完成:' + s.doneToday.map((t) => t.title).join(' | '))
if (s.review.due) {
const due = s.due.map((t) => `${t.title}${t.overdueDays > 0 ? `(逾期 ${t.overdueDays} 天)` : ''}`).join(' | ')
lines.push(`- 今日待复习 ${s.review.due} 张(其中逾期 ${s.review.overdue}),今天已经复习 ${s.review.doneToday} 次:${due}`)
lines.push(' 复习完用 study_desk 的 action=review、grade=again|good|easy 打分,排期会自动推进')
}
if (s.journal.today) lines.push('- 今天的复盘:' + s.journal.today)
else if (s.journal.yesterday) lines.push('- 昨天的复盘:' + s.journal.yesterday)
return lines.join('\n')
}
function statsText(state, days = 30) {
const s = summary(state)
const hm = heatmap(state, Math.max(1, Math.round(days / 7)), Date.now())
const brk = subjectBreakdown(state, days)
const lines = []
lines.push(`近 ${days} 天:共专注 ${fmtMinutes(hm.total)},有 ${hm.activeDays} 天学习,当前连续 ${s.streak} 天`)
if (brk.length) lines.push('按科目:' + brk.map((b) => `${b.label} ${fmtMinutes(b.minutes)}`).join(' | '))
else lines.push('按科目:还没有记录')
const recent = state.sessions.filter((x) => x.kind === 'focus').slice(-10).reverse()
if (recent.length) {
lines.push('最近的记录:' + recent.map((x) => `${new Date(x.at).toLocaleDateString('zh-CN')} ${x.title || x.subject || '未命名'} ${x.minutes}分钟`).join(';'))
}
return lines.join('\n')
}
/** 今日复习队列的播报(模型和用户看的是同一份排期)。 */
function reviewText(state, now = Date.now()) {
const st = reviewStats(state, now)
const queue = dueQueue(state, now)
const today = dayKey(now)
const lines = []
lines.push(`复习:排期 ${st.scheduled} 张 · 今天该过 ${st.due} 张(其中逾期 ${st.overdue})· 今天已经打过分 ${st.doneToday} 次 · 本周共 ${st.weekDone} 次`)
if (queue.length) {
lines.push('队列:' + queue.slice(0, 10).map((t) => {
const r = t.review
const late = r.due < today ? `逾期 ${-daysUntil(r.due, now)} 天` : '今天到期'
return `${t.title}[${t.subject || '未归类'}·${late}·第 ${r.reps + 1} 遍]`
}).join(' | '))
} else {
lines.push('队列:今天没有到期的卡。')
}
return lines.join('\n')
}
/** 周报文本。 */
function weeklyText(state, weeks = 8, now = Date.now()) {
const rep = weeklyReport(state, weeks, now)
const lines = [`最近 ${rep.weeks.length} 周(每周从周一算起):`]
for (const w of rep.weeks) {
const delta = w.deltaPct === null ? '' : `(${w.deltaPct > 0 ? '+' : ''}${w.deltaPct}%)`
const subj = w.bySubject.slice(0, 3).map((b) => `${b.label} ${fmtMinutes(b.minutes)}`).join('、')
lines.push(`- ${w.key}${w.current ? '(本周)' : ''}:${fmtMinutes(w.minutes)}${delta} · 学了 ${w.daysActive} 天 · 复习 ${w.reviews} 次${subj ? ` · ${subj}` : ''}`)
}
const goal = rep.goalWeekly
const current = rep.weeks[rep.weeks.length - 1]
if (current) {
lines.push(current.minutes >= goal
? `本周已达周目标(${fmtMinutes(goal)})。`
: `本周还差 ${fmtMinutes(goal - current.minutes)} 到周目标(${fmtMinutes(goal)})。`)
}
return lines.join('\n')
}
/**
* 不背单词打卡记录的纯文本摘要。数据形状由 bbdc.js 的 bbdcView 统一,
* 这里只负责把数字讲成人话(模型侧和工具侧共用同一段文案)。
*/
function bbdcText(data) {
if (!data) return '还没采集过不背单词的打卡数据。用 action=bbdc + refresh=true 采一次(需要 Androws 里登录过不背单词)。'
const lines = []
const when = data.fetchedAt ? new Date(data.fetchedAt) : null
const stamp = when
? `${when.getFullYear()}-${String(when.getMonth() + 1).padStart(2, '0')}-${String(when.getDate()).padStart(2, '0')} ${String(when.getHours()).padStart(2, '0')}:${String(when.getMinutes()).padStart(2, '0')}`
: '未知'
lines.push(`不背单词 · 打卡记录(采集于 ${stamp}${data.source && data.source.length ? `,来源 ${data.source.join(' + ')}` : ''})`)
if (!data.ok) {
lines.push(`- 这次没采到:${data.error || '未知原因'}`)
return lines.join('\n')
}
const u = data.user
if (u) {
lines.push(`- 账号:${u.nickname || '(未命名)'}${u.uid ? `(uid ${u.uid})` : ''}`)
lines.push(`- 连续签到 ${u.continuousDays || 0} 天 · 累计签到 ${u.totalDays || 0} 天`)
}
if (data.checkIn && data.checkIn.lastDate) lines.push(`- 最近一次打卡:${data.checkIn.lastDate}`)
const t = data.today
if (t) lines.push(`- 今日:学习 ${t.words || 0} 词 · 时长 ${fmtMinutes(t.durationMin || 0)}`)
const tot = data.total
if (tot) lines.push(`- 累计时长:${fmtMinutes(tot.durationMin || 0)}${tot.durationSec ? `(${tot.durationSec} 秒)` : ''}`)
// 别把滞后几小时的本地快照当成实时数报给她:这个标记只在「本地落后且云端没兜住」时为真
if (data.durationMightBeStale) {
lines.push(` (以上两项时长出自 App 本地,本地只刷到 ${data.durationAsOf || '未知'},可能偏旧,报数时请带上这一句)`)
}
const c = data.cloud
if (c && c.ok && c.totalWords !== null && c.totalWords !== undefined) {
lines.push(`- 累计学习 ${c.totalWords} 词 · 累计复习 ${c.totalReviews || 0} 次(近 ${c.months || 0} 个月)`)
} else if (c && c.error) {
lines.push(`- 累计词数没拿到:${c.error}`)
}
if (data.book) lines.push(`- 正在学:${data.book.name || ''}${data.book.wordCount ? `(${data.book.wordCount} 词)` : ''}`)
if (data.dailyPlan) lines.push(`- 今日计划:${data.dailyPlan.wordCount || 0} 词${data.dailyPlan.phraseCount ? ` + ${data.dailyPlan.phraseCount} 词组` : ''}`)
const signed = (data.calendar && data.calendar.signedDates) || []
if (signed.length) {
const month = String(data.calendar.month || '').slice(0, 7)
const inMonth = signed.filter((d) => d.startsWith(month))
lines.push(`- 本月打卡 ${inMonth.length} 天:${inMonth.join('、') || '(无)'}`)
lines.push(`- 全部打卡共 ${signed.length} 天(${signed[0]} ~ ${signed[signed.length - 1]})`)
}
const week = (data.checkIn && data.checkIn.week) || []
if (week.length) {
lines.push(`- 本周签到:${week.map((x) => `${x.date} ${x.signIn ? '✔' : '—'}`).join(' | ')}`)
}
return lines.join('\n')
}
// ---------------------------------------------------------------------------
// 工具
// ---------------------------------------------------------------------------
const STUDY_DESK_ACTIONS = ['board', 'add', 'move', 'update', 'remove', 'stats', 'focus', 'review', 'journal', 'report', 'bbdc']
function makeTool() {
return defineTool({
name: 'study_desk',
description:
'学习工作台:读写用户的待办墙(待办/进行中/已完成三列)、记录专注时长、查统计与热力图、起停番茄钟、'
+ '管间隔重复复习队列、写每日复盘、出周报。用户说「今天要做什么」「把 X 加到进行中」「这件做完了」'
+ '"我今天学了多久""开始 25 分钟专注""今天该复习什么""这个我忘了/记住了""帮我记一下今天的复盘""最近几周学得怎么样"'
+ '这类话时用它,而不是自己另开一份待办清单。倒计时的目标与节点由用户在设置页自己填(考试、答辩、投稿都可以)。action 取值:'
+ 'board=看当前看板全貌;add=新建卡;move=卡片换列/排序;update=改卡片字段;remove=删卡;'
+ 'focus=起停番茄钟(off=true 停;minutes 控制时长;title 给这段命名,饼图按名字聚合);stats=查最近 N 天统计;'
+ 'review=看今日复习队列(带 id/title + grade 时改为打分);journal=看最近复盘(带 text 时写入);'
+ 'report=出最近若干周的复习周报;bbdc=看不背单词的打卡记录(默认读缓存,refresh=true 才重新采集)。',
parameters: {
action: { type: 'string', enum: STUDY_DESK_ACTIONS, description: '要做的操作' },
title: { type: 'string', description: '卡片标题(add 必填;move/update/remove/review 可用它代替 id 定位;focus 时它给这段番茄钟命名——饼图按名字聚合,≤24 字,如「864 论述练习」;不挂卡也不命名时记为「专注(未命名)」)' },
id: { type: 'string', description: '卡片 id(比 title 精确)' },
status: { type: 'string', enum: STATUSES, description: 'move/update 的目标列:todo 待办 / doing 进行中 / done 已完成' },
subject: { type: 'string', description: '科目,如「631 公共管理」「864」「英语」「政治」' },
note: { type: 'string', description: '备注' },
tags: { type: 'string', description: '卡片标签,逗号分隔(如「名词解释, 错题」),add/update 用' },
estimateMin: { type: 'number', description: '预计要花多少分钟' },
pinned: { type: 'boolean', description: '是否置顶(add/update)' },
review: { type: 'boolean', description: 'add 时给 true = 直接把这张卡拉进间隔重复复习循环' },
beforeId: { type: 'string', description: 'move 时插到这张卡之前;省略则排到该列末尾' },
minutes: { type: 'number', description: 'focus 的时长(分钟);stats 时表示看最近多少天(默认 30)' },
task: { type: 'string', description: 'focus 时把专注挂到哪张卡上(id 或标题)' },
off: { type: 'boolean', description: 'focus 且 off=true 时停止番茄钟' },
grade: { type: 'string', enum: GRADES, description: 'review 的打分:again 忘了 / good 记得 / easy 很简单' },
due: { type: 'string', description: '排期日期 YYYY-MM-DD(review 排期用;省略=今天)' },
schedule: { type: 'boolean', description: 'review:true 拉进复习循环,false 移出;不传且无 grade 时只是看队列' },
text: { type: 'string', description: 'journal 的复盘正文;空串表示删掉那天的记录' },
date: { type: 'string', description: 'journal 的日期 YYYY-MM-DD,省略=今天' },
weeks: { type: 'number', description: 'report 看最近几周(默认 8)' },
refresh: { type: 'boolean', description: 'bbdc:true = 重新去不背单词采集一次(要拷虚拟机磁盘、约几秒);省略则读缓存' },
offline: { type: 'boolean', description: 'bbdc:true = 只读本机 App 数据,不联网查服务端' },
},
output: {
schema: { type: 'json' },
render: (_args, value) => [{ type: 'text', text: String((value && value.message) || '') }],
},
async execute(args) {
const action = String(args.action || 'board')
if (!STUDY_DESK_ACTIONS.includes(action)) return { ok: false, message: '未知 action:' + action }
try {
if (action === 'board') {
const { state } = await withDesk(null)
return { ok: true, message: boardText(state) }
}
if (action === 'stats') {
const { state } = await withDesk(null)
return { ok: true, message: statsText(state, Math.max(1, Math.round(Number(args.minutes) || 30))) }
}
if (action === 'add') {
const { state } = await withDesk((s) => addTask(s, {
title: args.title,
subject: args.subject,
note: args.note,
status: args.status || 'todo',
estimateMin: args.estimateMin,
pinned: args.pinned,
tags: args.tags,
review: args.review,
}))
const s = summary(state)
return {
ok: true,
message: `已加入「${args.status === 'doing' ? '进行中' : args.status === 'done' ? '已完成' : '待办'}」:${String(args.title || '').trim()}。当前 待办 ${s.counts.todo} / 进行中 ${s.counts.doing} / 已完成 ${s.counts.done}。`,
}
}
if (action === 'move') {
const ref = args.id || args.title
if (!ref) return { ok: false, message: 'move 需要 id 或 title' }
const { state, result } = await withDesk((s) => reorderTask(s, (s.tasks.find((t) => t.id === ref || t.title === ref) || {}).id || ref, args.status, args.beforeId))
const s = summary(state)
return {
ok: true,
message: `「${result.title}」已移到「${result.status === 'doing' ? '进行中' : result.status === 'done' ? '已完成' : '待办'}」。当前 待办 ${s.counts.todo} / 进行中 ${s.counts.doing} / 已完成 ${s.counts.done}。`,
}
}
if (action === 'update') {
const ref = args.id || args.title
if (!ref) return { ok: false, message: 'update 需要 id 或 title' }
const patch = {}
for (const k of ['title', 'note', 'subject', 'estimateMin', 'pinned', 'status', 'tags']) {
if (args[k] !== undefined) patch[k] = args[k]
}
if (args.review !== undefined) patch.review = !!args.review
const { result } = await withDesk((s) => updateTask(s, ref, patch))
return { ok: true, message: `已更新「${result.title}」。` }
}
if (action === 'remove') {
const ref = args.id || args.title
if (!ref) return { ok: false, message: 'remove 需要 id 或 title' }
const { result } = await withDesk((s) => removeTask(s, ref))
return { ok: true, message: `已删除「${result.title}」。` }
}
if (action === 'focus') {
if (args.off) {
const { state } = await withDesk((s) => resetTimer(s))
return { ok: true, message: `番茄钟已停。今天已专注 ${fmtMinutes(todayMinutes(state))}。` }
}
const taskRef = args.task
const named = String(args.title || '').trim().slice(0, 24)
const { state, result } = await withDesk((s) => startTimer(s, {
minutes: args.minutes,
taskId: taskRef,
name: named || undefined,
}))
const s = summary(state)
return {
ok: true,
message: `开始 ${result.minutes} 分钟${result.kind === 'focus' ? '专注' : '休息'}${result.title ? `(${result.title})` : ''}。今天已专注 ${fmtMinutes(s.today.minutes)} / 目标 ${fmtMinutes(s.today.goal)}。`,
}
}
if (action === 'review') {
const ref = args.id || args.title
if (ref && args.grade) {
if (!GRADES.includes(args.grade)) return { ok: false, message: 'grade 只能是 again / good / easy' }
const { state, result } = await withDesk((s) => gradeTask(s, ref, args.grade))
const r = result.task.review
const st = reviewStats(state)
return {
ok: true,
message: `已给「${result.task.title}」打分(${GRADE_LABEL[args.grade]}):下次 ${r.due},间隔 ${r.intervalDays} 天,这是第 ${r.reps + 1} 遍。今天还剩 ${st.due} 张没复习。`,
}
}
if (ref && args.schedule === false) {
const { result } = await withDesk((s) => unscheduleTask(s, ref))
return { ok: true, message: `已把「${result.title}」移出复习循环。` }
}
if (ref && (args.schedule === true || args.due)) {
const { result } = await withDesk((s) => scheduleTask(s, ref, args.due))
return { ok: true, message: `已把「${result.title}」拉进复习循环,下次 ${result.review.due} 到期。` }
}
const { state } = await withDesk(null)
return { ok: true, message: reviewText(state) }
}
if (action === 'journal') {
if (args.text !== undefined) {
const { result } = await withDesk((s) => setJournal(s, args.text, args.date))
return { ok: true, message: result.text ? `已记下 ${result.date} 的复盘。` : `已清掉 ${result.date} 的复盘。` }
}
const { state } = await withDesk(null)
const recent = journalRecent(state, Date.now(), 7)
if (!recent.length) return { ok: true, message: '还没有写过复盘。用 action=journal + text=… 记一条今天卡在哪。' }
return { ok: true, message: '最近的复盘:\n' + recent.map((x) => `- ${x.date}:${String(x.text).replace(/\n/g, ' ')}`).join('\n') }
}
if (action === 'report') {
const { state } = await withDesk(null)
return { ok: true, message: weeklyText(state, Math.max(1, Math.min(52, Math.round(Number(args.weeks) || 8)))) }
}
if (action === 'bbdc') {
// 默认读缓存(便宜);带 refresh=true 才真去采集一次
const data = args.refresh
? await readBbdc({ force: true, offline: !!args.offline })
: bbdcView(readBbdcCache())
return { ok: true, message: bbdcText(data) }
}
return { ok: false, message: '未处理:' + action }
} catch (error) {
return { ok: false, message: (error && error.message) ? error.message : String(error) }
}
},
})
}
// ---------------------------------------------------------------------------
// apply
// ---------------------------------------------------------------------------
export function apply(ctx, config) {
try {
const seed = readDesk()
if (!seed.sessions.length && !seed.tasks.length) writeDesk(seed)
} catch (error) {
console.error('[dsh-study-desk] 数据文件初始化失败:', error && error.message)
}
const webServer = ctx.get('webServer') || ctx.webServer
if (webServer && typeof webServer.register === 'function') {
ctx.effect(
() => webServer.register({ kind: 'prefix', path: API_ROUTE, handler: (req, res) => handleApi(req, res) }),
'dsh-study-desk: api',
)
} else {
console.error('[dsh-study-desk] 拿不到 webServer,面板将无法读写数据')
}
const tools = ctx.get('tools') || ctx.tools
if (tools && typeof tools.register === 'function') {
ctx.effect(() => tools.register(makeTool()), 'dsh-study-desk: tool')
}
ctx.on('system-prompt/assemble', async (assembly, _context, next) => {
const assembled = await next()
try {
const state = readDesk()
if (state.settings.injectPrompt !== false) {
assembled.sections.push({ name: 'study-desk:today', text: boardText(state) })
}
} catch { /* 注入失败不影响对话 */ }
return assembled
})
const timer = {
start: (input) => withDesk((s) => startTimer(s, input)),
pause: () => withDesk((s) => pauseTimer(s)),
resume: () => withDesk((s) => resumeTimer(s)),
reset: () => withDesk((s) => resetTimer(s)),
complete: () => withDesk((s) => completeTimer(s)),
reconcile: () => withDesk(null),
state: () => withDesk(null),
}
// 给别的插件留一个入口(本插件自己不用,纯便利):注册服务要用 ctx.provide,
// ctx.set 只能覆盖已经 provide 过的服务,直接 set 会抛
// Error: cannot set property "studyDesk" without provide
ctx.effect(
() => ctx.provide('studyDesk', { timer, read: () => readDesk(), file: deskFile() }),
'dsh-study-desk: service',
)
console.log(`[dsh-study-desk] v${pluginVersion} 已加载(数据:${deskFile()})`)
}
export { boardText, statsText, reviewText, weeklyText, bbdcText, nextMilestone }