mirror of
https://github.com/yan-istart/IStart-Note-AI-Plugin.git
synced 2026-07-22 06:51:37 +00:00
feat(core): unified LLM client, knowledge index, execution plan infra
This commit is contained in:
parent
04a1486ade
commit
d97d6e72c4
15 changed files with 922 additions and 444 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
122
Readme.md
122
Readme.md
|
|
@ -1,122 +0,0 @@
|
|||
# IStart-Note-AI
|
||||
|
||||
AI-powered knowledge management plugin for Obsidian. One unified AI assistant that helps you build structured notes, read books effectively, and visualize knowledge — all through natural language.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### AI Assistant (unified entry)
|
||||
|
||||
One input handles everything. Select text or place your cursor, then tell the AI what you want:
|
||||
|
||||
- **Expand** — select text, ask to expand or rewrite
|
||||
- **Explain** — select a term, ask to explain
|
||||
- **Diagrams** — describe what you want (flowchart, sequence, state, class, ER, Gantt)
|
||||
- **Formulas** — describe a math expression, get LaTeX
|
||||
- **Complete** — fill empty sections based on context
|
||||
- **Continue** — write more from cursor position
|
||||
- **Summarize** — summarize the current document
|
||||
- **Beautify** — restructure existing content with callouts, links, and visual breaks
|
||||
- **Anything else** — just describe it in natural language
|
||||
|
||||
Quick tags for common actions: `[扩写]` `[解释]` `[画图]` `[补全]` `[续写]` `[总结]` `[公式]` `[时序图]`
|
||||
|
||||
### Structured Output
|
||||
|
||||
AI generates content in professional knowledge-base style:
|
||||
|
||||
- Short paragraphs with visual breaks
|
||||
- Obsidian Callouts (`> [!summary]`, `> [!warning]`, `> [!tip]`)
|
||||
- Automatic Mermaid diagrams where appropriate
|
||||
- Auto-linked `[[concepts]]` to existing pages
|
||||
- Configurable output style (technical, minimal, academic, etc.)
|
||||
|
||||
### Reading Projects
|
||||
|
||||
Turn any book into a structured study plan:
|
||||
|
||||
1. Enter book title (optionally paste table of contents)
|
||||
2. AI generates reading roadmap, chapter relationships, and pre-reading questions
|
||||
3. Record notes as you read
|
||||
4. Generate chapter summaries and take Feynman tests
|
||||
5. Supports resume if generation is interrupted
|
||||
|
||||
### Knowledge Organization
|
||||
|
||||
- Concepts auto-organized into domain subdirectories after completion
|
||||
- Domain MOC index pages with Mermaid overview graphs
|
||||
- Relationship diagrams in concept pages
|
||||
- Question evolution graphs in question index
|
||||
|
||||
### Baidu Cloud Sync
|
||||
|
||||
- Incremental backup / bidirectional sync / force overwrite
|
||||
- Plugin and Obsidian config backup (toolbar, hotkeys, appearance)
|
||||
- Auto backup after note generation
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
- Obsidian 1.7.2 or later
|
||||
- A [DeepSeek API key](https://platform.deepseek.com)
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### From community plugins (recommended)
|
||||
|
||||
1. Settings → Community plugins → Browse
|
||||
2. Search **IStart-Note-AI**
|
||||
3. Install → Enable
|
||||
|
||||
### Manual
|
||||
|
||||
1. Build: `npm install && npm run build`
|
||||
2. Copy `dist/` contents to `.obsidian/plugins/istart-note-ai/`
|
||||
3. Enable in Settings → Community plugins
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings → IStart-Note-AI:
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| API Key | DeepSeek API key | — |
|
||||
| Base URL | API endpoint | `https://api.deepseek.com` |
|
||||
| Model | `deepseek-v4-flash` or `deepseek-v4-pro` | `deepseek-v4-flash` |
|
||||
| Output style | Knowledge-base, technical, minimal, product, academic, story, dashboard | Knowledge-base |
|
||||
| Q&A folder | Where Q&A notes are saved | `Knowledge/Q&A` |
|
||||
| Concepts folder | Where concept pages are saved | `Knowledge/Concepts` |
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Desktop
|
||||
|
||||
- **🧠 Ribbon icon** → Opens command panel
|
||||
- **Right-click in editor** → "IStart-Note-AI: AI 助手"
|
||||
- **Right-click file in sidebar** → "IStart-Note-AI: AI 助手"
|
||||
|
||||
### Mobile
|
||||
|
||||
- **🧠 Ribbon icon** → Opens command panel
|
||||
- Add `AI 助手` to mobile toolbar for one-tap access
|
||||
|
||||
### Workflow
|
||||
|
||||
1. Select text (optional)
|
||||
2. Click 🧠 or right-click → AI 助手
|
||||
3. Type your request (or tap a quick tag, or leave blank for auto-detect)
|
||||
4. Preview result → Confirm
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
322
main.js
322
main.js
|
|
@ -1,322 +0,0 @@
|
|||
/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// src/main.ts
|
||||
var main_exports = {};
|
||||
__export(main_exports, {
|
||||
default: () => DeepSeekPlugin
|
||||
});
|
||||
module.exports = __toCommonJS(main_exports);
|
||||
var import_obsidian4 = require("obsidian");
|
||||
|
||||
// src/types.ts
|
||||
var DEFAULT_SETTINGS = {
|
||||
apiKey: "",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
model: "deepseek-chat",
|
||||
savePath: "Knowledge/Q&A",
|
||||
autoOpenGraph: false
|
||||
};
|
||||
|
||||
// src/DeepSeekClient.ts
|
||||
var SYSTEM_PROMPT = `\u4F60\u662F\u4E00\u4E2A\u77E5\u8BC6\u56FE\u8C31\u6784\u5EFA\u52A9\u624B\u3002\u7528\u6237\u4F1A\u5411\u4F60\u63D0\u95EE\uFF0C\u4F60\u9700\u8981\uFF1A
|
||||
1. \u7ED9\u51FA\u6E05\u6670\u7684\u56DE\u7B54
|
||||
2. \u63D0\u53D6\u5173\u952E\u6982\u5FF5\uFF083-7\u4E2A\uFF09
|
||||
3. \u8BC6\u522B\u6982\u5FF5\u95F4\u7684\u5173\u7CFB\uFF08\u9650\u4E8E\uFF1A\u5F71\u54CD\u3001\u5C5E\u4E8E\u3001\u5BFC\u81F4\u3001\u4F9D\u8D56\u3001\u5BF9\u7ACB\uFF09
|
||||
4. \u751F\u6210\u76F8\u5173\u6807\u7B7E\uFF082-5\u4E2A\uFF09
|
||||
|
||||
\u4E25\u683C\u6309\u7167\u4EE5\u4E0B JSON \u683C\u5F0F\u8FD4\u56DE\uFF0C\u4E0D\u8981\u6709\u4EFB\u4F55\u5176\u4ED6\u5185\u5BB9\uFF1A
|
||||
{
|
||||
"answer": "\u8BE6\u7EC6\u56DE\u7B54",
|
||||
"concepts": ["\u6982\u5FF5A", "\u6982\u5FF5B"],
|
||||
"relations": [
|
||||
{ "from": "\u6982\u5FF5A", "relation": "\u5F71\u54CD", "to": "\u6982\u5FF5B" }
|
||||
],
|
||||
"tags": ["\u6807\u7B7E1", "\u6807\u7B7E2"]
|
||||
}`;
|
||||
var DeepSeekClient = class {
|
||||
constructor(settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
async ask(question) {
|
||||
var _a, _b, _c;
|
||||
if (!this.settings.apiKey) {
|
||||
throw new Error("\u8BF7\u5148\u5728\u63D2\u4EF6\u8BBE\u7F6E\u4E2D\u914D\u7F6E DeepSeek API Key");
|
||||
}
|
||||
const response = await fetch(`${this.settings.baseUrl}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.settings.apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.settings.model,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: question }
|
||||
],
|
||||
temperature: 0.7
|
||||
})
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`DeepSeek API \u9519\u8BEF: ${response.status} - ${err}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const content = (_c = (_b = (_a = data.choices) == null ? void 0 : _a[0]) == null ? void 0 : _b.message) == null ? void 0 : _c.content;
|
||||
if (!content) {
|
||||
throw new Error("DeepSeek \u8FD4\u56DE\u5185\u5BB9\u4E3A\u7A7A");
|
||||
}
|
||||
return this.parseResponse(content);
|
||||
}
|
||||
parseResponse(content) {
|
||||
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) || content.match(/(\{[\s\S]*\})/);
|
||||
const jsonStr = jsonMatch ? jsonMatch[1] : content;
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr.trim());
|
||||
return {
|
||||
answer: parsed.answer || "",
|
||||
concepts: Array.isArray(parsed.concepts) ? parsed.concepts : [],
|
||||
relations: Array.isArray(parsed.relations) ? parsed.relations : [],
|
||||
tags: Array.isArray(parsed.tags) ? parsed.tags : []
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
answer: content,
|
||||
concepts: [],
|
||||
relations: [],
|
||||
tags: []
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// src/VaultWriter.ts
|
||||
var import_obsidian = require("obsidian");
|
||||
var VaultWriter = class {
|
||||
constructor(app, settings) {
|
||||
this.app = app;
|
||||
this.settings = settings;
|
||||
}
|
||||
async writeQANote(question, response) {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const safeTitle = this.sanitizeFilename(question).slice(0, 50);
|
||||
const filename = `${date}-${safeTitle}.md`;
|
||||
const folderPath = (0, import_obsidian.normalizePath)(this.settings.savePath);
|
||||
const filePath = (0, import_obsidian.normalizePath)(`${folderPath}/${filename}`);
|
||||
await this.ensureFolder(folderPath);
|
||||
const content = this.buildNoteContent(question, response);
|
||||
const file = await this.app.vault.create(filePath, content);
|
||||
for (const concept of response.concepts) {
|
||||
await this.ensureConceptNote(concept);
|
||||
}
|
||||
return file;
|
||||
}
|
||||
buildNoteContent(question, response) {
|
||||
const conceptLinks = response.concepts.map((c) => `- [[${c}]]`).join("\n");
|
||||
const relationLines = response.relations.map((r) => `- [[${r.from}]] -${r.relation}-> [[${r.to}]]`).join("\n");
|
||||
const tagLine = response.tags.map((t) => `#${t.replace(/\s+/g, "_")}`).join(" ");
|
||||
return `# ${question}
|
||||
|
||||
## Question
|
||||
${question}
|
||||
|
||||
## Answer
|
||||
${response.answer}
|
||||
|
||||
## Concepts
|
||||
${conceptLinks || "- \u6682\u65E0"}
|
||||
|
||||
## Relations
|
||||
${relationLines || "- \u6682\u65E0"}
|
||||
|
||||
## Tags
|
||||
${tagLine || "\u6682\u65E0\u6807\u7B7E"}
|
||||
`;
|
||||
}
|
||||
async ensureConceptNote(concept) {
|
||||
const folderPath = (0, import_obsidian.normalizePath)("Knowledge/Concepts");
|
||||
const filePath = (0, import_obsidian.normalizePath)(`${folderPath}/${concept}.md`);
|
||||
await this.ensureFolder(folderPath);
|
||||
const exists = this.app.vault.getAbstractFileByPath(filePath);
|
||||
if (!exists) {
|
||||
await this.app.vault.create(
|
||||
filePath,
|
||||
`# ${concept}
|
||||
|
||||
## \u5B9A\u4E49
|
||||
|
||||
## \u5173\u8054
|
||||
|
||||
## \u6765\u6E90
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
async ensureFolder(path) {
|
||||
const exists = this.app.vault.getAbstractFileByPath(path);
|
||||
if (!exists) {
|
||||
await this.app.vault.createFolder(path);
|
||||
}
|
||||
}
|
||||
sanitizeFilename(name) {
|
||||
return name.replace(/[\\/:*?"<>|#\[\]]/g, "-").trim();
|
||||
}
|
||||
};
|
||||
|
||||
// src/QuestionModal.ts
|
||||
var import_obsidian2 = require("obsidian");
|
||||
var QuestionModal = class extends import_obsidian2.Modal {
|
||||
constructor(app, onSubmit) {
|
||||
super(app);
|
||||
this.question = "";
|
||||
this.onSubmit = onSubmit;
|
||||
}
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.createEl("h2", { text: "\u5411 DeepSeek \u63D0\u95EE" });
|
||||
const textArea = contentEl.createEl("textarea", {
|
||||
attr: {
|
||||
placeholder: "\u8F93\u5165\u4F60\u7684\u95EE\u9898...",
|
||||
rows: "4",
|
||||
style: "width:100%; resize:vertical; padding:8px; font-size:14px;"
|
||||
}
|
||||
});
|
||||
textArea.addEventListener("input", () => {
|
||||
this.question = textArea.value;
|
||||
});
|
||||
textArea.addEventListener("keydown", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
||||
this.submit();
|
||||
}
|
||||
});
|
||||
new import_obsidian2.Setting(contentEl).addButton(
|
||||
(btn) => btn.setButtonText("\u63D0\u95EE (Ctrl+Enter)").setCta().onClick(() => this.submit())
|
||||
).addButton(
|
||||
(btn) => btn.setButtonText("\u53D6\u6D88").onClick(() => this.close())
|
||||
);
|
||||
setTimeout(() => textArea.focus(), 50);
|
||||
}
|
||||
submit() {
|
||||
const q = this.question.trim();
|
||||
if (!q)
|
||||
return;
|
||||
this.close();
|
||||
this.onSubmit(q);
|
||||
}
|
||||
onClose() {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
};
|
||||
|
||||
// src/SettingsTab.ts
|
||||
var import_obsidian3 = require("obsidian");
|
||||
var DeepSeekSettingsTab = class extends import_obsidian3.PluginSettingTab {
|
||||
constructor(app, plugin) {
|
||||
super(app, plugin);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
display() {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
containerEl.createEl("h2", { text: "DeepSeek Knowledge Graph \u8BBE\u7F6E" });
|
||||
new import_obsidian3.Setting(containerEl).setName("API Key").setDesc("DeepSeek API Key\uFF08\u5728 platform.deepseek.com \u83B7\u53D6\uFF09").addText(
|
||||
(text) => text.setPlaceholder("sk-...").setValue(this.plugin.settings.apiKey).onChange(async (value) => {
|
||||
this.plugin.settings.apiKey = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
new import_obsidian3.Setting(containerEl).setName("Base URL").setDesc("API \u5730\u5740\uFF0C\u9ED8\u8BA4 https://api.deepseek.com").addText(
|
||||
(text) => text.setPlaceholder("https://api.deepseek.com").setValue(this.plugin.settings.baseUrl).onChange(async (value) => {
|
||||
this.plugin.settings.baseUrl = value.trim() || "https://api.deepseek.com";
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
new import_obsidian3.Setting(containerEl).setName("\u6A21\u578B").setDesc("\u9009\u62E9\u4F7F\u7528\u7684 DeepSeek \u6A21\u578B").addDropdown(
|
||||
(drop) => drop.addOption("deepseek-chat", "deepseek-chat\uFF08\u63A8\u8350\uFF09").addOption("deepseek-reasoner", "deepseek-reasoner\uFF08\u6DF1\u5EA6\u63A8\u7406\uFF09").setValue(this.plugin.settings.model).onChange(async (value) => {
|
||||
this.plugin.settings.model = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
new import_obsidian3.Setting(containerEl).setName("\u7B14\u8BB0\u4FDD\u5B58\u8DEF\u5F84").setDesc("Q&A \u7B14\u8BB0\u5B58\u50A8\u76EE\u5F55\uFF08\u76F8\u5BF9\u4E8E Vault \u6839\u76EE\u5F55\uFF09").addText(
|
||||
(text) => text.setPlaceholder("Knowledge/Q&A").setValue(this.plugin.settings.savePath).onChange(async (value) => {
|
||||
this.plugin.settings.savePath = value.trim() || "Knowledge/Q&A";
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
new import_obsidian3.Setting(containerEl).setName("\u81EA\u52A8\u6253\u5F00 Graph View").setDesc("\u751F\u6210\u7B14\u8BB0\u540E\u81EA\u52A8\u6253\u5F00\u56FE\u8C31\u89C6\u56FE").addToggle(
|
||||
(toggle) => toggle.setValue(this.plugin.settings.autoOpenGraph).onChange(async (value) => {
|
||||
this.plugin.settings.autoOpenGraph = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// src/main.ts
|
||||
var DeepSeekPlugin = class extends import_obsidian4.Plugin {
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
this.addRibbonIcon("brain", "DeepSeek \u63D0\u95EE", () => {
|
||||
this.openQuestionModal();
|
||||
});
|
||||
this.addCommand({
|
||||
id: "ask-deepseek",
|
||||
name: "\u5411 DeepSeek \u63D0\u95EE\u5E76\u751F\u6210\u77E5\u8BC6\u7B14\u8BB0",
|
||||
hotkeys: [{ modifiers: ["Mod", "Shift"], key: "d" }],
|
||||
callback: () => this.openQuestionModal()
|
||||
});
|
||||
this.addSettingTab(new DeepSeekSettingsTab(this.app, this));
|
||||
}
|
||||
openQuestionModal() {
|
||||
new QuestionModal(this.app, (question) => {
|
||||
this.processQuestion(question);
|
||||
}).open();
|
||||
}
|
||||
async processQuestion(question) {
|
||||
const notice = new import_obsidian4.Notice("\u23F3 DeepSeek \u601D\u8003\u4E2D...", 0);
|
||||
try {
|
||||
const client = new DeepSeekClient(this.settings);
|
||||
const response = await client.ask(question);
|
||||
const writer = new VaultWriter(this.app, this.settings);
|
||||
const file = await writer.writeQANote(question, response);
|
||||
notice.hide();
|
||||
new import_obsidian4.Notice(`\u2705 \u7B14\u8BB0\u5DF2\u751F\u6210\uFF1A${file.name}`);
|
||||
const leaf = this.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(file);
|
||||
if (this.settings.autoOpenGraph) {
|
||||
this.app.commands.executeCommandById("graph:open");
|
||||
}
|
||||
} catch (err) {
|
||||
notice.hide();
|
||||
new import_obsidian4.Notice(`\u274C \u9519\u8BEF\uFF1A${err.message}`);
|
||||
console.error("[DeepSeek Plugin]", err);
|
||||
}
|
||||
}
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
}
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
};
|
||||
BIN
src/.DS_Store
vendored
BIN
src/.DS_Store
vendored
Binary file not shown.
132
src/core/execution/PlanBuilder.ts
Normal file
132
src/core/execution/PlanBuilder.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { ExecutionPlan, VaultOperation, RiskLevel, PlanSource } from "./types";
|
||||
|
||||
/**
|
||||
* Fluent builder for constructing an ExecutionPlan.
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* const plan = new PlanBuilder("从会议纪要生成行动计划", "assistant")
|
||||
* .createFile("Projects/xxx.md", content)
|
||||
* .appendSection("Meeting/2024-01-01.md", "执行计划", link)
|
||||
* .build();
|
||||
* ```
|
||||
*/
|
||||
export class PlanBuilder {
|
||||
private ops: VaultOperation[] = [];
|
||||
|
||||
constructor(
|
||||
private title: string,
|
||||
private source: PlanSource
|
||||
) {}
|
||||
|
||||
createFile(path: string, content: string): this {
|
||||
this.ops.push({ type: "create-file", path, content });
|
||||
return this;
|
||||
}
|
||||
|
||||
modifyFile(path: string, content: string, description?: string): this {
|
||||
this.ops.push({ type: "modify-file", path, content, description });
|
||||
return this;
|
||||
}
|
||||
|
||||
appendSection(path: string, section: string, content: string): this {
|
||||
this.ops.push({ type: "append-section", path, section, content });
|
||||
return this;
|
||||
}
|
||||
|
||||
replaceSelection(path: string, oldText: string, newText: string): this {
|
||||
this.ops.push({ type: "replace-selection", path, oldText, newText });
|
||||
return this;
|
||||
}
|
||||
|
||||
moveFile(from: string, to: string): this {
|
||||
this.ops.push({ type: "move-file", from, to });
|
||||
return this;
|
||||
}
|
||||
|
||||
createLink(path: string, target: string, location = "end"): this {
|
||||
this.ops.push({ type: "create-link", path, target, location });
|
||||
return this;
|
||||
}
|
||||
|
||||
updateFrontmatter(path: string, fields: Record<string, unknown>): this {
|
||||
this.ops.push({ type: "update-frontmatter", path, fields });
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): ExecutionPlan {
|
||||
return {
|
||||
id: this.generateId(),
|
||||
title: this.title,
|
||||
source: this.source,
|
||||
operations: this.ops,
|
||||
previewMarkdown: this.renderPreview(),
|
||||
riskLevel: this.assessRisk(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Internals ──────────────────────────────────────────────
|
||||
|
||||
private generateId(): string {
|
||||
const ts = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 6);
|
||||
return `exec-${ts}-${rand}`;
|
||||
}
|
||||
|
||||
private assessRisk(): RiskLevel {
|
||||
const fileCount = new Set(this.ops.map((op) => this.getPath(op))).size;
|
||||
const hasMoves = this.ops.some((op) => op.type === "move-file");
|
||||
const hasModifies = this.ops.some((op) => op.type === "modify-file");
|
||||
|
||||
if (fileCount >= 5 || hasMoves) return "high";
|
||||
if (fileCount >= 3 || hasModifies) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
private renderPreview(): string {
|
||||
const lines: string[] = [`## 执行计划:${this.title}\n`];
|
||||
const grouped = this.groupByPath();
|
||||
|
||||
for (const [path, ops] of grouped) {
|
||||
lines.push(`### \`${path}\``);
|
||||
for (const op of ops) {
|
||||
lines.push(`- ${this.describeOp(op)}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push(`---`);
|
||||
lines.push(`影响文件:${grouped.size} 个 | 操作数:${this.ops.length} | 风险等级:${this.assessRisk()}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
private groupByPath(): Map<string, VaultOperation[]> {
|
||||
const map = new Map<string, VaultOperation[]>();
|
||||
for (const op of this.ops) {
|
||||
const key = this.getPath(op);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(op);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private getPath(op: VaultOperation): string {
|
||||
switch (op.type) {
|
||||
case "move-file": return op.from;
|
||||
default: return (op as { path: string }).path;
|
||||
}
|
||||
}
|
||||
|
||||
private describeOp(op: VaultOperation): string {
|
||||
switch (op.type) {
|
||||
case "create-file": return `创建文件`;
|
||||
case "modify-file": return op.description ?? `修改文件内容`;
|
||||
case "append-section": return `追加到 §${op.section}`;
|
||||
case "replace-selection": return `替换选中文本`;
|
||||
case "move-file": return `移动到 \`${op.to}\``;
|
||||
case "create-link": return `添加链接 → [[${op.target}]]`;
|
||||
case "update-frontmatter": return `更新 frontmatter: ${Object.keys(op.fields).join(", ")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
219
src/core/execution/PlanExecutor.ts
Normal file
219
src/core/execution/PlanExecutor.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { App, TFile, normalizePath, parseYaml, stringifyYaml } from "obsidian";
|
||||
import { ExecutionPlan, ExecutionRecord, VaultOperation } from "./types";
|
||||
import { SCHEMA_VERSION, todayIso } from "../schema";
|
||||
|
||||
/**
|
||||
* PlanExecutor — applies an ExecutionPlan to the vault and records the result.
|
||||
*
|
||||
* Current scope (v1):
|
||||
* - Executes operations sequentially.
|
||||
* - Records the result as a markdown note in `Knowledge/_Executions/`.
|
||||
* - Does NOT support rollback yet (planned for v3).
|
||||
*/
|
||||
export class PlanExecutor {
|
||||
constructor(private app: App) {}
|
||||
|
||||
/**
|
||||
* Apply all operations in a plan.
|
||||
* Returns an ExecutionRecord with the outcome.
|
||||
*/
|
||||
async execute(plan: ExecutionPlan): Promise<ExecutionRecord> {
|
||||
const affectedPaths: string[] = [];
|
||||
let error: string | undefined;
|
||||
|
||||
try {
|
||||
for (const op of plan.operations) {
|
||||
await this.applyOp(op);
|
||||
affectedPaths.push(this.getPath(op));
|
||||
}
|
||||
} catch (err) {
|
||||
error = (err as Error).message;
|
||||
}
|
||||
|
||||
const record: ExecutionRecord = {
|
||||
plan,
|
||||
executedAt: new Date().toISOString(),
|
||||
success: !error,
|
||||
affectedPaths: [...new Set(affectedPaths)],
|
||||
error,
|
||||
};
|
||||
|
||||
await this.persistRecord(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
// ── Apply individual operations ────────────────────────────
|
||||
|
||||
private async applyOp(op: VaultOperation): Promise<void> {
|
||||
switch (op.type) {
|
||||
case "create-file":
|
||||
await this.ensureParentFolder(op.path);
|
||||
await this.app.vault.create(op.path, op.content);
|
||||
break;
|
||||
|
||||
case "modify-file": {
|
||||
const file = this.getFile(op.path);
|
||||
await this.app.vault.modify(file, op.content);
|
||||
break;
|
||||
}
|
||||
|
||||
case "append-section": {
|
||||
const file = this.getFile(op.path);
|
||||
const content = await this.app.vault.read(file);
|
||||
const updated = this.appendToSection(content, op.section, op.content);
|
||||
await this.app.vault.modify(file, updated);
|
||||
break;
|
||||
}
|
||||
|
||||
case "replace-selection": {
|
||||
const file = this.getFile(op.path);
|
||||
const content = await this.app.vault.read(file);
|
||||
const updated = content.replace(op.oldText, op.newText);
|
||||
await this.app.vault.modify(file, updated);
|
||||
break;
|
||||
}
|
||||
|
||||
case "move-file": {
|
||||
const file = this.getFile(op.from);
|
||||
await this.ensureParentFolder(op.to);
|
||||
await this.app.vault.rename(file, op.to);
|
||||
break;
|
||||
}
|
||||
|
||||
case "create-link": {
|
||||
const file = this.getFile(op.path);
|
||||
const content = await this.app.vault.read(file);
|
||||
const link = `[[${op.target}]]`;
|
||||
if (content.includes(link)) break; // no duplicates
|
||||
|
||||
if (op.location === "end") {
|
||||
await this.app.vault.modify(file, content.trimEnd() + `\n- ${link}\n`);
|
||||
} else {
|
||||
// Append under section heading
|
||||
const updated = this.appendToSection(content, op.location, `- ${link}`);
|
||||
await this.app.vault.modify(file, updated);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "update-frontmatter": {
|
||||
const file = this.getFile(op.path);
|
||||
const content = await this.app.vault.read(file);
|
||||
const updated = this.patchFrontmatter(content, op.fields);
|
||||
await this.app.vault.modify(file, updated);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────
|
||||
|
||||
private getFile(path: string): TFile {
|
||||
const f = this.app.vault.getAbstractFileByPath(path);
|
||||
if (!f || !(f instanceof TFile)) {
|
||||
throw new Error(`文件不存在:${path}`);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
private async ensureParentFolder(filePath: string): Promise<void> {
|
||||
const parts = filePath.split("/");
|
||||
parts.pop(); // remove filename
|
||||
if (parts.length === 0) return;
|
||||
const folder = normalizePath(parts.join("/"));
|
||||
if (!this.app.vault.getAbstractFileByPath(folder)) {
|
||||
await this.app.vault.createFolder(folder);
|
||||
}
|
||||
}
|
||||
|
||||
private appendToSection(content: string, sectionName: string, text: string): string {
|
||||
const regex = new RegExp(`(^##\\s+${this.escapeRegex(sectionName)}\\s*\\n)`, "m");
|
||||
const match = content.match(regex);
|
||||
if (match && match.index !== undefined) {
|
||||
const insertPos = match.index + match[0].length;
|
||||
return content.slice(0, insertPos) + text + "\n" + content.slice(insertPos);
|
||||
}
|
||||
// Section not found — append at end
|
||||
return content.trimEnd() + `\n\n## ${sectionName}\n${text}\n`;
|
||||
}
|
||||
|
||||
private patchFrontmatter(content: string, fields: Record<string, unknown>): string {
|
||||
const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!fmMatch) {
|
||||
// No frontmatter — create one
|
||||
const fm = stringifyYaml(fields);
|
||||
return `---\n${fm}---\n\n${content}`;
|
||||
}
|
||||
try {
|
||||
const existing = parseYaml(fmMatch[1]) as Record<string, unknown> ?? {};
|
||||
const merged = { ...existing, ...fields };
|
||||
return `---\n${stringifyYaml(merged)}---\n\n${fmMatch[2]}`;
|
||||
} catch {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
private escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// ── Persist execution log ──────────────────────────────────
|
||||
|
||||
private async persistRecord(record: ExecutionRecord): Promise<void> {
|
||||
const folder = normalizePath("Knowledge/_Executions");
|
||||
if (!this.app.vault.getAbstractFileByPath(folder)) {
|
||||
await this.app.vault.createFolder(folder);
|
||||
}
|
||||
|
||||
const date = todayIso();
|
||||
const safeName = record.plan.title.replace(/[\\/:*?"<>|#[\]]/g, "-").slice(0, 40);
|
||||
const fileName = `${date}-${safeName}.md`;
|
||||
let path = normalizePath(`${folder}/${fileName}`);
|
||||
|
||||
// Conflict-safe
|
||||
let suffix = 2;
|
||||
while (this.app.vault.getAbstractFileByPath(path)) {
|
||||
path = normalizePath(`${folder}/${date}-${safeName}-${suffix}.md`);
|
||||
suffix++;
|
||||
}
|
||||
|
||||
const content = this.renderRecord(record);
|
||||
await this.app.vault.create(path, content);
|
||||
}
|
||||
|
||||
private renderRecord(record: ExecutionRecord): string {
|
||||
const { plan } = record;
|
||||
const statusEmoji = record.success ? "✅" : "❌";
|
||||
return `---
|
||||
type: execution
|
||||
schema_version: ${SCHEMA_VERSION}
|
||||
status: ${record.success ? "done" : "failed"}
|
||||
source: ${plan.source}
|
||||
plan_id: ${plan.id}
|
||||
risk_level: ${plan.riskLevel}
|
||||
operations_count: ${plan.operations.length}
|
||||
affected_files: ${record.affectedPaths.length}
|
||||
executed_at: ${record.executedAt}
|
||||
created_at: ${plan.createdAt}
|
||||
---
|
||||
|
||||
# ${statusEmoji} ${plan.title}
|
||||
|
||||
## 执行计划
|
||||
|
||||
${plan.previewMarkdown}
|
||||
|
||||
## 影响文件
|
||||
|
||||
${record.affectedPaths.map((p) => `- [[${p}]]`).join("\n")}
|
||||
${record.error ? `\n## 错误\n\n\`\`\`\n${record.error}\n\`\`\`\n` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private getPath(op: VaultOperation): string {
|
||||
switch (op.type) {
|
||||
case "move-file": return op.from;
|
||||
default: return (op as { path: string }).path;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/core/execution/index.ts
Normal file
16
src/core/execution/index.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export { PlanBuilder } from "./PlanBuilder";
|
||||
export { PlanExecutor } from "./PlanExecutor";
|
||||
export type {
|
||||
ExecutionPlan,
|
||||
ExecutionRecord,
|
||||
VaultOperation,
|
||||
CreateFileOp,
|
||||
ModifyFileOp,
|
||||
AppendSectionOp,
|
||||
ReplaceSelectionOp,
|
||||
MoveFileOp,
|
||||
CreateLinkOp,
|
||||
UpdateFrontmatterOp,
|
||||
RiskLevel,
|
||||
PlanSource,
|
||||
} from "./types";
|
||||
113
src/core/execution/types.ts
Normal file
113
src/core/execution/types.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* ExecutionPlan — the core data structure for the "knowledge → action" pipeline.
|
||||
*
|
||||
* Every vault-modifying batch that the plugin performs (from AI or user request)
|
||||
* should first produce an ExecutionPlan, present a preview to the user, and only
|
||||
* apply changes after confirmation.
|
||||
*
|
||||
* Future: add rollback, dry-run, and diff rendering.
|
||||
*/
|
||||
|
||||
/** A single atomic vault operation. */
|
||||
export type VaultOperation =
|
||||
| CreateFileOp
|
||||
| ModifyFileOp
|
||||
| AppendSectionOp
|
||||
| ReplaceSelectionOp
|
||||
| MoveFileOp
|
||||
| CreateLinkOp
|
||||
| UpdateFrontmatterOp;
|
||||
|
||||
export interface CreateFileOp {
|
||||
type: "create-file";
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ModifyFileOp {
|
||||
type: "modify-file";
|
||||
path: string;
|
||||
/** The full new content. */
|
||||
content: string;
|
||||
/** Optional description of what changed. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AppendSectionOp {
|
||||
type: "append-section";
|
||||
path: string;
|
||||
section: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ReplaceSelectionOp {
|
||||
type: "replace-selection";
|
||||
path: string;
|
||||
/** The text to be replaced (used for display; actual replacement is cursor-based). */
|
||||
oldText: string;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
export interface MoveFileOp {
|
||||
type: "move-file";
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface CreateLinkOp {
|
||||
type: "create-link";
|
||||
/** The file where the link is added. */
|
||||
path: string;
|
||||
/** The target to link to. */
|
||||
target: string;
|
||||
/** Where in the file to add (section heading, or "end"). */
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface UpdateFrontmatterOp {
|
||||
type: "update-frontmatter";
|
||||
path: string;
|
||||
/** Fields to set or overwrite. */
|
||||
fields: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Risk level — determined by how many files are touched and the nature of ops. */
|
||||
export type RiskLevel = "low" | "medium" | "high";
|
||||
|
||||
/** Source feature that generated this plan. */
|
||||
export type PlanSource =
|
||||
| "assistant"
|
||||
| "reading"
|
||||
| "question"
|
||||
| "concept"
|
||||
| "sync"
|
||||
| "beautify"
|
||||
| "manual";
|
||||
|
||||
/**
|
||||
* An ExecutionPlan groups one or more VaultOperations that logically belong
|
||||
* together and should be previewed/confirmed as a unit.
|
||||
*/
|
||||
export interface ExecutionPlan {
|
||||
id: string;
|
||||
title: string;
|
||||
source: PlanSource;
|
||||
operations: VaultOperation[];
|
||||
/** Markdown preview shown to the user before apply. */
|
||||
previewMarkdown: string;
|
||||
riskLevel: RiskLevel;
|
||||
createdAt: string; // ISO datetime
|
||||
}
|
||||
|
||||
/**
|
||||
* Record of a completed execution, persisted as a note
|
||||
* in `Knowledge/_Executions/`.
|
||||
*/
|
||||
export interface ExecutionRecord {
|
||||
plan: ExecutionPlan;
|
||||
executedAt: string;
|
||||
success: boolean;
|
||||
/** Paths that were actually modified (subset of plan if partial failure). */
|
||||
affectedPaths: string[];
|
||||
error?: string;
|
||||
}
|
||||
293
src/core/knowledge/KnowledgeIndexService.ts
Normal file
293
src/core/knowledge/KnowledgeIndexService.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import { App, TFile, CachedMetadata, normalizePath } from "obsidian";
|
||||
|
||||
/**
|
||||
* A single indexed vault entry.
|
||||
* Derived from Obsidian's metadataCache + file stats — no embedding, no external DB.
|
||||
*/
|
||||
export interface IndexEntry {
|
||||
path: string;
|
||||
basename: string;
|
||||
title: string; // first H1 or basename
|
||||
type: string | undefined; // frontmatter.type
|
||||
domain: string | undefined; // frontmatter.domain
|
||||
status: string | undefined; // frontmatter.status
|
||||
tags: string[];
|
||||
headings: string[]; // all ## headings
|
||||
links: string[]; // outgoing [[links]]
|
||||
backlinks: string[]; // populated after full scan
|
||||
concepts: string[]; // frontmatter.concepts (for questions)
|
||||
mtime: number;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
entry: IndexEntry;
|
||||
score: number;
|
||||
/** Which matching criteria contributed. */
|
||||
matchedOn: ("title" | "tag" | "heading" | "link" | "backlink" | "concept" | "domain" | "keyword")[];
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
/** Max results to return. Default 10. */
|
||||
limit?: number;
|
||||
/** Filter by frontmatter type. */
|
||||
types?: string[];
|
||||
/** Filter by domain. */
|
||||
domains?: string[];
|
||||
/** Boost entries that link to or are linked from this file path. */
|
||||
contextFile?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* KnowledgeIndexService — lightweight in-memory index built from metadataCache.
|
||||
*
|
||||
* Design:
|
||||
* - No embedding, no external dependency.
|
||||
* - Built once on load, updated incrementally via metadataCache events.
|
||||
* - Three-layer scoring: exact match → structural (links/backlinks/domain) → keyword.
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* const idx = new KnowledgeIndexService(app);
|
||||
* idx.rebuild();
|
||||
* const results = idx.search("并发控制");
|
||||
* ```
|
||||
*/
|
||||
export class KnowledgeIndexService {
|
||||
private entries: Map<string, IndexEntry> = new Map();
|
||||
|
||||
constructor(private app: App) {}
|
||||
|
||||
// ── Build / Rebuild ────────────────────────────────────────
|
||||
|
||||
/** Full rebuild. Call once on plugin load. */
|
||||
rebuild(): void {
|
||||
this.entries.clear();
|
||||
const files = this.app.vault.getMarkdownFiles();
|
||||
for (const file of files) {
|
||||
this.indexFile(file);
|
||||
}
|
||||
this.computeBacklinks();
|
||||
}
|
||||
|
||||
/** Incremental update for a single file. */
|
||||
updateFile(file: TFile): void {
|
||||
this.indexFile(file);
|
||||
// Recompute backlinks (cheap for incremental — just re-scan outgoing of this file)
|
||||
this.computeBacklinks();
|
||||
}
|
||||
|
||||
/** Remove a file from the index. */
|
||||
removeFile(path: string): void {
|
||||
this.entries.delete(path);
|
||||
// Clean backlinks pointing to this file
|
||||
for (const entry of this.entries.values()) {
|
||||
entry.backlinks = entry.backlinks.filter((b) => b !== path);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Search the index for entries relevant to the given query.
|
||||
*
|
||||
* Scoring layers:
|
||||
* 1. Exact title/basename match (score += 10)
|
||||
* 2. Tag match (score += 5)
|
||||
* 3. Heading match (score += 3)
|
||||
* 4. Link/backlink match (score += 4)
|
||||
* 5. Domain match (score += 3)
|
||||
* 6. Concept list match (score += 4)
|
||||
* 7. Keyword substring in title/headings (score += 2)
|
||||
*
|
||||
* If `contextFile` is provided, entries linked from/to it get +3 boost.
|
||||
*/
|
||||
search(query: string, options: SearchOptions = {}): SearchResult[] {
|
||||
const { limit = 10, types, domains, contextFile } = options;
|
||||
const terms = this.tokenize(query);
|
||||
if (terms.length === 0) return [];
|
||||
|
||||
const contextLinks = contextFile ? this.getLinksOf(contextFile) : new Set<string>();
|
||||
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
for (const entry of this.entries.values()) {
|
||||
// Filters
|
||||
if (types && types.length > 0 && (!entry.type || !types.includes(entry.type))) continue;
|
||||
if (domains && domains.length > 0 && (!entry.domain || !domains.includes(entry.domain))) continue;
|
||||
|
||||
let score = 0;
|
||||
const matchedOn: SearchResult["matchedOn"] = [];
|
||||
|
||||
for (const term of terms) {
|
||||
const lower = term.toLowerCase();
|
||||
|
||||
// 1. Title / basename
|
||||
if (entry.title.toLowerCase().includes(lower) || entry.basename.toLowerCase().includes(lower)) {
|
||||
score += entry.title.toLowerCase() === lower ? 10 : 5;
|
||||
if (!matchedOn.includes("title")) matchedOn.push("title");
|
||||
}
|
||||
|
||||
// 2. Tags
|
||||
if (entry.tags.some((t) => t.toLowerCase().includes(lower))) {
|
||||
score += 5;
|
||||
if (!matchedOn.includes("tag")) matchedOn.push("tag");
|
||||
}
|
||||
|
||||
// 3. Headings
|
||||
if (entry.headings.some((h) => h.toLowerCase().includes(lower))) {
|
||||
score += 3;
|
||||
if (!matchedOn.includes("heading")) matchedOn.push("heading");
|
||||
}
|
||||
|
||||
// 4. Outgoing links
|
||||
if (entry.links.some((l) => l.toLowerCase().includes(lower))) {
|
||||
score += 4;
|
||||
if (!matchedOn.includes("link")) matchedOn.push("link");
|
||||
}
|
||||
|
||||
// 5. Concepts
|
||||
if (entry.concepts.some((c) => c.toLowerCase().includes(lower))) {
|
||||
score += 4;
|
||||
if (!matchedOn.includes("concept")) matchedOn.push("concept");
|
||||
}
|
||||
|
||||
// 6. Domain
|
||||
if (entry.domain && entry.domain.toLowerCase().includes(lower)) {
|
||||
score += 3;
|
||||
if (!matchedOn.includes("domain")) matchedOn.push("domain");
|
||||
}
|
||||
}
|
||||
|
||||
// Context boost
|
||||
if (contextFile && (contextLinks.has(entry.path) || entry.links.includes(contextFile))) {
|
||||
score += 3;
|
||||
}
|
||||
|
||||
if (score > 0) {
|
||||
results.push({ entry, score, matchedOn });
|
||||
}
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.score - a.score);
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
|
||||
/** Get all entries of a given type. */
|
||||
getByType(type: string): IndexEntry[] {
|
||||
return [...this.entries.values()].filter((e) => e.type === type);
|
||||
}
|
||||
|
||||
/** Get all entries in a given domain. */
|
||||
getByDomain(domain: string): IndexEntry[] {
|
||||
return [...this.entries.values()].filter((e) => e.domain === domain);
|
||||
}
|
||||
|
||||
/** Get known domains. */
|
||||
getDomains(): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const e of this.entries.values()) {
|
||||
if (e.domain) set.add(e.domain);
|
||||
}
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
/** Get all concept names (type=concept entries). */
|
||||
getConceptNames(): string[] {
|
||||
return [...this.entries.values()]
|
||||
.filter((e) => e.type === "concept")
|
||||
.map((e) => e.basename);
|
||||
}
|
||||
|
||||
/** Get entry by path. */
|
||||
get(path: string): IndexEntry | undefined {
|
||||
return this.entries.get(path);
|
||||
}
|
||||
|
||||
/** Total entries in the index. */
|
||||
get size(): number {
|
||||
return this.entries.size;
|
||||
}
|
||||
|
||||
// ── Internals ──────────────────────────────────────────────
|
||||
|
||||
private indexFile(file: TFile): void {
|
||||
const meta: CachedMetadata | null = this.app.metadataCache.getFileCache(file);
|
||||
const fm = meta?.frontmatter;
|
||||
|
||||
const title = meta?.headings?.find((h) => h.level === 1)?.heading ?? file.basename;
|
||||
const headings = (meta?.headings ?? [])
|
||||
.filter((h) => h.level === 2)
|
||||
.map((h) => h.heading);
|
||||
const tags = [
|
||||
...(meta?.tags ?? []).map((t) => t.tag.replace(/^#/, "")),
|
||||
...((fm?.tags as string[] | undefined) ?? []),
|
||||
];
|
||||
const links = (meta?.links ?? []).map((l) => l.link);
|
||||
const concepts: string[] = Array.isArray(fm?.concepts) ? (fm!.concepts as string[]) : [];
|
||||
|
||||
const entry: IndexEntry = {
|
||||
path: file.path,
|
||||
basename: file.basename,
|
||||
title,
|
||||
type: fm?.type as string | undefined,
|
||||
domain: fm?.domain as string | undefined,
|
||||
status: fm?.status as string | undefined,
|
||||
tags,
|
||||
headings,
|
||||
links,
|
||||
backlinks: this.entries.get(file.path)?.backlinks ?? [],
|
||||
concepts,
|
||||
mtime: file.stat.mtime,
|
||||
};
|
||||
|
||||
this.entries.set(file.path, entry);
|
||||
}
|
||||
|
||||
private computeBacklinks(): void {
|
||||
// Reset all backlinks
|
||||
for (const entry of this.entries.values()) {
|
||||
entry.backlinks = [];
|
||||
}
|
||||
// Build
|
||||
for (const entry of this.entries.values()) {
|
||||
for (const link of entry.links) {
|
||||
// Resolve link to a path
|
||||
const resolved = this.resolveLink(link, entry.path);
|
||||
if (resolved) {
|
||||
const target = this.entries.get(resolved);
|
||||
if (target && !target.backlinks.includes(entry.path)) {
|
||||
target.backlinks.push(entry.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private resolveLink(link: string, fromPath: string): string | null {
|
||||
// Use Obsidian's resolve to handle relative paths, aliases, etc.
|
||||
const file = this.app.metadataCache.getFirstLinkpathDest(link, fromPath);
|
||||
return file?.path ?? null;
|
||||
}
|
||||
|
||||
private getLinksOf(path: string): Set<string> {
|
||||
const entry = this.entries.get(path);
|
||||
if (!entry) return new Set();
|
||||
const set = new Set<string>();
|
||||
for (const link of entry.links) {
|
||||
const resolved = this.resolveLink(link, path);
|
||||
if (resolved) set.add(resolved);
|
||||
}
|
||||
for (const bl of entry.backlinks) {
|
||||
set.add(bl);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private tokenize(query: string): string[] {
|
||||
// Split on whitespace, punctuation, and common separators
|
||||
return query
|
||||
.split(/[\s,;、,;]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length >= 1);
|
||||
}
|
||||
}
|
||||
2
src/core/knowledge/index.ts
Normal file
2
src/core/knowledge/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { KnowledgeIndexService } from "./KnowledgeIndexService";
|
||||
export type { IndexEntry, SearchResult, SearchOptions } from "./KnowledgeIndexService";
|
||||
37
src/core/llm/JsonExtractor.ts
Normal file
37
src/core/llm/JsonExtractor.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Tolerant JSON extractor for LLM outputs.
|
||||
*
|
||||
* LLMs frequently wrap JSON in ```json ... ``` fences or surround it with
|
||||
* explanatory prose. These helpers locate and parse the JSON payload safely.
|
||||
*/
|
||||
|
||||
/** Locate the JSON-looking substring inside an LLM response. */
|
||||
export function extractJson(raw: string): string {
|
||||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (fenced) return fenced[1];
|
||||
|
||||
const braced = raw.match(/(\{[\s\S]*\})/);
|
||||
if (braced) return braced[1];
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON from an LLM response, returning `fallback` on any failure.
|
||||
* Never throws.
|
||||
*/
|
||||
export function parseJsonSafe<T>(raw: string, fallback: T): T {
|
||||
try {
|
||||
return JSON.parse(extractJson(raw).trim()) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse JSON from an LLM response. Throws if the payload is not valid JSON.
|
||||
* Use when an empty/default value is not acceptable upstream.
|
||||
*/
|
||||
export function parseJsonStrict<T>(raw: string): T {
|
||||
return JSON.parse(extractJson(raw).trim()) as T;
|
||||
}
|
||||
87
src/core/llm/LLMClient.ts
Normal file
87
src/core/llm/LLMClient.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { requestUrl } from "obsidian";
|
||||
import { DeepSeekSettings } from "../../types";
|
||||
|
||||
/**
|
||||
* Unified LLM chat-completion client.
|
||||
*
|
||||
* Centralizes:
|
||||
* - OpenAI-compatible chat-completions request shape
|
||||
* - API-key validation
|
||||
* - Error normalization
|
||||
* - Default headers / timeouts
|
||||
*
|
||||
* All AI feature modules (assistant, classifier, completer, planner, ...)
|
||||
* should use this client instead of calling `requestUrl` directly.
|
||||
*
|
||||
* Provider-agnostic by design: the request payload conforms to the
|
||||
* `chat/completions` schema used by DeepSeek, OpenAI, and most compatible
|
||||
* providers. Adding a new provider should only require subclassing or
|
||||
* swapping out this module.
|
||||
*/
|
||||
|
||||
export interface LLMChatOptions {
|
||||
/** Optional system prompt. Sent as the first message when provided. */
|
||||
systemPrompt?: string;
|
||||
/** Required user prompt. */
|
||||
userPrompt: string;
|
||||
/** Sampling temperature. Defaults to 0.5. */
|
||||
temperature?: number;
|
||||
/**
|
||||
* Override the model name configured in settings.
|
||||
* Useful for tasks that always need a specific model.
|
||||
*/
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export class LLMError extends Error {
|
||||
constructor(public status: number, message: string, public body?: string) {
|
||||
super(message);
|
||||
this.name = "LLMError";
|
||||
}
|
||||
}
|
||||
|
||||
export class LLMClient {
|
||||
constructor(private settings: DeepSeekSettings) {}
|
||||
|
||||
/** Throw a friendly error if the API key is not configured. */
|
||||
ensureApiKey(): void {
|
||||
if (!this.settings.apiKey) {
|
||||
throw new Error("请先在插件设置中配置 API Key");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single chat-completion request.
|
||||
* @returns The raw assistant message content (string, possibly empty).
|
||||
*/
|
||||
async chat(options: LLMChatOptions): Promise<string> {
|
||||
this.ensureApiKey();
|
||||
|
||||
const messages: { role: "system" | "user"; content: string }[] = [];
|
||||
if (options.systemPrompt) {
|
||||
messages.push({ role: "system", content: options.systemPrompt });
|
||||
}
|
||||
messages.push({ role: "user", content: options.userPrompt });
|
||||
|
||||
const res = await requestUrl({
|
||||
url: `${this.settings.baseUrl}/v1/chat/completions`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.settings.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: options.model ?? this.settings.model,
|
||||
messages,
|
||||
temperature: options.temperature ?? 0.5,
|
||||
}),
|
||||
throw: false,
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
throw new LLMError(res.status, `LLM API 错误: ${res.status}`, res.text);
|
||||
}
|
||||
|
||||
return res.json?.choices?.[0]?.message?.content ?? "";
|
||||
}
|
||||
}
|
||||
3
src/core/llm/index.ts
Normal file
3
src/core/llm/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { LLMClient, LLMError } from "./LLMClient";
|
||||
export type { LLMChatOptions } from "./LLMClient";
|
||||
export { extractJson, parseJsonSafe, parseJsonStrict } from "./JsonExtractor";
|
||||
20
src/core/schema.ts
Normal file
20
src/core/schema.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Frontmatter schema version for plugin-managed notes.
|
||||
*
|
||||
* Bump when:
|
||||
* - The shape of frontmatter fields changes (rename/remove/retype).
|
||||
* - The semantics of an existing field change.
|
||||
*
|
||||
* Migration code lives in `src/core/schema/migrations/` (added on first bump).
|
||||
*
|
||||
* Currently affects:
|
||||
* - Concept pages (`type: concept`)
|
||||
* - Question Q&A notes (`type: question`)
|
||||
* - Domain index pages (`type: domain-index`)
|
||||
*/
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
/** Today's date in ISO `YYYY-MM-DD` form, in the local timezone. */
|
||||
export function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
BIN
src/features/.DS_Store
vendored
BIN
src/features/.DS_Store
vendored
Binary file not shown.
Loading…
Reference in a new issue