dsh-screenshot-capture 0.2.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/LICENSE +21 -0
- package/README.md +170 -0
- package/client.js +261 -0
- package/clipboard.mjs +146 -0
- package/config.example.json +21 -0
- package/config.mjs +79 -0
- package/cordis.patch.yml +5 -0
- package/core.mjs +65 -0
- package/index.mjs +272 -0
- package/ocr.mjs +55 -0
- package/organize.mjs +155 -0
- package/package.json +80 -0
- package/screenshots.json +5 -0
- package/scripts/clip-dialog.ps1 +200 -0
- package/storage.mjs +126 -0
package/config.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
6
|
+
enabled: true,
|
|
7
|
+
pollIntervalMs: 200,
|
|
8
|
+
cooldownMs: 2000,
|
|
9
|
+
// 默认库路径留空:未配置时采集功能自动停用并告警,绝不创建无关目录。
|
|
10
|
+
// 首次使用请在 Web 设置 / config.json 里配置你自己的 Obsidian 库路径。
|
|
11
|
+
vaultPath: "",
|
|
12
|
+
inboxFolder: "收件箱",
|
|
13
|
+
attachmentsFolder: "attachments",
|
|
14
|
+
knowledgeFolder: "知识库",
|
|
15
|
+
summaryFolder: "总结",
|
|
16
|
+
archiveFolder: "归档",
|
|
17
|
+
recycleFolder: "回收站",
|
|
18
|
+
ocr: {
|
|
19
|
+
mode: "qwen", // "qwen" | "off"
|
|
20
|
+
model: "qwen-vl-plus",
|
|
21
|
+
apiKey: "",
|
|
22
|
+
endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
|
|
23
|
+
prompt: "请识别图片中的全部文字内容,原样输出;数学公式用 LaTeX 输出。只输出识别结果,不要任何解释。",
|
|
24
|
+
},
|
|
25
|
+
dialog: { offsetX: 16, offsetY: 16, previewMaxWidth: 320 },
|
|
26
|
+
organize: { addBacklinks: true },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export function configDir() {
|
|
30
|
+
return join(homedir(), ".dsh-screenshot-capture");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function configPath() {
|
|
34
|
+
return join(configDir(), "config.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 合并默认配置 + 配置文件 + 运行时入参(DSh 插件配置覆盖最高) */
|
|
38
|
+
export function resolveConfig(input = {}) {
|
|
39
|
+
let file = {};
|
|
40
|
+
try {
|
|
41
|
+
if (existsSync(configPath())) {
|
|
42
|
+
file = JSON.parse(readFileSync(configPath(), "utf8"));
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
file = {};
|
|
46
|
+
}
|
|
47
|
+
const merged = mergeDeep(structuredClone(DEFAULT_CONFIG), file);
|
|
48
|
+
return mergeDeep(merged, input);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function saveConfig(config) {
|
|
52
|
+
mkdirSync(configDir(), { recursive: true });
|
|
53
|
+
writeFileSync(configPath(), JSON.stringify(config, null, 2), "utf8");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mergeDeep(base, patch) {
|
|
57
|
+
if (patch === undefined || patch === null) return base;
|
|
58
|
+
if (typeof patch !== "object" || Array.isArray(patch)) return patch;
|
|
59
|
+
const out = { ...base };
|
|
60
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
61
|
+
out[k] = typeof v === "object" && v !== null && !Array.isArray(v) && typeof out[k] === "object" && out[k] !== null
|
|
62
|
+
? mergeDeep(out[k], v)
|
|
63
|
+
: v;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ensureVault(config) {
|
|
69
|
+
const folders = [
|
|
70
|
+
join(config.vaultPath, config.inboxFolder),
|
|
71
|
+
join(config.vaultPath, config.inboxFolder, config.attachmentsFolder),
|
|
72
|
+
join(config.vaultPath, config.knowledgeFolder),
|
|
73
|
+
join(config.vaultPath, config.summaryFolder),
|
|
74
|
+
join(config.vaultPath, config.archiveFolder),
|
|
75
|
+
join(config.vaultPath, config.recycleFolder),
|
|
76
|
+
];
|
|
77
|
+
for (const dir of folders) mkdirSync(dir, { recursive: true });
|
|
78
|
+
return folders;
|
|
79
|
+
}
|
package/cordis.patch.yml
ADDED
package/core.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { unlinkSync } from "node:fs";
|
|
2
|
+
import { ocrImage } from "./ocr.mjs";
|
|
3
|
+
import {
|
|
4
|
+
appendEntry,
|
|
5
|
+
KIND,
|
|
6
|
+
saveAttachment,
|
|
7
|
+
stampParts,
|
|
8
|
+
updateEntryOcr,
|
|
9
|
+
} from "./storage.mjs";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 悬浮窗选择处理(插件与独立测试共用):
|
|
13
|
+
* copy → 剪贴板原样不动,仅清理临时图
|
|
14
|
+
* doc → 图片入附件 + 当天笔记立即追加(#文档,OCR 占位)→ 后台 OCR → 回填文字
|
|
15
|
+
* img → 图片入附件 + 当天笔记追加(#图片,无 OCR)
|
|
16
|
+
* note/isKey 来自悬浮窗:用户注释 + 「重点」标记(标题一 # **重点**)。
|
|
17
|
+
*/
|
|
18
|
+
export async function handleChoice(config, { action, path, note = "", isKey = false }) {
|
|
19
|
+
const now = new Date();
|
|
20
|
+
const { date, fileStamp, time } = stampParts(now);
|
|
21
|
+
|
|
22
|
+
if (action === "copy") {
|
|
23
|
+
try { unlinkSync(path); } catch { /* ignore */ }
|
|
24
|
+
return { action, date, time, note: "已忽略(剪贴板原样保留,可直接粘贴)" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const kind = action === "doc" ? KIND.DOC : KIND.IMG;
|
|
28
|
+
const imageRel = saveAttachment(config, path, date, fileStamp, kind);
|
|
29
|
+
|
|
30
|
+
if (kind === KIND.IMG) {
|
|
31
|
+
try { unlinkSync(path); } catch { /* ignore */ }
|
|
32
|
+
const notePath = appendEntry(config, { date, time, kind, imageRel, note, isKey });
|
|
33
|
+
return { action, date, time, imageRel, notePath, note: `已存图片:${imageRel}` };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 文档:先占位落笔记,后台 OCR 后回填
|
|
37
|
+
const notePath = appendEntry(config, { date, time, kind, imageRel, ocrText: null, note, isKey });
|
|
38
|
+
let ocrText = "";
|
|
39
|
+
let ocrError = "";
|
|
40
|
+
try {
|
|
41
|
+
ocrText = await ocrImage(config, path);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
ocrError = err.message;
|
|
44
|
+
} finally {
|
|
45
|
+
try { unlinkSync(path); } catch { /* ignore */ }
|
|
46
|
+
}
|
|
47
|
+
if (!ocrError) {
|
|
48
|
+
updateEntryOcr(config, { date, time, imageRel, ocrText });
|
|
49
|
+
} else {
|
|
50
|
+
// OCR 失败时也把占位替换为失败说明
|
|
51
|
+
updateEntryOcr(config, { date, time, imageRel, ocrText: `(识别失败:${ocrError})` });
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
action,
|
|
55
|
+
date,
|
|
56
|
+
time,
|
|
57
|
+
imageRel,
|
|
58
|
+
notePath,
|
|
59
|
+
ocrText: ocrText || null,
|
|
60
|
+
ocrError: ocrError || null,
|
|
61
|
+
note: ocrError
|
|
62
|
+
? `图片已存,但 OCR 失败:${ocrError}`
|
|
63
|
+
: `已存文档(含 OCR 文字,${ocrText.length} 字)`,
|
|
64
|
+
};
|
|
65
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
2
|
+
import z from "@deepseek-ai/schemastery";
|
|
3
|
+
import { ClipboardWatcher } from "./clipboard.mjs";
|
|
4
|
+
import { DEFAULT_CONFIG, ensureVault, resolveConfig } from "./config.mjs";
|
|
5
|
+
import { handleChoice } from "./core.mjs";
|
|
6
|
+
import { listEntriesForDate, organizeDay } from "./organize.mjs";
|
|
7
|
+
import { readDailyNote, todayString } from "./storage.mjs";
|
|
8
|
+
|
|
9
|
+
export const name = "dsh-screenshot-capture";
|
|
10
|
+
export const inject = ["tools"];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 设置命名空间:Web 配置界面读写它。用户层持久化在 settings.yaml,
|
|
14
|
+
* config.json 作为 base(旧配置自动继承)。配置变化实时生效。
|
|
15
|
+
*/
|
|
16
|
+
const SETTINGS_NS = "dsh-screenshot-capture";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 扁平 schema:settings 客户端的 set(field, value) 只支持单段路径,
|
|
20
|
+
* 所以把嵌套的 ocr/dialog 拍平,host 侧再映射回插件结构。
|
|
21
|
+
*/
|
|
22
|
+
const settingsSchema = z.object({
|
|
23
|
+
enabled: z.boolean().default(true),
|
|
24
|
+
vaultPath: z.string().default(DEFAULT_CONFIG.vaultPath),
|
|
25
|
+
pollIntervalMs: z.number().min(50).max(60000).default(DEFAULT_CONFIG.pollIntervalMs),
|
|
26
|
+
cooldownMs: z.number().min(0).max(120000).default(DEFAULT_CONFIG.cooldownMs),
|
|
27
|
+
ocrMode: z.union([z.const("qwen"), z.const("off")]).default(DEFAULT_CONFIG.ocr.mode),
|
|
28
|
+
ocrModel: z.string().default(DEFAULT_CONFIG.ocr.model),
|
|
29
|
+
ocrApiKey: z.string().default(""),
|
|
30
|
+
ocrEndpoint: z.string().default(DEFAULT_CONFIG.ocr.endpoint),
|
|
31
|
+
ocrPrompt: z.string().default(DEFAULT_CONFIG.ocr.prompt),
|
|
32
|
+
dialogOffsetX: z.number().min(-1000).max(1000).default(DEFAULT_CONFIG.dialog.offsetX),
|
|
33
|
+
dialogOffsetY: z.number().min(-1000).max(1000).default(DEFAULT_CONFIG.dialog.offsetY),
|
|
34
|
+
dialogPreviewMaxWidth: z.number().min(100).max(2000).default(DEFAULT_CONFIG.dialog.previewMaxWidth),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** 插件嵌套结构 → 扁平 settings 结构 */
|
|
38
|
+
function toFlat(config) {
|
|
39
|
+
return {
|
|
40
|
+
enabled: config.enabled,
|
|
41
|
+
vaultPath: config.vaultPath,
|
|
42
|
+
pollIntervalMs: config.pollIntervalMs,
|
|
43
|
+
cooldownMs: config.cooldownMs,
|
|
44
|
+
ocrMode: config.ocr?.mode,
|
|
45
|
+
ocrModel: config.ocr?.model,
|
|
46
|
+
ocrApiKey: config.ocr?.apiKey ?? "",
|
|
47
|
+
ocrEndpoint: config.ocr?.endpoint,
|
|
48
|
+
ocrPrompt: config.ocr?.prompt,
|
|
49
|
+
dialogOffsetX: config.dialog?.offsetX,
|
|
50
|
+
dialogOffsetY: config.dialog?.offsetY,
|
|
51
|
+
dialogPreviewMaxWidth: config.dialog?.previewMaxWidth,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 扁平 settings 结构 → 插件嵌套结构 */
|
|
56
|
+
function fromFlat(flat) {
|
|
57
|
+
return {
|
|
58
|
+
enabled: flat.enabled,
|
|
59
|
+
vaultPath: flat.vaultPath,
|
|
60
|
+
pollIntervalMs: flat.pollIntervalMs,
|
|
61
|
+
cooldownMs: flat.cooldownMs,
|
|
62
|
+
ocr: {
|
|
63
|
+
mode: flat.ocrMode,
|
|
64
|
+
model: flat.ocrModel,
|
|
65
|
+
apiKey: flat.ocrApiKey || "",
|
|
66
|
+
endpoint: flat.ocrEndpoint,
|
|
67
|
+
prompt: flat.ocrPrompt,
|
|
68
|
+
},
|
|
69
|
+
dialog: {
|
|
70
|
+
offsetX: flat.dialogOffsetX,
|
|
71
|
+
offsetY: flat.dialogOffsetY,
|
|
72
|
+
previewMaxWidth: flat.dialogPreviewMaxWidth,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function apply(ctx, input = {}) {
|
|
78
|
+
// 初始配置:默认值 ← config.json ← 插件行 config
|
|
79
|
+
let liveConfig = resolveConfig(input);
|
|
80
|
+
if (!liveConfig.vaultPath) {
|
|
81
|
+
ctx.logger.warn("dsh-screenshot-capture: 未配置 vaultPath,采集功能已停用");
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
ensureVault(liveConfig);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
ctx.logger.warn(`dsh-screenshot-capture: vault 初始化失败:${err.message}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---- 剪贴板监听:单例 + 串行化重启 ----
|
|
90
|
+
// 全程只有一个 watcher。配置变化时先等旧 PowerShell 进程真正退出
|
|
91
|
+
// (stopAsync:kill + taskkill 兜底 + 超时),再起新的;配置没变就不重启,
|
|
92
|
+
// 避免残留多个监听导致一次截图弹多个窗。
|
|
93
|
+
let watcher = null;
|
|
94
|
+
let watcherKey = null; // 当前 watcher 所用配置的特征串
|
|
95
|
+
let disposed = false;
|
|
96
|
+
let restartChain = Promise.resolve();
|
|
97
|
+
|
|
98
|
+
const configKey = (cfg) => JSON.stringify({
|
|
99
|
+
enabled: cfg.enabled,
|
|
100
|
+
vaultPath: cfg.vaultPath,
|
|
101
|
+
pollIntervalMs: cfg.pollIntervalMs,
|
|
102
|
+
cooldownMs: cfg.cooldownMs,
|
|
103
|
+
dialogOffsetX: cfg.dialog?.offsetX,
|
|
104
|
+
dialogOffsetY: cfg.dialog?.offsetY,
|
|
105
|
+
dialogPreviewMaxWidth: cfg.dialog?.previewMaxWidth,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const scheduleRestart = () => {
|
|
109
|
+
const cfg = liveConfig;
|
|
110
|
+
const key = configKey(cfg);
|
|
111
|
+
restartChain = restartChain.then(async () => {
|
|
112
|
+
if (disposed || key === watcherKey) return;
|
|
113
|
+
watcherKey = key;
|
|
114
|
+
const old = watcher;
|
|
115
|
+
if (old) {
|
|
116
|
+
watcher = null;
|
|
117
|
+
await old.stopAsync();
|
|
118
|
+
}
|
|
119
|
+
if (!cfg.enabled || !cfg.vaultPath) return;
|
|
120
|
+
const w = new ClipboardWatcher(cfg);
|
|
121
|
+
w.on("choice", async ({ action, path, note = "", isKey = false }) => {
|
|
122
|
+
try {
|
|
123
|
+
const result = await handleChoice(cfg, { action, path, note, isKey });
|
|
124
|
+
ctx.logger.info(`dsh-screenshot-capture: ${result.note} (${action})`);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
ctx.logger.warn(`dsh-screenshot-capture: 处理失败:${err.message}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
w.on("err", (ev) => ctx.logger.warn(`dsh-screenshot-capture: ${ev.msg}`));
|
|
130
|
+
w.on("exit", (ev) => ctx.logger.info(`dsh-screenshot-capture: 监听助手退出 code=${ev.code}`));
|
|
131
|
+
watcher = w;
|
|
132
|
+
w.start();
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const disposeWatcher = () => {
|
|
137
|
+
disposed = true;
|
|
138
|
+
if (watcher) {
|
|
139
|
+
watcher.stop();
|
|
140
|
+
watcher = null;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
ctx.effect(
|
|
145
|
+
() => {
|
|
146
|
+
scheduleRestart();
|
|
147
|
+
return disposeWatcher;
|
|
148
|
+
},
|
|
149
|
+
"dsh-screenshot-capture.watcher",
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// 设置命名空间:Web 配置界面的数据层。settings 服务由 dsh-settings-file
|
|
153
|
+
// 提供,apply 时未必已就绪,须用 ctx.inject 等它可用后再注册(直接
|
|
154
|
+
// ctx.get 会拿到 undefined,导致命名空间没注册、界面显示不可用)。
|
|
155
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
156
|
+
try {
|
|
157
|
+
const scope = settingsCtx.settings.register(SETTINGS_NS, settingsSchema, {
|
|
158
|
+
base: toFlat(liveConfig),
|
|
159
|
+
});
|
|
160
|
+
const resolved = scope.get();
|
|
161
|
+
if (resolved) {
|
|
162
|
+
liveConfig = { ...liveConfig, ...fromFlat(resolved) };
|
|
163
|
+
scheduleRestart();
|
|
164
|
+
}
|
|
165
|
+
scope.watch((next) => {
|
|
166
|
+
if (!next) return;
|
|
167
|
+
liveConfig = { ...liveConfig, ...fromFlat(next) };
|
|
168
|
+
scheduleRestart();
|
|
169
|
+
});
|
|
170
|
+
} catch (err) {
|
|
171
|
+
ctx.logger.warn(`dsh-screenshot-capture: 设置命名空间注册失败,使用 JSON 配置:${err.message}`);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// 工具
|
|
176
|
+
ctx.tools.register(textTool({
|
|
177
|
+
name: "screenshot_status",
|
|
178
|
+
description:
|
|
179
|
+
"查询「截图入库」插件的状态:监听是否开启、vault 路径、今天的已收条目数。",
|
|
180
|
+
parameters: {},
|
|
181
|
+
async execute() {
|
|
182
|
+
const today = todayString();
|
|
183
|
+
const note = readDailyNote(liveConfig, today);
|
|
184
|
+
const count = (note.match(/^## /gm) || []).length;
|
|
185
|
+
return [
|
|
186
|
+
`监听:${watcher?.started ? "运行中" : "未运行"}`,
|
|
187
|
+
`vault:${liveConfig.vaultPath}`,
|
|
188
|
+
`OCR:${liveConfig.ocr?.mode ?? "off"}`,
|
|
189
|
+
`今日条目:${count}`,
|
|
190
|
+
].join("\n");
|
|
191
|
+
},
|
|
192
|
+
}));
|
|
193
|
+
|
|
194
|
+
ctx.tools.register(textTool({
|
|
195
|
+
name: "screenshot_inbox_list",
|
|
196
|
+
description:
|
|
197
|
+
"列出某天「收件箱」里的全部截图条目(时间/类型/OCR文字)。晚间整理前调用,展示给用户选择保留哪些。",
|
|
198
|
+
parameters: {
|
|
199
|
+
date: { type: "string", description: "日期 YYYY-MM-DD,缺省今天" },
|
|
200
|
+
},
|
|
201
|
+
async execute(args) {
|
|
202
|
+
const date = args.date || todayString();
|
|
203
|
+
const entries = listEntriesForDate(liveConfig, date);
|
|
204
|
+
if (entries.length === 0) return `${date} 收件箱为空(还没有截图入库)`;
|
|
205
|
+
return entries
|
|
206
|
+
.map((e, i) => {
|
|
207
|
+
const lines = [`${i + 1}. [${e.time}] #${e.kind} ${e.imageRel ?? ""}`];
|
|
208
|
+
if (e.isKey) lines.push(` 重点:是`);
|
|
209
|
+
if (e.note) lines.push(` 注释:${e.note}`);
|
|
210
|
+
lines.push(` OCR:${(e.ocrText || "无").slice(0, 120)}`);
|
|
211
|
+
return lines.join("\n");
|
|
212
|
+
})
|
|
213
|
+
.join("\n");
|
|
214
|
+
},
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
ctx.tools.register(textTool({
|
|
218
|
+
name: "screenshot_inbox_organize",
|
|
219
|
+
description:
|
|
220
|
+
"晚间整理:对某天收件箱里【保留】的条目,按分类写入知识库并生成笔记、更新分类索引、写当日总结、加双链,然后把当天收件箱笔记移入归档。keep 传 'all' 表示全部保留。",
|
|
221
|
+
parameters: {
|
|
222
|
+
date: { type: "string", description: "日期 YYYY-MM-DD,缺省今天" },
|
|
223
|
+
keep: {
|
|
224
|
+
type: "array",
|
|
225
|
+
items: { type: "string" },
|
|
226
|
+
description: "保留条目的时间列表(如 ['14:30','15:02']),或 ['all'] 表示全部保留",
|
|
227
|
+
},
|
|
228
|
+
categories: {
|
|
229
|
+
type: "object",
|
|
230
|
+
additionalProperties: true,
|
|
231
|
+
description: "可选:时间→分类名 的映射(如 {'14:30':'数学'}),缺省归入『未分类』",
|
|
232
|
+
},
|
|
233
|
+
summaryTitle: { type: "string", description: "可选:当日总结标题" },
|
|
234
|
+
},
|
|
235
|
+
async execute(args) {
|
|
236
|
+
const date = args.date || todayString();
|
|
237
|
+
const keepRaw = Array.isArray(args.keep) ? args.keep : ["all"];
|
|
238
|
+
const keep = keepRaw.includes("all") ? null : keepRaw;
|
|
239
|
+
const result = organizeDay(liveConfig, {
|
|
240
|
+
date,
|
|
241
|
+
keep,
|
|
242
|
+
categories: args.categories ?? {},
|
|
243
|
+
summaryTitle: args.summaryTitle ?? "",
|
|
244
|
+
});
|
|
245
|
+
return [
|
|
246
|
+
`日期:${result.date}`,
|
|
247
|
+
`保留:${result.keptCount} 条,丢弃:${result.discardedCount} 条`,
|
|
248
|
+
`分类:${JSON.stringify(result.categories)}`,
|
|
249
|
+
`总结:${result.summaryPath}`,
|
|
250
|
+
`归档:${result.archived ?? "无"}`,
|
|
251
|
+
`生成笔记:`,
|
|
252
|
+
...result.files.map((f) => ` ${f}`),
|
|
253
|
+
].join("\n");
|
|
254
|
+
},
|
|
255
|
+
}));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function textTool(definition) {
|
|
259
|
+
return defineTool({
|
|
260
|
+
...definition,
|
|
261
|
+
output: {
|
|
262
|
+
schema: { type: "string" },
|
|
263
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
264
|
+
},
|
|
265
|
+
presentCall: (args) => ({
|
|
266
|
+
card: "generic",
|
|
267
|
+
kind: "text",
|
|
268
|
+
title: definition.name,
|
|
269
|
+
rawInput: args,
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
272
|
+
}
|
package/ocr.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** 通义千问多模态 OCR。key 优先取配置,其次环境变量 DASHSCOPE_API_KEY。 */
|
|
4
|
+
export async function ocrImage(config, imagePath) {
|
|
5
|
+
const mode = config.ocr?.mode ?? "off";
|
|
6
|
+
if (mode === "off") return "";
|
|
7
|
+
if (mode === "qwen") return ocrQwen(config, imagePath);
|
|
8
|
+
throw new Error(`OCR: 未知模式 ${mode}`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function ocrQwen(config, imagePath) {
|
|
12
|
+
const apiKey = config.ocr?.apiKey || process.env.DASHSCOPE_API_KEY;
|
|
13
|
+
if (!apiKey) throw new Error("OCR: 未配置通义 API key(配置 config.json 或环境变量 DASHSCOPE_API_KEY)");
|
|
14
|
+
|
|
15
|
+
const b64 = readFileSync(imagePath).toString("base64");
|
|
16
|
+
const mime = imagePath.toLowerCase().endsWith(".png") ? "image/png" : "image/jpeg";
|
|
17
|
+
const endpoint = config.ocr?.endpoint ?? "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
|
|
18
|
+
const model = config.ocr?.model ?? "qwen-vl-plus";
|
|
19
|
+
const prompt = config.ocr?.prompt ?? "请识别图片中的全部文字内容,原样输出;数学公式用 LaTeX 输出。只输出识别结果,不要任何解释。";
|
|
20
|
+
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timer = setTimeout(() => controller.abort(), 60000);
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(endpoint, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: {
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
Authorization: `Bearer ${apiKey}`,
|
|
29
|
+
},
|
|
30
|
+
body: JSON.stringify({
|
|
31
|
+
model,
|
|
32
|
+
messages: [
|
|
33
|
+
{
|
|
34
|
+
role: "user",
|
|
35
|
+
content: [
|
|
36
|
+
{ type: "image_url", image_url: { url: `data:${mime};base64,${b64}` } },
|
|
37
|
+
{ type: "text", text: prompt },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
}),
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const body = await res.text().catch(() => "");
|
|
46
|
+
throw new Error(`OCR: 通义接口 ${res.status} ${body.slice(0, 300)}`);
|
|
47
|
+
}
|
|
48
|
+
const data = await res.json();
|
|
49
|
+
const text = data?.choices?.[0]?.message?.content;
|
|
50
|
+
if (typeof text !== "string") throw new Error("OCR: 通义返回格式异常");
|
|
51
|
+
return text.trim();
|
|
52
|
+
} finally {
|
|
53
|
+
clearTimeout(timer);
|
|
54
|
+
}
|
|
55
|
+
}
|
package/organize.mjs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ensureVault } from "./config.mjs";
|
|
4
|
+
import { archiveDailyNote, parseEntries, readDailyNote } from "./storage.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 晚间整理(MVP):
|
|
8
|
+
* 输入: 保留的条目列表 + 分类映射 + 当日总结标题
|
|
9
|
+
* 输出: 知识库/<分类>/<date>_<time>_<kind>.md(含图片/OCR/相关双链)
|
|
10
|
+
* 知识库/<分类>/INDEX.md 索引
|
|
11
|
+
* 总结/<date>.md 当日总结(链接全部保留条目 + 上一篇总结)
|
|
12
|
+
* 收件箱当天笔记移入归档
|
|
13
|
+
* 所有双链均为确定性生成(按文件名),不是 AI 自由文本。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
function escFilenamePart(s) {
|
|
17
|
+
return s.replace(/[\\/:*?"<>|]/g, "_").trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function noteTitleOf({ time, kind }) {
|
|
21
|
+
return `截图 ${time}${kind === "文档" ? "" : "(图片)"}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function listEntriesForDate(config, date) {
|
|
25
|
+
const text = readDailyNote(config, date);
|
|
26
|
+
return parseEntries(text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function organizeDay(config, { date, keep, categories = {}, summaryTitle = "" }) {
|
|
30
|
+
ensureVault(config);
|
|
31
|
+
const entries = listEntriesForDate(config, date);
|
|
32
|
+
const kept = Array.isArray(keep)
|
|
33
|
+
? entries.filter((e) => keep.includes(e.time))
|
|
34
|
+
: entries; // "all"
|
|
35
|
+
|
|
36
|
+
const written = [];
|
|
37
|
+
for (const entry of kept) {
|
|
38
|
+
const cat = escFilenamePart(categories[entry.time] || "未分类");
|
|
39
|
+
const file = `${date.replaceAll("-", "")}_${entry.time.replace(":", "")}_${entry.kind}.md`;
|
|
40
|
+
const notePath = join(config.vaultPath, config.knowledgeFolder, cat, file);
|
|
41
|
+
|
|
42
|
+
const prevLinks = previousNotesInCategory(config, cat, file);
|
|
43
|
+
const backlinks = [`[[${date} 总结]]`];
|
|
44
|
+
const keyBlock = (entry.note || entry.isKey)
|
|
45
|
+
? [
|
|
46
|
+
"## 重点",
|
|
47
|
+
"",
|
|
48
|
+
...(entry.isKey ? ["# **重点**", ""] : []),
|
|
49
|
+
...(entry.note ? [entry.note, ""] : []),
|
|
50
|
+
]
|
|
51
|
+
: [];
|
|
52
|
+
const body = [
|
|
53
|
+
"---",
|
|
54
|
+
`tags: [截图, ${entry.kind}, ${cat}]`,
|
|
55
|
+
`date: ${date} ${entry.time}`,
|
|
56
|
+
"---",
|
|
57
|
+
"",
|
|
58
|
+
`# ${summaryTitle && categories[entry.time] ? `${summaryTitle}` : noteTitleOf(entry)}`,
|
|
59
|
+
"",
|
|
60
|
+
`![[${entry.imageRel.split("/").pop()}]]`,
|
|
61
|
+
"",
|
|
62
|
+
...keyBlock,
|
|
63
|
+
"## 文字",
|
|
64
|
+
"",
|
|
65
|
+
entry.ocrText ? entry.ocrText : "(无 OCR 文字)",
|
|
66
|
+
"",
|
|
67
|
+
"## 相关",
|
|
68
|
+
"",
|
|
69
|
+
...prevLinks.map((p) => `- [[${p}]]`),
|
|
70
|
+
"- [[INDEX]]",
|
|
71
|
+
...backlinks.map((b) => `- ${b}`),
|
|
72
|
+
"",
|
|
73
|
+
].join("\n");
|
|
74
|
+
|
|
75
|
+
mkdirSync(join(config.vaultPath, config.knowledgeFolder, cat), { recursive: true });
|
|
76
|
+
writeFileSync(notePath, body, "utf8");
|
|
77
|
+
written.push({ cat, file, path: notePath, entry });
|
|
78
|
+
updateIndex(config, cat, file, entry);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const summaryPath = writeSummary(config, { date, written, summaryTitle });
|
|
82
|
+
|
|
83
|
+
// 收件箱当天笔记 → 归档
|
|
84
|
+
const archived = archiveDailyNote(config, date);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
date,
|
|
88
|
+
keptCount: kept.length,
|
|
89
|
+
discardedCount: entries.length - kept.length,
|
|
90
|
+
categories: Object.fromEntries(written.map((w) => [w.entry.time, w.cat])),
|
|
91
|
+
summaryPath,
|
|
92
|
+
archived,
|
|
93
|
+
files: written.map((w) => w.path),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function previousNotesInCategory(config, cat, excludeFile) {
|
|
98
|
+
const dir = join(config.vaultPath, config.knowledgeFolder, cat);
|
|
99
|
+
if (!existsSync(dir)) return [];
|
|
100
|
+
return readdirSync(dir)
|
|
101
|
+
.filter((f) => f.endsWith(".md") && f !== "INDEX.md" && f !== excludeFile)
|
|
102
|
+
.sort()
|
|
103
|
+
.map((f) => f.replace(/\.md$/, ""));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function updateIndex(config, cat, file, entry) {
|
|
107
|
+
const dir = join(config.vaultPath, config.knowledgeFolder, cat);
|
|
108
|
+
const indexPath = join(dir, "INDEX.md");
|
|
109
|
+
const title = noteTitleOf(entry);
|
|
110
|
+
const line = `- [[${file.replace(/\.md$/, "")}]] — ${title}`;
|
|
111
|
+
let text = "";
|
|
112
|
+
if (existsSync(indexPath)) {
|
|
113
|
+
text = readFileSync(indexPath, "utf8");
|
|
114
|
+
if (!text.includes(`[[${file.replace(/\.md$/, "")}]]`)) text += line + "\n";
|
|
115
|
+
} else {
|
|
116
|
+
text = [`# ${cat}`, "", `> 该分类下的截图笔记索引(晚间整理自动生成)。`, "", line, ""].join("\n");
|
|
117
|
+
}
|
|
118
|
+
writeFileSync(indexPath, text, "utf8");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeSummary(config, { date, written, summaryTitle }) {
|
|
122
|
+
const dir = join(config.vaultPath, config.summaryFolder);
|
|
123
|
+
mkdirSync(dir, { recursive: true });
|
|
124
|
+
const path = join(dir, `${date}.md`);
|
|
125
|
+
const title = summaryTitle || `${date} 当日总结`;
|
|
126
|
+
const lines = [
|
|
127
|
+
"---",
|
|
128
|
+
"tags: [总结]",
|
|
129
|
+
"date: " + date,
|
|
130
|
+
"---",
|
|
131
|
+
"",
|
|
132
|
+
`# ${title}`,
|
|
133
|
+
"",
|
|
134
|
+
"## 今日留存",
|
|
135
|
+
"",
|
|
136
|
+
...written.map((w) => `- [[${w.file.replace(/\.md$/, "")}]] — ${w.entry.time} ${w.entry.kind}(${w.cat})`),
|
|
137
|
+
"",
|
|
138
|
+
];
|
|
139
|
+
const prev = previousSummary(config, date);
|
|
140
|
+
if (prev.length > 0) {
|
|
141
|
+
lines.push("## 上一篇总结", "", ...prev.map((p) => `- [[${p}]]`), "");
|
|
142
|
+
}
|
|
143
|
+
writeFileSync(path, lines.join("\n"), "utf8");
|
|
144
|
+
return path;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function previousSummary(config, date) {
|
|
148
|
+
const dir = join(config.vaultPath, config.summaryFolder);
|
|
149
|
+
if (!existsSync(dir)) return [];
|
|
150
|
+
return readdirSync(dir)
|
|
151
|
+
.filter((f) => f.endsWith(".md") && f !== `${date}.md`)
|
|
152
|
+
.sort()
|
|
153
|
+
.slice(-3)
|
|
154
|
+
.map((f) => f.replace(/\.md$/, ""));
|
|
155
|
+
}
|