feat(core): unified LLM client, knowledge index, execution plan infra

This commit is contained in:
Yan 2026-05-20 15:17:01 +08:00
parent 04a1486ade
commit d97d6e72c4
15 changed files with 922 additions and 444 deletions

BIN
.DS_Store vendored

Binary file not shown.

122
Readme.md
View file

@ -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
View file

@ -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

Binary file not shown.

View 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(", ")}`;
}
}
}

View 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;
}
}
}

View 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
View 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;
}

View 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);
}
}

View file

@ -0,0 +1,2 @@
export { KnowledgeIndexService } from "./KnowledgeIndexService";
export type { IndexEntry, SearchResult, SearchOptions } from "./KnowledgeIndexService";

View 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
View 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
View 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
View 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

Binary file not shown.