yan-istart_IStart-Note-AI-P.../src/main.ts
2026-05-07 09:44:40 +08:00

751 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Notice, Plugin, TFile, normalizePath } from "obsidian";
import { DeepSeekSettings, DEFAULT_SETTINGS, CompletionDepth } from "./types";
import { DeepSeekClient } from "./ai/DeepSeekClient";
import { VaultWriter } from "./vault/VaultWriter";
import { QuestionModal } from "./features/question/QuestionModal";
import { DeepSeekSettingsTab } from "./settings/SettingsTab";
import { ConceptCompleter } from "./ai/ConceptCompleter";
import { ConceptPageManager } from "./features/concept/ConceptPageManager";
import { DepthSelectModal, PreviewModal, BatchScanModal } from "./features/concept/ConceptCompletionModal";
import { QuestionClassifier } from "./ai/QuestionClassifier";
import { QuestionGraphManager } from "./features/question/QuestionGraphManager";
import { QuestionClassifyModal } from "./features/question/QuestionClassifyModal";
import { ContextQAClient } from "./ai/ContextQAClient";
import { ContextQAModal } from "./features/context-qa/ContextQAModal";
import { SectionAppender } from "./ai/SectionAppender";
import { SectionAppendModal, SectionPreviewModal } from "./features/section/SectionAppendModal";
import { BaiduSyncService } from "./features/sync/BaiduSyncService";
import { BaiduAuthModal } from "./features/sync/BaiduAuthModal";
import { BaiduSyncModal } from "./features/sync/BaiduSyncModal";
import { DEFAULT_BAIDU_SYNC_CONFIG } from "./types";
import { BaiduSyncView, SYNC_VIEW_TYPE } from "./features/sync/BaiduSyncView";
import { DiagramGenerator, DiagramType } from "./ai/DiagramGenerator";
import { DiagramTypeModal, DiagramPreviewModal } from "./features/diagram/DiagramModal";
export default class DeepSeekPlugin extends Plugin {
settings: DeepSeekSettings;
async onload() {
await this.loadSettings();
this.addRibbonIcon("brain", "DeepSeek ask", () => {
this.openQuestionModal();
});
this.addRibbonIcon("cloud", "Baidu cloud sync status", () => {
void this.activateSyncView();
});
this.registerView(SYNC_VIEW_TYPE, (leaf) => new BaiduSyncView(leaf, this));
this.addCommand({
id: "ask-deepseek",
name: "Ask DeepSeek and generate a knowledge note",
callback: () => this.openQuestionModal(),
});
this.addCommand({
id: "complete-current-concept",
name: "Complete current concept page",
callback: () => { void this.completeCurrentConcept(); },
});
this.addCommand({
id: "scan-empty-concepts",
name: "Scan empty concept pages",
callback: () => { void this.scanAndBatchComplete(); },
});
this.addCommand({
id: "context-qa",
name: "Ask based on selection",
editorCallback: (editor) => {
const selection = editor.getSelection().trim();
if (!selection) {
new Notice("请先选中一段文字");
return;
}
const activeFile = this.app.workspace.getActiveFile();
this.openContextQAModal(selection, activeFile?.path ?? "");
},
});
this.addCommand({
id: "append-current-section",
name: "Append content to current section",
editorCallback: (editor) => {
const cursor = editor.getCursor();
const content = editor.getValue();
const appender = new SectionAppender(this.app, this.settings);
const sectionName = appender.getSectionAtCursor(content, cursor.line);
if (!sectionName) {
new Notice("请将光标置于某个章节(## 标题)内");
return;
}
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
void this.runSectionAppend(activeFile, sectionName, content);
},
});
this.addCommand({
id: "open-question-index",
name: "Open question index",
callback: () => { void this.openQuestionIndex(); },
});
this.addCommand({
id: "baidu-sync",
name: "Baidu Netdisk sync / backup",
callback: () => this.openBaiduSyncModal(),
});
this.addCommand({
id: "baidu-sync-view",
name: "Open Baidu cloud sync status panel",
callback: () => { void this.activateSyncView(); },
});
this.addCommand({
id: "baidu-auth",
name: "Baidu Netdisk re-authorize",
callback: () => this.openBaiduAuthModal(),
});
// 命令:生成图表/公式(选中文字)
this.addCommand({
id: "generate-diagram",
name: "Generate diagram or formula from selection",
editorCallback: (editor) => {
const selection = editor.getSelection().trim();
if (!selection) {
new Notice("请先选中一段文字作为图表/公式的描述");
return;
}
const context = editor.getValue().slice(0, 800);
this.openDiagramGenerator(selection, context, editor);
},
});
// 命令:智能生成(自动判断类型)
this.addCommand({
id: "smart-diagram",
name: "Smart generate (auto-detect type)",
editorCallback: (editor) => {
const selection = editor.getSelection().trim();
if (!selection) {
new Notice("请先选中一段文字");
return;
}
const context = editor.getValue().slice(0, 800);
void this.runDiagramGeneration(selection, "auto", context, editor);
},
});
this.addSettingTab(new DeepSeekSettingsTab(this.app, this));
this.registerEvent(
this.app.workspace.on("file-menu", (menu, file) => {
if (!(file instanceof TFile) || file.extension !== "md") return;
menu.addItem((item) => {
item
.setTitle("IStart-Note-AI: Complete this concept page")
.setIcon("brain")
.onClick(() => {
void (async () => {
const manager = new ConceptPageManager(this.app, this.settings);
const info = await manager.analyzeFile(file);
if (!info) {
new Notice("该文件不是概念页");
return;
}
new DepthSelectModal(this.app, info.conceptName, (depth) => {
void this.runConceptCompletion(info.file, info.conceptName, depth, {
sourceQuestion: info.sourceQuestion,
sourceAnswer: info.sourceAnswer,
});
}).open();
})();
});
});
})
);
this.registerEvent(
this.app.workspace.on("editor-menu", (menu, editor) => {
const selection = editor.getSelection().trim();
if (selection) {
menu.addItem((item) => {
item
.setTitle("IStart-Note-AI: Ask based on selection")
.setIcon("message-circle")
.onClick(() => {
const activeFile = this.app.workspace.getActiveFile();
this.openContextQAModal(selection, activeFile?.path ?? "");
});
});
// 图表/公式生成入口
menu.addItem((item) => {
item
.setTitle("IStart-Note-AI: Generate diagram / formula")
.setIcon("bar-chart-2")
.onClick(() => {
const context = editor.getValue().slice(0, 800);
this.openDiagramGenerator(selection, context, editor);
});
});
}
const cursor = editor.getCursor();
const fullContent = editor.getValue();
const appender = new SectionAppender(this.app, this.settings);
const sectionAtCursor = appender.getSectionAtCursor(fullContent, cursor.line);
if (sectionAtCursor) {
menu.addItem((item) => {
item
.setTitle(`IStart-Note-AI: Append to "${sectionAtCursor}"`)
.setIcon("plus-circle")
.onClick(() => {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) return;
void this.runSectionAppend(activeFile, sectionAtCursor, fullContent);
});
});
}
const linkMatch = selection.match(/^\[\[(.+?)(?:\|.+?)?\]\]$/) ||
selection.match(/^(.+)$/);
const conceptName = linkMatch?.[1];
if (!conceptName) return;
menu.addItem((item) => {
item
.setTitle(`IStart-Note-AI: Complete concept "${conceptName}"`)
.setIcon("brain")
.onClick(() => {
void (async () => {
const manager = new ConceptPageManager(this.app, this.settings);
const conceptsPath = this.settings.conceptsPath || "Knowledge/Concepts";
// 在所有子目录中查找概念文件
let conceptFile: TFile | null = null;
const allFiles = this.app.vault.getMarkdownFiles();
const found = allFiles.find(
(f) => f.path.startsWith(conceptsPath) && f.basename === conceptName
);
if (found) {
conceptFile = found;
}
if (!conceptFile) {
const writer = new VaultWriter(this.app, this.settings);
await writer.ensureConceptNote(conceptName);
// 重新查找(现在在 _未分类/ 下)
const created = allFiles.find(
(f) => f.path.startsWith(conceptsPath) && f.basename === conceptName
);
if (created) conceptFile = created;
else {
// 直接用路径查找
const uncatPath = `${conceptsPath}/_未分类/${conceptName}.md`;
const uncatFile = this.app.vault.getAbstractFileByPath(uncatPath);
if (uncatFile instanceof TFile) conceptFile = uncatFile;
}
}
if (!conceptFile) {
new Notice(`无法找到或创建概念页:${conceptName}`);
return;
}
const targetFile = conceptFile;
const info = await manager.analyzeFile(targetFile);
new DepthSelectModal(this.app, conceptName, (depth) => {
void this.runConceptCompletion(targetFile, conceptName, depth, {
sourceQuestion: info?.sourceQuestion,
sourceAnswer: info?.sourceAnswer,
});
}).open();
})();
});
});
menu.addItem((item) => {
item
.setTitle("IStart-Note-AI: Complete current concept page")
.setIcon("brain")
.onClick(() => { void this.completeCurrentConcept(); });
});
})
);
}
private openContextQAModal(selectedText: string, sourceNotePath: string) {
new ContextQAModal(this.app, selectedText, (question) => {
void this.processContextQA(question, selectedText, sourceNotePath);
}).open();
}
private async processContextQA(question: string, context: string, sourceNotePath: string) {
const notice = new Notice("⏳ 基于上下文思考中...", 0);
try {
const client = new ContextQAClient(this.settings);
const graphManager = new QuestionGraphManager(this.app, this.settings);
let surroundingContext: string | undefined;
if (sourceNotePath) {
const sourceFile = this.app.vault.getAbstractFileByPath(sourceNotePath);
if (sourceFile instanceof TFile) {
const fullContent = await this.app.vault.read(sourceFile);
surroundingContext = fullContent.slice(0, 500);
}
}
const [response, history] = await Promise.all([
client.ask({ question, context, sourceNote: sourceNotePath, surroundingContext }),
Promise.resolve(graphManager.getQuestionHistory()),
]);
notice.hide();
const classifier = new QuestionClassifier(this.settings);
const classifyNotice = new Notice("🔍 分析问题关系...", 0);
const classification = await classifier.classify(question, history);
classifyNotice.hide();
new QuestionClassifyModal(this.app, question, classification, (confirmed) => {
void (async () => {
const writeNotice = new Notice("✍️ 写入笔记...", 0);
try {
const writer = new VaultWriter(this.app, this.settings);
const file = await writer.writeContextQANote(
{ question, context, sourceNote: sourceNotePath, surroundingContext },
response
);
await graphManager.attachClassification(file, question, confirmed, response.concepts);
await graphManager.appendRecommendations(file, confirmed);
await graphManager.updateQuestionIndex(question, confirmed, file.path);
writeNotice.hide();
new Notice(`✅ 上下文笔记已生成:${file.name}`);
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
void this.triggerAutoBackup(file.path);
} catch (err) {
writeNotice.hide();
new Notice(`❌ 写入失败:${(err as Error).message}`);
console.error("[IStart-Note-AI]", err);
}
})();
}).open();
} catch (err) {
notice.hide();
new Notice(`❌ 错误:${(err as Error).message}`);
console.error("[IStart-Note-AI]", err);
}
}
private openQuestionModal() {
new QuestionModal(this.app, (question) => {
void this.processQuestion(question);
}).open();
}
private async processQuestion(question: string) {
const notice = new Notice("⏳ DeepSeek 思考中...", 0);
try {
const client = new DeepSeekClient(this.settings);
const graphManager = new QuestionGraphManager(this.app, this.settings);
const [response, history] = await Promise.all([
client.ask(question),
Promise.resolve(graphManager.getQuestionHistory()),
]);
notice.hide();
const classifier = new QuestionClassifier(this.settings);
const classifyNotice = new Notice("🔍 分析问题关系...", 0);
const classification = await classifier.classify(question, history);
classifyNotice.hide();
new QuestionClassifyModal(this.app, question, classification, (confirmed) => {
void (async () => {
const writeNotice = new Notice("✍️ 写入笔记...", 0);
try {
const writer = new VaultWriter(this.app, this.settings);
const file = await writer.writeQANote(question, response);
await graphManager.attachClassification(file, question, confirmed, response.concepts);
await graphManager.appendRecommendations(file, confirmed);
await graphManager.updateQuestionIndex(question, confirmed, file.path);
writeNotice.hide();
new Notice(`✅ 笔记已生成:${file.name}`);
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
if (this.settings.autoOpenGraph) {
const appWithCommands = this.app as unknown as { commands: { executeCommandById: (id: string) => void } };
appWithCommands.commands.executeCommandById("graph:open");
}
void this.triggerAutoBackup(file.path);
} catch (err) {
writeNotice.hide();
new Notice(`❌ 写入失败:${(err as Error).message}`);
console.error("[DeepSeek Plugin]", err);
}
})();
}).open();
} catch (err) {
notice.hide();
new Notice(`❌ 错误:${(err as Error).message}`);
console.error("[DeepSeek Plugin]", err);
}
}
private async openQuestionIndex() {
const indexFolder = normalizePath(this.settings.questionsIndexPath);
const indexPath = normalizePath(`${indexFolder}/问题索引.md`);
let file = this.app.vault.getAbstractFileByPath(indexPath);
if (!file || !(file instanceof TFile)) {
try { await this.app.vault.createFolder(indexFolder); } catch { /* exists */ }
file = await this.app.vault.create(indexPath, "# 问题索引\n\n## 核心问题\n\n## 深化问题\n\n## 扩展问题\n");
}
if (file instanceof TFile) {
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
}
}
private async completeCurrentConcept() {
const manager = new ConceptPageManager(this.app, this.settings);
const info = await manager.analyzeCurrentFile();
if (!info) {
new Notice("当前文件不是概念页,请打开 Knowledge/Concepts 下的概念文件");
return;
}
new DepthSelectModal(this.app, info.conceptName, (depth) => {
void this.runConceptCompletion(info.file, info.conceptName, depth, {
sourceQuestion: info.sourceQuestion,
sourceAnswer: info.sourceAnswer,
});
}).open();
}
private async runConceptCompletion(
file: TFile,
conceptName: string,
depth: CompletionDepth,
context: { sourceQuestion?: string; sourceAnswer?: string }
) {
const notice = new Notice(`⏳ 正在补全概念:${conceptName}...`, 0);
try {
const completer = new ConceptCompleter(this.settings);
const result = await completer.complete(conceptName, depth, context);
notice.hide();
const manager = new ConceptPageManager(this.app, this.settings);
const previewMd = manager.buildPreviewMarkdown(result, depth);
new PreviewModal(
this.app,
conceptName,
previewMd,
() => {
void (async () => {
await manager.writeCompletion(file, result, depth);
new Notice(`✅ 概念页已补全:${conceptName}`);
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
})();
},
() => {
void this.runConceptCompletion(file, conceptName, depth, context);
}
).open();
} catch (err) {
notice.hide();
new Notice(`❌ 补全失败:${(err as Error).message}`);
console.error("[DeepSeek Plugin]", err);
}
}
private runSectionAppend(file: TFile, sectionName: string, content: string) {
const appender = new SectionAppender(this.app, this.settings);
const section = appender.extractSection(content, sectionName);
const existingItems = section?.existing
.split("\n")
.filter((l) => l.trim().startsWith("-"))
.length ?? 0;
const meta = this.app.metadataCache.getFileCache(file);
const conceptName = (meta?.frontmatter?.name as string) || file.basename;
new SectionAppendModal(this.app, sectionName, existingItems, (count) => {
void this.generateAndPreviewSection(file, conceptName, sectionName, section?.existing ?? "", count);
}).open();
}
private async generateAndPreviewSection(
file: TFile,
conceptName: string,
sectionName: string,
existingContent: string,
count: number
) {
const notice = new Notice(`⏳ 生成"${sectionName}"补充内容...`, 0);
try {
const appender = new SectionAppender(this.app, this.settings);
const result = await appender.generate(conceptName, sectionName, existingContent, count);
notice.hide();
new SectionPreviewModal(
this.app,
sectionName,
result.items,
() => {
void (async () => {
await appender.appendToSection(file, sectionName, result.items);
new Notice(`✅ 已追加 ${result.items.length} 条内容到"${sectionName}"`);
})();
},
() => {
void this.generateAndPreviewSection(file, conceptName, sectionName, existingContent, count);
}
).open();
} catch (err) {
notice.hide();
new Notice(`❌ 生成失败:${(err as Error).message}`);
}
}
private async scanAndBatchComplete() {
const notice = new Notice("🔍 扫描空概念页中...", 0);
const manager = new ConceptPageManager(this.app, this.settings);
const empties = await manager.scanEmptyConcepts();
notice.hide();
const items = empties.map((e) => ({ name: e.conceptName, path: e.file.path }));
new BatchScanModal(this.app, items, (selectedPaths, depth) => {
void (async () => {
let done = 0;
for (const path of selectedPaths) {
const abstract = this.app.vault.getAbstractFileByPath(path);
if (!abstract || !(abstract instanceof TFile)) continue;
const info = await manager.analyzeFile(abstract);
if (!info) continue;
const n = new Notice(`⏳ 补全中 (${++done}/${selectedPaths.length})${info.conceptName}`, 0);
try {
const completer = new ConceptCompleter(this.settings);
const result = await completer.complete(info.conceptName, depth, {
sourceQuestion: info.sourceQuestion,
sourceAnswer: info.sourceAnswer,
});
await manager.writeCompletion(abstract, result, depth);
n.hide();
} catch (err) {
n.hide();
new Notice(`${info.conceptName} 补全失败:${(err as Error).message}`);
}
}
new Notice(`✅ 批量补全完成,共 ${done} 个概念页`);
})();
}).open();
}
private async activateSyncView() {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(SYNC_VIEW_TYPE)[0];
if (!leaf) {
leaf = workspace.getRightLeaf(false) ?? workspace.getLeaf(true);
await leaf.setViewState({ type: SYNC_VIEW_TYPE, active: true });
}
await workspace.revealLeaf(leaf);
}
private openBaiduSyncModal() {
if (!this.settings.baiduSync.enabled) {
new Notice("请先在设置中启用百度云同步");
return;
}
new BaiduSyncModal(this.app, this.settings.baiduSync, async (accessToken, expiresAt) => {
this.settings.baiduSync.accessToken = accessToken;
this.settings.baiduSync.tokenExpiresAt = expiresAt;
await this.saveSettings();
}).open();
}
private openBaiduAuthModal() {
if (!this.settings.baiduSync.enabled) {
new Notice("请先在设置中启用百度云同步");
return;
}
new BaiduAuthModal(this.app, this.settings.baiduSync, (accessToken, refreshToken, expiresAt) => {
void (async () => {
this.settings.baiduSync.accessToken = accessToken;
this.settings.baiduSync.refreshToken = refreshToken;
this.settings.baiduSync.tokenExpiresAt = expiresAt;
await this.saveSettings();
})();
}).open();
}
// ── 图表/公式生成 ─────────────────────────────────────────
private openDiagramGenerator(selection: string, context: string, editor: import("obsidian").Editor) {
new DiagramTypeModal(this.app, (type) => {
void this.runDiagramGeneration(selection, type, context, editor);
}).open();
}
private async runDiagramGeneration(
selection: string,
type: DiagramType,
context: string,
editor: import("obsidian").Editor
) {
const notice = new Notice(`⏳ 生成${type === "auto" ? "图表" : type}中...`, 0);
try {
const generator = new DiagramGenerator(this.settings);
const result = await generator.generate(selection, type, context);
notice.hide();
const formatted = generator.formatForInsert(result);
new DiagramPreviewModal(
this.app,
result,
formatted,
() => {
// 插入到选中文字下方
const cursor = editor.getCursor("to");
const insertPos = { line: cursor.line + 1, ch: 0 };
const insertText = `\n${formatted}\n`;
editor.replaceRange(insertText, insertPos);
new Notice(`✅ 已插入${result.typeName}`);
},
() => {
void this.runDiagramGeneration(selection, type, context, editor);
},
(instruction) => {
void this.runDiagramRefine(result.code, instruction, editor);
}
).open();
} catch (err) {
notice.hide();
new Notice(`❌ 生成失败:${(err as Error).message}`);
}
}
private async runDiagramRefine(
existingCode: string,
instruction: string,
editor: import("obsidian").Editor
) {
const notice = new Notice("⏳ 优化图表中...", 0);
try {
const generator = new DiagramGenerator(this.settings);
const result = await generator.refine(existingCode, instruction);
notice.hide();
const formatted = generator.formatForInsert(result);
new DiagramPreviewModal(
this.app,
result,
formatted,
() => {
const cursor = editor.getCursor("to");
const insertPos = { line: cursor.line + 1, ch: 0 };
editor.replaceRange(`\n${formatted}\n`, insertPos);
new Notice(`✅ 已插入优化后的${result.typeName}`);
},
() => {
void this.runDiagramRefine(existingCode, instruction, editor);
},
(newInstruction) => {
void this.runDiagramRefine(result.code, newInstruction, editor);
}
).open();
} catch (err) {
notice.hide();
new Notice(`❌ 优化失败:${(err as Error).message}`);
}
}
private async triggerAutoBackup(filePath: string) {
const cfg = this.settings.baiduSync;
if (!cfg.enabled || !cfg.autoBackup || !cfg.accessToken) return;
try {
const service = new BaiduSyncService(this.app, cfg);
const tokenOk = await service.ensureValidToken();
if (!tokenOk) return;
const folder = filePath.includes("/") ? filePath.substring(0, filePath.lastIndexOf("/")) : "";
await service.backup(folder);
} catch {
// 自动备份失败静默处理
}
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
this.settings.baiduSync = Object.assign({}, DEFAULT_BAIDU_SYNC_CONFIG, this.settings.baiduSync);
if (this.settings.baiduSync.enabled && this.settings.baiduSync.accessToken) {
void this.pullConfig(true);
}
}
async saveSettings() {
await this.saveData(this.settings);
}
async pushConfig() {
const cfg = this.settings.baiduSync;
if (!cfg.enabled || !cfg.accessToken) {
new Notice("请先启用百度云同步并完成授权");
return;
}
const service = new BaiduSyncService(this.app, cfg);
const adapter = this.app.vault.adapter as unknown as { basePath?: string };
const basePath = adapter.basePath ?? "unknown-device";
const ok = await service.pushConfig(this.settings, basePath);
new Notice(ok ? "✅ 配置已推送到百度云" : "❌ 配置推送失败");
}
async pullConfig(silent = false) {
const cfg = this.settings.baiduSync;
if (!cfg.enabled || !cfg.accessToken) return;
const service = new BaiduSyncService(this.app, cfg);
const remote = await service.pullConfig(undefined);
if (!remote) {
if (!silent) new Notice("远端无配置或已是最新");
return;
}
this.settings = BaiduSyncService.applyRemoteConfig(this.settings, remote);
await this.saveData(this.settings);
if (!silent) new Notice(`✅ 已从百度云拉取配置(由 ${remote.deviceId}${remote.updatedAt.slice(0, 10)} 推送)`);
}
}