mirror of
https://github.com/yan-istart/IStart-Note-AI-Plugin.git
synced 2026-07-22 06:51:37 +00:00
对章节进行补充
This commit is contained in:
parent
45c86cf17e
commit
26e80d696b
5 changed files with 362 additions and 2 deletions
22
Readme.md
22
Readme.md
|
|
@ -12,6 +12,7 @@
|
|||
| 框选提问 | 选中文本 → 基于上下文提问 → 生成带来源引用的笔记 |
|
||||
| 问题分类 | 自动判断问题类型(新问题 / 深化 / 扩展)并建立关联 |
|
||||
| 概念补全 | 按需补全空概念页,支持轻量 / 标准两种深度 |
|
||||
| 章节追加 | 对已有章节(如"示例")按需补充更多条目 |
|
||||
| 批量扫描 | 扫描 Vault 中所有空概念页,批量补全 |
|
||||
| 问题索引 | 自动维护问题图谱索引页 |
|
||||
|
||||
|
|
@ -77,6 +78,18 @@ Knowledge/
|
|||
3. 在弹窗中输入针对该内容的问题
|
||||
4. 生成的笔记包含来源引用,并在原笔记末尾追加反向链接
|
||||
|
||||
### 章节内容追加
|
||||
|
||||
对概念页任意章节(示例、关联概念、相关问题等)补充更多条目:
|
||||
|
||||
1. 将光标置于目标章节内(如 `## 示例` 下方任意位置)
|
||||
2. 右键 → `IStart-Note-AI:补充"示例"内容`
|
||||
或命令面板:`为当前章节补充内容`
|
||||
3. 选择本次新增条目数(2 / 3 / 5 / 8)
|
||||
4. 预览生成结果,确认追加或重新生成
|
||||
|
||||
DeepSeek 会读取该章节现有内容作为上下文,**不重复已有条目**,风格保持一致。
|
||||
|
||||
### 概念页补全
|
||||
|
||||
**单个补全:**
|
||||
|
|
@ -210,7 +223,8 @@ src/
|
|||
├── ConceptCompleter.ts # 概念补全 API 调用
|
||||
├── ConceptPageManager.ts # 概念页识别 / 增量写入 / 批量扫描
|
||||
├── ConceptCompletionModal.ts # 深度选择 / 预览确认 / 批量扫描弹窗
|
||||
└── SettingsTab.ts # 设置页面
|
||||
├── SectionAppender.ts # 章节内容提取、DeepSeek 追加生成、写入
|
||||
├── SectionAppendModal.ts # 数量选择弹窗 + 预览确认弹窗
|
||||
```
|
||||
|
||||
### 扩展开发
|
||||
|
|
@ -229,6 +243,12 @@ src/
|
|||
|
||||
## 版本历史
|
||||
|
||||
### v1.4.0
|
||||
- 新增章节内容追加功能(对已有章节按需补充更多条目)
|
||||
- 右键菜单自动识别光标所在章节
|
||||
- 追加内容不重复已有条目,风格一致
|
||||
- 支持预览确认 / 重新生成
|
||||
|
||||
### v1.3.0
|
||||
- 新增框选提问(Context Q&A)功能
|
||||
- 框选提问支持上下文传入、来源引用、反向链接
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "istart-note-ai",
|
||||
"name": "IStart-Note-AI",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"minAppVersion": "1.0.0",
|
||||
"description": "基于 DeepSeek AI 的知识图谱插件:框选提问、概念补全、问题图谱、自动双链",
|
||||
"author": "Your Name",
|
||||
|
|
|
|||
105
src/SectionAppendModal.ts
Normal file
105
src/SectionAppendModal.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { App, Modal, Setting } from "obsidian";
|
||||
|
||||
export class SectionAppendModal extends Modal {
|
||||
private count = 3;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private sectionName: string,
|
||||
private existingCount: number,
|
||||
private onConfirm: (count: number) => void
|
||||
) {
|
||||
super(app);
|
||||
this.titleEl.setText(`补充章节:${sectionName}`);
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
|
||||
contentEl.createEl("p", {
|
||||
text: `当前已有 ${this.existingCount} 条内容,选择本次新增数量:`,
|
||||
attr: { style: "color: var(--text-muted); margin-bottom: 12px;" },
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.setName("新增条目数")
|
||||
.addDropdown((drop) =>
|
||||
drop
|
||||
.addOption("2", "2 条")
|
||||
.addOption("3", "3 条(推荐)")
|
||||
.addOption("5", "5 条")
|
||||
.addOption("8", "8 条")
|
||||
.setValue(String(this.count))
|
||||
.onChange((v) => (this.count = parseInt(v)))
|
||||
);
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText("生成").setCta().onClick(() => {
|
||||
this.close();
|
||||
this.onConfirm(this.count);
|
||||
})
|
||||
)
|
||||
.addButton((btn) => btn.setButtonText("取消").onClick(() => this.close()));
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
export class SectionPreviewModal extends Modal {
|
||||
constructor(
|
||||
app: App,
|
||||
private sectionName: string,
|
||||
private newItems: string[],
|
||||
private onConfirm: () => void,
|
||||
private onRegenerate: () => void
|
||||
) {
|
||||
super(app);
|
||||
this.titleEl.setText(`预览新增内容:${sectionName}`);
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
|
||||
const listEl = contentEl.createDiv({
|
||||
attr: {
|
||||
style: [
|
||||
"border: 1px solid var(--background-modifier-border)",
|
||||
"border-radius: 4px",
|
||||
"padding: 12px",
|
||||
"margin-bottom: 16px",
|
||||
"max-height: 50vh",
|
||||
"overflow-y: auto",
|
||||
].join(";"),
|
||||
},
|
||||
});
|
||||
|
||||
for (const item of this.newItems) {
|
||||
listEl.createEl("div", {
|
||||
text: `• ${item}`,
|
||||
attr: { style: "padding: 3px 0; font-size: 14px;" },
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText("追加写入").setCta().onClick(() => {
|
||||
this.close();
|
||||
this.onConfirm();
|
||||
})
|
||||
)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText("重新生成").onClick(() => {
|
||||
this.close();
|
||||
this.onRegenerate();
|
||||
})
|
||||
)
|
||||
.addButton((btn) => btn.setButtonText("取消").onClick(() => this.close()));
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
148
src/SectionAppender.ts
Normal file
148
src/SectionAppender.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { App, TFile } from "obsidian";
|
||||
import { DeepSeekSettings } from "./types";
|
||||
|
||||
export interface SectionAppendResult {
|
||||
items: string[]; // 新增条目列表
|
||||
raw: string; // 原始追加文本
|
||||
}
|
||||
|
||||
const APPEND_PROMPT = `你是一个个人知识图谱助手。用户希望为概念页的某个章节补充更多内容。
|
||||
|
||||
概念:{{concept}}
|
||||
章节名:{{section}}
|
||||
章节现有内容:
|
||||
{{existing}}
|
||||
|
||||
要求:
|
||||
1. 只生成新增内容,不重复已有条目。
|
||||
2. 风格与现有内容保持一致。
|
||||
3. 生成 {{count}} 条新内容。
|
||||
4. 严格按以下 JSON 格式返回,不要有任何其他内容:
|
||||
{
|
||||
"items": ["新条目1", "新条目2"]
|
||||
}`;
|
||||
|
||||
export class SectionAppender {
|
||||
constructor(private app: App, private settings: DeepSeekSettings) {}
|
||||
|
||||
/** 从文件内容中提取指定 section 的现有内容 */
|
||||
extractSection(content: string, sectionName: string): { existing: string; startIndex: number; endIndex: number } | null {
|
||||
// 匹配 ## sectionName 到下一个 ## 或文件末尾
|
||||
const regex = new RegExp(`(^##\\s+${this.escapeRegex(sectionName)}\\s*\\n)([\\s\\S]*?)(?=\\n##\\s|$)`, "m");
|
||||
const match = content.match(regex);
|
||||
if (!match) return null;
|
||||
|
||||
const startIndex = content.indexOf(match[0]);
|
||||
const headerEnd = startIndex + match[1].length;
|
||||
const endIndex = startIndex + match[0].length;
|
||||
|
||||
return {
|
||||
existing: match[2].trim(),
|
||||
startIndex: headerEnd,
|
||||
endIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/** 识别光标所在的 section 名称 */
|
||||
getSectionAtCursor(content: string, cursorLine: number): string | null {
|
||||
const lines = content.split("\n");
|
||||
// 从光标行向上找最近的 ## 标题
|
||||
for (let i = cursorLine; i >= 0; i--) {
|
||||
const match = lines[i]?.match(/^##\s+(.+)/);
|
||||
if (match) return match[1].trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 调用 DeepSeek 生成追加内容 */
|
||||
async generate(
|
||||
conceptName: string,
|
||||
sectionName: string,
|
||||
existingContent: string,
|
||||
count = 3
|
||||
): Promise<SectionAppendResult> {
|
||||
if (!this.settings.apiKey) {
|
||||
throw new Error("请先配置 API Key");
|
||||
}
|
||||
|
||||
const prompt = APPEND_PROMPT
|
||||
.replace("{{concept}}", conceptName)
|
||||
.replace("{{section}}", sectionName)
|
||||
.replace("{{existing}}", existingContent || "(暂无内容)")
|
||||
.replace("{{count}}", String(count));
|
||||
|
||||
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: "user", content: prompt }],
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`API 错误: ${response.status} - ${err}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const raw = data.choices?.[0]?.message?.content ?? "";
|
||||
return this.parse(raw, sectionName);
|
||||
}
|
||||
|
||||
/** 将新条目追加写入文件的指定 section */
|
||||
async appendToSection(file: TFile, sectionName: string, newItems: string[]): Promise<void> {
|
||||
const content = await this.app.vault.read(file);
|
||||
const section = this.extractSection(content, sectionName);
|
||||
|
||||
if (!section) {
|
||||
// section 不存在则在文件末尾新建
|
||||
const appendText = `\n## ${sectionName}\n${this.formatItems(newItems, sectionName)}\n`;
|
||||
await this.app.vault.modify(file, content.trimEnd() + appendText);
|
||||
return;
|
||||
}
|
||||
|
||||
const newText = this.formatItems(newItems, sectionName);
|
||||
const updated =
|
||||
content.slice(0, section.endIndex).trimEnd() +
|
||||
"\n" + newText + "\n" +
|
||||
content.slice(section.endIndex);
|
||||
|
||||
await this.app.vault.modify(file, updated);
|
||||
}
|
||||
|
||||
private formatItems(items: string[], sectionName: string): string {
|
||||
// 关联概念保持 [[链接]] 格式,其他用列表
|
||||
if (sectionName === "关联概念") {
|
||||
return items.map((item) => {
|
||||
// 如果已经是 [[xxx]]:yyy 格式则直接用,否则包装
|
||||
return item.startsWith("- ") ? item : `- ${item}`;
|
||||
}).join("\n");
|
||||
}
|
||||
return items.map((item) => `- ${item}`).join("\n");
|
||||
}
|
||||
|
||||
private parse(content: string, sectionName: string): SectionAppendResult {
|
||||
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) ||
|
||||
content.match(/(\{[\s\S]*\})/);
|
||||
const jsonStr = jsonMatch ? jsonMatch[1] : content;
|
||||
|
||||
try {
|
||||
const p = JSON.parse(jsonStr.trim());
|
||||
const items: string[] = Array.isArray(p.items) ? p.items : [];
|
||||
return { items, raw: this.formatItems(items, sectionName) };
|
||||
} catch {
|
||||
// 降级:把每行当作一个条目
|
||||
const items = content.split("\n").map((l) => l.replace(/^[-*]\s*/, "").trim()).filter(Boolean);
|
||||
return { items, raw: this.formatItems(items, sectionName) };
|
||||
}
|
||||
}
|
||||
|
||||
private escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
}
|
||||
87
src/main.ts
87
src/main.ts
|
|
@ -12,6 +12,8 @@ import { QuestionGraphManager } from "./QuestionGraphManager";
|
|||
import { QuestionClassifyModal } from "./QuestionClassifyModal";
|
||||
import { ContextQAClient } from "./ContextQAClient";
|
||||
import { ContextQAModal } from "./ContextQAModal";
|
||||
import { SectionAppender } from "./SectionAppender";
|
||||
import { SectionAppendModal, SectionPreviewModal } from "./SectionAppendModal";
|
||||
|
||||
export default class DeepSeekPlugin extends Plugin {
|
||||
settings: DeepSeekSettings;
|
||||
|
|
@ -62,6 +64,25 @@ export default class DeepSeekPlugin extends Plugin {
|
|||
},
|
||||
});
|
||||
|
||||
// 命令:补充当前章节
|
||||
this.addCommand({
|
||||
id: "append-current-section",
|
||||
name: "为当前章节补充内容",
|
||||
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;
|
||||
this.runSectionAppend(activeFile, sectionName, content);
|
||||
},
|
||||
});
|
||||
|
||||
// 命令:问题图谱索引
|
||||
this.addCommand({
|
||||
id: "open-question-index",
|
||||
|
|
@ -115,6 +136,24 @@ export default class DeepSeekPlugin extends Plugin {
|
|||
});
|
||||
}
|
||||
|
||||
// 章节补充入口(光标在 ## 章节内时显示)
|
||||
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:补充"${sectionAtCursor}"内容`)
|
||||
.setIcon("plus-circle")
|
||||
.onClick(() => {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
if (!activeFile) return;
|
||||
this.runSectionAppend(activeFile, sectionAtCursor, fullContent);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 概念补全入口
|
||||
const linkMatch = selection.match(/^\[\[(.+?)(?:\|.+?)?\]\]$/) ||
|
||||
selection.match(/^(.+)$/);
|
||||
|
|
@ -361,6 +400,54 @@ export default class DeepSeekPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
private async 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;
|
||||
|
||||
// 获取概念名(文件名或 frontmatter.name)
|
||||
const meta = this.app.metadataCache.getFileCache(file);
|
||||
const conceptName = (meta?.frontmatter?.name as string) || file.basename;
|
||||
|
||||
new SectionAppendModal(this.app, sectionName, existingItems, (count) => {
|
||||
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,
|
||||
async () => {
|
||||
await appender.appendToSection(file, sectionName, result.items);
|
||||
new Notice(`✅ 已追加 ${result.items.length} 条内容到"${sectionName}"`);
|
||||
},
|
||||
() => {
|
||||
this.generateAndPreviewSection(file, conceptName, sectionName, existingContent, count);
|
||||
}
|
||||
).open();
|
||||
} catch (err) {
|
||||
notice.hide();
|
||||
new Notice(`❌ 生成失败:${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async scanAndBatchComplete() {
|
||||
const notice = new Notice("🔍 扫描空概念页中...", 0);
|
||||
const manager = new ConceptPageManager(this.app, this.settings);
|
||||
|
|
|
|||
Loading…
Reference in a new issue