diff --git a/Readme.md b/Readme.md index 4647c97..e84ae7c 100644 --- a/Readme.md +++ b/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)功能 - 框选提问支持上下文传入、来源引用、反向链接 diff --git a/manifest.json b/manifest.json index 38438dd..d5c333e 100644 --- a/manifest.json +++ b/manifest.json @@ -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", diff --git a/src/SectionAppendModal.ts b/src/SectionAppendModal.ts new file mode 100644 index 0000000..f017b28 --- /dev/null +++ b/src/SectionAppendModal.ts @@ -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(); + } +} diff --git a/src/SectionAppender.ts b/src/SectionAppender.ts new file mode 100644 index 0000000..c491163 --- /dev/null +++ b/src/SectionAppender.ts @@ -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 { + 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 { + 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, "\\$&"); + } +} diff --git a/src/main.ts b/src/main.ts index c6c4d56..a33e01c 100644 --- a/src/main.ts +++ b/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);