Skip to content

Commit 4fd0b41

Browse files
committed
feat: 添加文件改动的防抖处理,优化同步逻辑以合并连续编辑
1 parent 8fc0f1a commit 4fd0b41

3 files changed

Lines changed: 100 additions & 36 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ For easiest local development, keep this repository inside:
8181
1. Open **Settings → Obsidian RAG Integration → LightRAG**.
8282
2. Enable LightRAG and set the server URL, for example `http://192.168.50.209:9621`.
8383
3. Select **Test connection** to verify `/health`.
84-
4. Keep **Auto sync Markdown** enabled or select **Start sync** to sync immediately. Renames and deletions remove the corresponding LightRAG document, and a full **Start sync** also reconciles the server by removing orphaned documents whose vault files no longer exist (only documents previously created by this plugin are touched).
84+
4. Keep **Auto sync Markdown** enabled or select **Start sync** to sync immediately. Edits are debounced (continuous typing is coalesced into a single re-sync after you pause, with a hard cap so a long editing session still syncs periodically), and the periodic scan defers to files you are actively editing to avoid re-processing intermediate states. Renames and deletions remove the corresponding LightRAG document, and a full **Start sync** also reconciles the server by removing orphaned documents whose vault files no longer exist (only documents previously created by this plugin are touched).
8585
5. In **Vault chat** mode, the default answer mode calls LightRAG `/query/data` for retrieval, then uses the plugin's selected chat model to generate the final answer. You can switch to direct LightRAG `/query/stream` generation in the LightRAG query settings.
8686

8787
LightRAG sync only sends Markdown files. New or changed files are submitted in batches of up to 3 texts per `/documents/texts` request. When a synced file changes, the plugin deletes the previously tracked LightRAG document and inserts the updated text because the current LightRAG API does not expose an in-place document update endpoint. The plugin listens for vault file events, scans once on startup, and can periodically rescan the vault by mtime to catch missed events. The LightRAG exclude list supports exact paths, folder prefixes such as `private/`, and simple glob rules such as `archive/**/*.md`; excluded files are not synced and previously synced excluded files are removed from LightRAG on the next scan. LightRAG chat defaults to `/query/data` retrieval plus plugin-model generation, strips generated inline References sections, and shows a limited de-duplicated source list based on retrieved chunks. The LightRAG settings tab refreshes pipeline and tracked document status automatically while it is open.

src/lightrag/LightRAGSyncService.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ const STORE_PATH = `${STORE_DIR}/lightrag-sync.json`;
99
const POLL_INTERVAL_MS = 1500;
1010
const PIPELINE_WAIT_MS = 5 * 60 * 1000;
1111
const INSERT_BATCH_SIZE = 3;
12+
// 文件改动后等待这么久(无新改动)才真正入队同步,合并连续编辑
13+
const DEBOUNCE_MS = 10000;
14+
// 连续编辑时的强制上限:即使一直在改,距首次改动超过此时长也会落一次同步
15+
const DEBOUNCE_MAX_WAIT_MS = 300 * 1000;
1216

1317
type QueueItem =
1418
| { type: 'sync'; filePath: string; force: boolean }
@@ -57,6 +61,8 @@ export class LightRAGSyncService {
5761
private queuedKeys = new Set<string>();
5862
private processing = false;
5963
private saveTimer: ReturnType<typeof setTimeout> | null = null;
64+
private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
65+
private debounceFirstSeen = new Map<string, number>();
6066
private idleResolvers: Array<() => void> = [];
6167
private lastError: string | null = null;
6268
private pipelineStatus: LightRAGPipelineStatus | null = null;
@@ -78,6 +84,7 @@ export class LightRAGSyncService {
7884
this.stopped = true;
7985
this.queue = [];
8086
this.queuedKeys.clear();
87+
this.clearAllDebounce();
8188
this.resolveIdle();
8289
}
8390

@@ -128,6 +135,12 @@ export class LightRAGSyncService {
128135
if (!this.config.enabled) return;
129136

130137
const files = this.app.vault.getMarkdownFiles();
138+
// 防御启动竞态:文件列表为空却已有同步记录,几乎一定是 vault 尚未就绪。
139+
// 此时执行下面的孤儿清理会把所有已同步文档误删,跳过本轮,留待就绪后再同步。
140+
if (files.length === 0 && Object.keys(this.records).length > 0) {
141+
Logger.warn('[LightRAGSync] queueAll 跳过:文件列表为空但存在同步记录,疑似 vault 未就绪');
142+
return;
143+
}
131144
const livePaths = new Set(files.map(file => normalizePath(file.path)));
132145

133146
for (const record of Object.values(this.records)) {
@@ -174,19 +187,21 @@ export class LightRAGSyncService {
174187
handleCreateOrModify(file: TFile): void {
175188
if (!this.config.enabled || !this.config.autoSync || !this.isMarkdown(file)) return;
176189
if (this.isExcludedPath(file.path)) return;
177-
this.enqueue({ type: 'sync', filePath: file.path, force: false });
178-
this.processQueue();
190+
this.scheduleDebouncedSync(file.path);
179191
}
180192

181193
handleDelete(filePath: string): void {
182194
if (!this.config.enabled || !this.config.autoSync) return;
195+
this.cancelDebounce(filePath);
183196
if (!this.records[filePath]) return;
184197
this.enqueue({ type: 'delete', filePath });
185198
this.processQueue();
186199
}
187200

188201
handleRename(file: TFile, oldPath: string): void {
189202
if (!this.config.enabled || !this.config.autoSync) return;
203+
this.cancelDebounce(oldPath);
204+
this.cancelDebounce(file.path);
190205
if (this.records[oldPath]) {
191206
this.enqueue({ type: 'delete', filePath: oldPath });
192207
}
@@ -196,6 +211,47 @@ export class LightRAGSyncService {
196211
this.processQueue();
197212
}
198213

214+
/**
215+
* 将文件改动延迟入队,合并连续编辑。带 maxWait 上限:即使一直在改,
216+
* 距首次改动超过 DEBOUNCE_MAX_WAIT_MS 也会强制落一次同步。
217+
* 处于 debounce 窗口内的文件由 shouldSync 跳过,避免定时扫盘抢先同步中间态。
218+
*/
219+
private scheduleDebouncedSync(filePath: string): void {
220+
if (this.stopped) return;
221+
const now = Date.now();
222+
const firstSeen = this.debounceFirstSeen.get(filePath) ?? now;
223+
if (!this.debounceFirstSeen.has(filePath)) {
224+
this.debounceFirstSeen.set(filePath, now);
225+
}
226+
const existing = this.debounceTimers.get(filePath);
227+
if (existing) clearTimeout(existing);
228+
const delay = Math.min(DEBOUNCE_MS, Math.max(0, DEBOUNCE_MAX_WAIT_MS - (now - firstSeen)));
229+
this.debounceTimers.set(filePath, setTimeout(() => this.flushDebouncedSync(filePath), delay));
230+
}
231+
232+
private flushDebouncedSync(filePath: string): void {
233+
this.debounceTimers.delete(filePath);
234+
this.debounceFirstSeen.delete(filePath);
235+
if (this.stopped || !this.config.enabled || !this.config.autoSync) return;
236+
const file = this.app.vault.getAbstractFileByPath(filePath);
237+
if (!(file instanceof TFile) || !this.isMarkdown(file) || this.isExcludedPath(filePath)) return;
238+
this.enqueue({ type: 'sync', filePath, force: false });
239+
this.processQueue();
240+
}
241+
242+
private cancelDebounce(filePath: string): void {
243+
const timer = this.debounceTimers.get(filePath);
244+
if (timer) clearTimeout(timer);
245+
this.debounceTimers.delete(filePath);
246+
this.debounceFirstSeen.delete(filePath);
247+
}
248+
249+
private clearAllDebounce(): void {
250+
for (const timer of this.debounceTimers.values()) clearTimeout(timer);
251+
this.debounceTimers.clear();
252+
this.debounceFirstSeen.clear();
253+
}
254+
199255
getSnapshot(): LightRAGSyncSnapshot {
200256
const records = Object.values(this.records).sort((a, b) => b.updatedAt - a.updatedAt);
201257
return {
@@ -220,6 +276,8 @@ export class LightRAGSyncService {
220276

221277
private shouldSync(file: TFile): boolean {
222278
if (this.isExcludedPath(file.path)) return false;
279+
// 正处于编辑 debounce 窗口内的文件交给 debounce 处理,定时扫盘不抢先同步中间态
280+
if (this.debounceTimers.has(file.path)) return false;
223281
const record = this.records[file.path];
224282
if (!record) return true;
225283
if (record.mtime < file.stat.mtime) return true;

src/main.ts

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,6 @@ export default class CopilotPlugin extends Plugin {
5656
await this.loadConversations();
5757
await this.localStore.load();
5858
await this.lightRAGSync.load();
59-
if (this.settings.lightRAG.enabled && this.settings.lightRAG.autoSync) {
60-
this.lightRAGSync.queueAll(false);
61-
}
6259
this.configureLightRAGPeriodicScan();
6360
this.registerViews();
6461
this.addCommands();
@@ -79,39 +76,48 @@ export default class CopilotPlugin extends Plugin {
7976
)
8077
);
8178

82-
// Watch for file deletions to keep index in sync
83-
this.registerEvent(
84-
this.app.vault.on('delete', file => {
85-
void this.indexer.deleteFileIndex(file.path);
86-
this.lightRAGSync.handleDelete(file.path);
87-
})
88-
);
79+
// Vault 文件事件需等布局就绪后再注册:
80+
// 1) onload 阶段 getMarkdownFiles() 可能为空/不全,此时 queueAll 的孤儿清理会把已同步文档误删;
81+
// 2) 过早注册 'create' 会在启动时对每个已存在文件各触发一次,造成全量误同步。
82+
this.app.workspace.onLayoutReady(() => {
83+
if (this.settings.lightRAG.enabled && this.settings.lightRAG.autoSync) {
84+
this.lightRAGSync.queueAll(false);
85+
}
8986

90-
this.registerEvent(
91-
this.app.vault.on('create', file => {
92-
if (file instanceof TFile) {
93-
this.lightRAGSync.handleCreateOrModify(file);
94-
}
95-
})
96-
);
87+
// Watch for file deletions to keep index in sync
88+
this.registerEvent(
89+
this.app.vault.on('delete', file => {
90+
void this.indexer.deleteFileIndex(file.path);
91+
this.lightRAGSync.handleDelete(file.path);
92+
})
93+
);
9794

98-
this.registerEvent(
99-
this.app.vault.on('modify', file => {
100-
if (file instanceof TFile) {
101-
this.lightRAGSync.handleCreateOrModify(file);
102-
}
103-
})
104-
);
95+
this.registerEvent(
96+
this.app.vault.on('create', file => {
97+
if (file instanceof TFile) {
98+
this.lightRAGSync.handleCreateOrModify(file);
99+
}
100+
})
101+
);
105102

106-
this.registerEvent(
107-
this.app.vault.on('rename', (file, oldPath) => {
108-
if (file instanceof TFile) {
109-
this.lightRAGSync.handleRename(file, oldPath);
110-
} else {
111-
this.lightRAGSync.handleDelete(oldPath);
112-
}
113-
})
114-
);
103+
this.registerEvent(
104+
this.app.vault.on('modify', file => {
105+
if (file instanceof TFile) {
106+
this.lightRAGSync.handleCreateOrModify(file);
107+
}
108+
})
109+
);
110+
111+
this.registerEvent(
112+
this.app.vault.on('rename', (file, oldPath) => {
113+
if (file instanceof TFile) {
114+
this.lightRAGSync.handleRename(file, oldPath);
115+
} else {
116+
this.lightRAGSync.handleDelete(oldPath);
117+
}
118+
})
119+
);
120+
});
115121

116122
Logger.info('Copilot plugin loaded');
117123
}

0 commit comments

Comments
 (0)