From 22fda29f01791f4286cb71e8dadda02ca301df70 Mon Sep 17 00:00:00 2001 From: yan Date: Thu, 7 May 2026 22:11:41 +0800 Subject: [PATCH] release: 1.7.2 --- .DS_Store | Bin 8196 -> 6148 bytes manifest.json | 2 +- package.json | 2 +- src/ai/ReadingPlanner.ts | 284 +++++++ src/ai/SmartCompleter.ts | 165 ++++ .../command-panel/CommandPanelModal.ts | 176 +++++ src/features/concept/ConceptPageManager.ts | 6 +- src/features/reading/ReadingModal.ts | 114 +++ src/features/reading/ReadingProjectManager.ts | 369 +++++++++ .../smart-complete/SmartCompleteModal.ts | 123 +++ src/main.ts | 719 ++++++++++++++++-- styles.css | 106 +++ versions.json | 3 +- 13 files changed, 1991 insertions(+), 78 deletions(-) create mode 100644 src/ai/ReadingPlanner.ts create mode 100644 src/ai/SmartCompleter.ts create mode 100644 src/features/command-panel/CommandPanelModal.ts create mode 100644 src/features/reading/ReadingModal.ts create mode 100644 src/features/reading/ReadingProjectManager.ts create mode 100644 src/features/smart-complete/SmartCompleteModal.ts diff --git a/.DS_Store b/.DS_Store index ad479bfe3c207c9b353a0884112c8c26ce0e20db..63ba550b639bfc442dfdf9205f53372f122038d8 100644 GIT binary patch delta 240 zcmZp1XfcprU|?W$DortDU=RQ@Ie-{MGjdE!6q~50D9QxlfW(r@iwlx+@{@r4T@w?R zPd1QY5oKXWVaQ}CW+*|HoU9{Q=qXiQt!rp(Vx*&BY-Uiaqfl*WWT2y9Vr*7h%gG_C ztZy9@pPiGNm)`?44hR^5R)7I7l!j5glVgOWH>U8hEoSH75M+i33vdHzSCAt&CVpq0 g%r9f90FnTj0dg&fW&zPa4#+zoA2Mu?=b6I{0Q;RL{Qv*} literal 8196 zcmeHMO^ee&7=EYiZd2Be9#$!y0-l7$ZELA49*ni##fTpKs7+hiVwx#UYLQZiS6BQ4 zqIlPf7xAD7dhqH82>t{QUi<~V^U*Z3*)Dq$7w3hUXEN{mW}ZBqOfo3|klMIi0w@50 zfrVjy4Xa&@`B_O=O7@&UB$y9ww`8~IV3>&19a;gcfL1^&pcT*x{3{CJGn<*QE4~Fl%wRV+Z0A`6(UfRO3&rasQ&hz{$VfnZQv-sOSAKFp$B7dl+hvm49~tJBF^FWxBE{k zUpR+E)Pl+KD20z$bTZIJ6?_Psi9eLr`o0UYky8bnLZ#o@)z-ku!3Q zd|^5*ZB;hRO0hcIFsEDPBDT*|XS1Af?BwRTD~-GEz@raQ9W)}BLpUssqk0=#(e)_G zrpmwKzPP3zUT){ALenUBKo@H6=2vt~U1kKL+{rPMg=6i} z^Jtzb=TVp~jf(RDB!HGiJ|k$wRfE*#$Au?8{;3DAAD2J#`$Xo&$4z>b@zQKK{@Ym+ zLxFtSVWpq{sE0PKz(G_%+}%)M`~OSJzyBY^OVgaS0$PE6DnK%IyI#ZK`ODtR^YEH@CRK8599y< diff --git a/manifest.json b/manifest.json index cc65e25..e200554 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "istart-note-ai", "name": "IStart-Note-AI", - "version": "1.7.1", + "version": "1.7.2", "minAppVersion": "1.4.0", "description": "Generate structured knowledge notes from questions and selected text using DeepSeek AI, with automatic concept pages, bidirectional links, and a question graph.", "author": "Yan", diff --git a/package.json b/package.json index 1c6def9..a643656 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "istart-note-ai", - "version": "1.7.1", + "version": "1.7.2", "description": "IStart-Note-AI: DeepSeek-powered knowledge graph plugin for Obsidian", "main": "main.js", "scripts": { diff --git a/src/ai/ReadingPlanner.ts b/src/ai/ReadingPlanner.ts new file mode 100644 index 0000000..9be3ec9 --- /dev/null +++ b/src/ai/ReadingPlanner.ts @@ -0,0 +1,284 @@ +import { requestUrl } from "obsidian"; +import { DeepSeekSettings } from "../types"; + +export interface ReadingPlan { + bookTitle: string; + author: string; + oneLiner: string; + coreQuestions: string[]; + prerequisites: string[]; + chapters: ChapterSkeleton[]; + chapterRelations: string[]; + keyConcepts: string[]; +} + +/** 骨架:只有标题和重要度,不含详细问题 */ +export interface ChapterSkeleton { + number: number; + title: string; + summary: string; + importance: "core" | "recommended" | "optional"; + keyConcepts: string[]; +} + +/** 单章详情:预设问题 */ +export interface ChapterDetail { + number: number; + title: string; + questions: string[]; +} + +export interface ChapterSummaryResult { + summary: string; + answeredQuestions: { question: string; answer: string }[]; + newConcepts: string[]; + connections: string[]; + mermaid?: string; +} + +export interface FeynmanQuestion { + question: string; + difficulty: "basic" | "intermediate" | "advanced"; + hint: string; +} + +// ── Prompts ────────────────────────────────────────────────── + +const SKELETON_PROMPT = `你是一个阅读规划助手。用户准备阅读一本书,请生成全书骨架。 + +书籍信息:{{book_info}} + +{{toc_section}} + +要求: +1. 如果你了解这本书,基于你的知识生成。如果不了解,基于目录推断。 +2. importance: core=必读核心, recommended=推荐, optional=可跳读。 +3. chapterRelations 用 Mermaid graph 语法描述章节逻辑依赖。 +4. keyConcepts 列出全书最重要的 10-15 个概念。 +5. 每章的 summary 只需 1-2 句话。 + +严格按以下 JSON 格式返回: +{ + "bookTitle": "书名", + "author": "作者", + "oneLiner": "一句话概括", + "coreQuestions": ["核心问题1", "核心问题2", "核心问题3"], + "prerequisites": ["前置知识1"], + "chapters": [ + { "number": 1, "title": "章节标题", "summary": "本章讲什么", "importance": "core", "keyConcepts": ["概念1"] } + ], + "chapterRelations": ["Ch1[标题1] --> Ch2[标题2]"], + "keyConcepts": ["概念1", "概念2"] +}`; + +const CHAPTER_DETAIL_PROMPT = `你是一个阅读规划助手。请为以下章节生成"读前问题"——用户带着这些问题去阅读,帮助聚焦重点。 + +书名:{{book}} +章节:第{{number}}章 - {{title}} +章节概要:{{summary}} +本章核心概念:{{concepts}} + +要求: +1. 生成 3-5 个引导性问题,从基础到深入。 +2. 问题应该能通过阅读本章找到答案。 +3. 至少包含 1 个"为什么"类问题和 1 个"如何"类问题。 + +严格按以下 JSON 格式返回: +{ + "questions": ["问题1", "问题2", "问题3", "问题4"] +}`; + +const CHAPTER_SUMMARY_PROMPT = `你是一个阅读助手。用户读完了一个章节,请基于他的笔记生成章节总结。 + +书名:{{book}} +章节:{{chapter}} +用户的笔记内容: +{{notes}} + +读前预设问题: +{{questions}} + +要求: +1. 基于用户笔记生成总结,不要编造用户没提到的内容。 +2. 尝试回答预设问题(如果笔记中有相关信息)。 +3. 提取新出现的概念。 +4. 找出与其他章节的关联。 + +严格按以下 JSON 格式返回: +{ + "summary": "章节总结(2-3段)", + "answeredQuestions": [{ "question": "问题", "answer": "回答" }], + "newConcepts": ["概念1"], + "connections": ["与第X章的关联"], + "mermaid": "graph LR\\n A[概念1] -->|关系| B[概念2]" +}`; + +const FEYNMAN_PROMPT = `你是一个学习检验助手。请基于章节内容提出检验性问题。 + +书名:{{book}} +章节:{{chapter}} +核心概念:{{concepts}} +用户笔记: +{{notes}} + +要求: +1. 5 个检验问题(从简单到深入)。 +2. 需要真正理解才能回答,不是记忆题。 +3. 至少 1 个"如果...会怎样"思考题。 +4. 至少 1 个对比题。 + +返回 JSON: +{ + "questions": [{ "question": "问题", "difficulty": "basic|intermediate|advanced", "hint": "提示" }] +}`; + +export class ReadingPlanner { + constructor(private settings: DeepSeekSettings) {} + + /** 第一步:生成全书骨架(轻量,一次请求) */ + async planSkeleton(bookInfo: string, tableOfContents?: string): Promise { + if (!this.settings.apiKey) throw new Error("请先配置 API Key"); + + const tocSection = tableOfContents + ? `目录(用户提供):\n${tableOfContents}` + : "(用户未提供目录,请基于你对这本书的了解生成)"; + + const prompt = SKELETON_PROMPT + .replace("{{book_info}}", bookInfo) + .replace("{{toc_section}}", tocSection); + + const raw = await this.call(prompt); + return this.parseSkeleton(raw); + } + + /** 第二步:为单章生成预设问题 */ + async generateChapterQuestions( + book: string, + chapter: ChapterSkeleton + ): Promise { + const prompt = CHAPTER_DETAIL_PROMPT + .replace("{{book}}", book) + .replace("{{number}}", String(chapter.number)) + .replace("{{title}}", chapter.title) + .replace("{{summary}}", chapter.summary) + .replace("{{concepts}}", chapter.keyConcepts.join("、") || "未知"); + + const raw = await this.call(prompt); + const parsed = this.extractJson(raw); + + try { + const p = JSON.parse(parsed) as { questions: string[] }; + return { + number: chapter.number, + title: chapter.title, + questions: Array.isArray(p.questions) ? p.questions : [], + }; + } catch { + return { number: chapter.number, title: chapter.title, questions: [] }; + } + } + + /** 生成章节总结 */ + async summarizeChapter( + book: string, + chapter: string, + notes: string, + presetQuestions: string[] + ): Promise { + const prompt = CHAPTER_SUMMARY_PROMPT + .replace("{{book}}", book) + .replace("{{chapter}}", chapter) + .replace("{{notes}}", notes.slice(0, 3000)) + .replace("{{questions}}", presetQuestions.map((q, i) => `${i + 1}. ${q}`).join("\n")); + + const raw = await this.call(prompt); + return this.parseChapterSummary(raw); + } + + /** 费曼检验 */ + async feynmanTest( + book: string, + chapter: string, + concepts: string[], + notes: string + ): Promise { + const prompt = FEYNMAN_PROMPT + .replace("{{book}}", book) + .replace("{{chapter}}", chapter) + .replace("{{concepts}}", concepts.join("、")) + .replace("{{notes}}", notes.slice(0, 2000)); + + const raw = await this.call(prompt); + return this.parseFeynman(raw); + } + + private async call(prompt: string): Promise { + 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: this.settings.model, + messages: [{ role: "user", content: prompt }], + temperature: 0.5, + }), + throw: false, + }); + + if (res.status !== 200) throw new Error(`API 错误: ${res.status}`); + return res.json.choices?.[0]?.message?.content ?? ""; + } + + private parseSkeleton(raw: string): ReadingPlan { + const jsonStr = this.extractJson(raw); + try { + const p = JSON.parse(jsonStr) as ReadingPlan; + return { + bookTitle: p.bookTitle || "未知书名", + author: p.author || "未知作者", + oneLiner: p.oneLiner || "", + coreQuestions: Array.isArray(p.coreQuestions) ? p.coreQuestions : [], + prerequisites: Array.isArray(p.prerequisites) ? p.prerequisites : [], + chapters: Array.isArray(p.chapters) ? p.chapters : [], + chapterRelations: Array.isArray(p.chapterRelations) ? p.chapterRelations : [], + keyConcepts: Array.isArray(p.keyConcepts) ? p.keyConcepts : [], + }; + } catch { + throw new Error("AI 返回格式异常,请重试"); + } + } + + private parseChapterSummary(raw: string): ChapterSummaryResult { + const jsonStr = this.extractJson(raw); + try { + const p = JSON.parse(jsonStr) as ChapterSummaryResult; + return { + summary: p.summary || "", + answeredQuestions: Array.isArray(p.answeredQuestions) ? p.answeredQuestions : [], + newConcepts: Array.isArray(p.newConcepts) ? p.newConcepts : [], + connections: Array.isArray(p.connections) ? p.connections : [], + mermaid: p.mermaid || undefined, + }; + } catch { + return { summary: raw, answeredQuestions: [], newConcepts: [], connections: [] }; + } + } + + private parseFeynman(raw: string): FeynmanQuestion[] { + const jsonStr = this.extractJson(raw); + try { + const p = JSON.parse(jsonStr) as { questions: FeynmanQuestion[] }; + return Array.isArray(p.questions) ? p.questions : []; + } catch { + return []; + } + } + + private extractJson(raw: string): string { + const match = raw.match(/```(?:json)?\s*([\s\S]*?)```/) || raw.match(/(\{[\s\S]*\})/); + return match ? match[1] : raw; + } +} diff --git a/src/ai/SmartCompleter.ts b/src/ai/SmartCompleter.ts new file mode 100644 index 0000000..4591110 --- /dev/null +++ b/src/ai/SmartCompleter.ts @@ -0,0 +1,165 @@ +import { requestUrl } from "obsidian"; +import { DeepSeekSettings } from "../types"; + +export type CompletionMode = + | "concept" // 概念页补全 + | "section" // 空 section 补全 + | "expand" // 选中文字扩写 + | "document" // 文档缺失分析 + | "continue"; // 续写 + +export interface SmartCompletionResult { + mode: CompletionMode; + content: string; // 生成的 Markdown 内容 + explanation?: string; // 简短说明 +} + +const SECTION_PROMPT = `你是一个知识笔记助手。用户有一篇笔记,其中某个章节是空的,请根据文件上下文为该章节生成内容。 + +文件标题:{{title}} +章节名:{{section}} +文件已有内容(供参考): +{{context}} + +要求: +1. 内容与文件主题一致,风格匹配已有内容。 +2. 简洁、准确、适合个人知识库。 +3. 直接输出该章节的正文内容(不要包含章节标题本身)。 +4. 如果是列表类章节(如"示例"、"相关问题"),用 - 列表格式。`; + +const EXPAND_PROMPT = `你是一个知识笔记助手。用户选中了一段文字,请帮助扩写/补全。 + +选中的文字: +{{selection}} + +上下文(选中文字所在文件的部分内容): +{{context}} + +要求: +1. 保持原文风格和语气。 +2. 扩展内容要有实质信息,不要水字数。 +3. 直接输出扩写后的完整段落(包含原文 + 新增内容)。`; + +const CONTINUE_PROMPT = `你是一个知识笔记助手。用户的光标在文件末尾或段落末尾,请续写内容。 + +光标前的内容: +{{before}} + +要求: +1. 自然衔接前文。 +2. 保持风格一致。 +3. 生成 2-4 段有价值的续写内容。 +4. 直接输出续写的内容。`; + +const DOCUMENT_PROMPT = `你是一个知识笔记助手。请分析以下文档,找出可以补充的部分。 + +文档内容: +{{content}} + +要求: +1. 分析文档结构,找出缺失或可以丰富的部分。 +2. 为每个建议生成具体内容。 +3. 严格按以下 JSON 格式返回: +{ + "suggestions": [ + { + "section": "建议补充的章节名(已有章节名或新章节名)", + "reason": "为什么需要补充", + "content": "建议的内容" + } + ] +}`; + +export interface DocumentSuggestion { + section: string; + reason: string; + content: string; +} + +export class SmartCompleter { + constructor(private settings: DeepSeekSettings) {} + + /** 补全空 section */ + async completeSection( + title: string, + sectionName: string, + fileContext: string + ): Promise { + const prompt = SECTION_PROMPT + .replace("{{title}}", title) + .replace("{{section}}", sectionName) + .replace("{{context}}", fileContext.slice(0, 1500)); + + const content = await this.call(prompt); + return { mode: "section", content, explanation: `已补全"${sectionName}"` }; + } + + /** 扩写选中文字 */ + async expand(selection: string, context: string): Promise { + const prompt = EXPAND_PROMPT + .replace("{{selection}}", selection) + .replace("{{context}}", context.slice(0, 1500)); + + const content = await this.call(prompt); + return { mode: "expand", content, explanation: "已扩写选中内容" }; + } + + /** 续写 */ + async continueWriting(beforeCursor: string): Promise { + const prompt = CONTINUE_PROMPT + .replace("{{before}}", beforeCursor.slice(-1500)); + + const content = await this.call(prompt); + return { mode: "continue", content, explanation: "已续写" }; + } + + /** 分析文档缺失部分 */ + async analyzeDocument(content: string): Promise { + const prompt = DOCUMENT_PROMPT + .replace("{{content}}", content.slice(0, 3000)); + + const raw = await this.call(prompt); + return this.parseDocumentSuggestions(raw); + } + + private async call(prompt: string): Promise { + if (!this.settings.apiKey) { + throw new Error("请先配置 API Key"); + } + + 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: this.settings.model, + messages: [{ role: "user", content: prompt }], + temperature: 0.6, + }), + throw: false, + }); + + if (res.status !== 200) { + throw new Error(`API 错误: ${res.status} - ${res.text}`); + } + + return res.json.choices?.[0]?.message?.content ?? ""; + } + + private parseDocumentSuggestions(raw: string): DocumentSuggestion[] { + const jsonMatch = raw.match(/```(?:json)?\s*([\s\S]*?)```/) || raw.match(/(\{[\s\S]*\})/); + const jsonStr = jsonMatch ? jsonMatch[1] : raw; + + try { + const p = JSON.parse(jsonStr.trim()) as Record; + const suggestions = p.suggestions; + if (!Array.isArray(suggestions)) return []; + return suggestions as DocumentSuggestion[]; + } catch { + return []; + } + } +} diff --git a/src/features/command-panel/CommandPanelModal.ts b/src/features/command-panel/CommandPanelModal.ts new file mode 100644 index 0000000..8db79f0 --- /dev/null +++ b/src/features/command-panel/CommandPanelModal.ts @@ -0,0 +1,176 @@ +import { App, Modal, TFile } from "obsidian"; + +export interface PanelAction { + id: string; + icon: string; + label: string; + description?: string; + callback: () => void; +} + +export interface PanelGroup { + title: string; + actions: PanelAction[]; +} + +/** + * 统一命令面板 — 根据上下文动态展示可用操作 + */ +export class CommandPanelModal extends Modal { + private groups: PanelGroup[]; + + constructor(app: App, groups: PanelGroup[]) { + super(app); + this.groups = groups; + this.titleEl.setText("IStart-Note-AI"); + } + + onOpen() { + const { contentEl } = this; + contentEl.addClass("istart-command-panel"); + + let shortcutIndex = 1; + + for (const group of this.groups) { + if (group.actions.length === 0) continue; + + const groupEl = contentEl.createDiv({ cls: "istart-panel-group" }); + groupEl.createEl("div", { text: group.title, cls: "istart-panel-group-title" }); + + for (const action of group.actions) { + const row = groupEl.createDiv({ cls: "istart-panel-action" }); + const currentIndex = shortcutIndex; + + row.createSpan({ text: `${action.icon}`, cls: "istart-panel-action-icon" }); + + const textEl = row.createDiv({ cls: "istart-panel-action-text" }); + textEl.createEl("span", { text: action.label, cls: "istart-panel-action-label" }); + if (action.description) { + textEl.createEl("span", { text: action.description, cls: "istart-panel-action-desc" }); + } + + if (shortcutIndex <= 9) { + row.createSpan({ text: `${shortcutIndex}`, cls: "istart-panel-action-key" }); + } + + row.addEventListener("click", () => { + this.close(); + action.callback(); + }); + + shortcutIndex++; + } + } + + // 键盘快捷键支持 + const handler = (e: KeyboardEvent) => { + const num = parseInt(e.key); + if (num >= 1 && num <= 9) { + const allActions = this.groups.flatMap((g) => g.actions); + const action = allActions[num - 1]; + if (action) { + this.close(); + action.callback(); + } + } + if (e.key === "Escape") { + this.close(); + } + }; + document.addEventListener("keydown", handler); + this.onClose = () => { + document.removeEventListener("keydown", handler); + contentEl.empty(); + }; + } +} + +/** + * 根据当前编辑器状态构建面板分组 + */ +export function buildPanelGroups(context: { + hasSelection: boolean; + selection: string; + isConceptPage: boolean; + isReadingNote: boolean; + isInSection: boolean; + sectionName: string | null; + activeFile: TFile | null; + // 回调 + onAsk: () => void; + onContextQA: () => void; + onNewReading: () => void; + onSmartComplete: () => void; + onDiagram: () => void; + onExpand: () => void; + onContinue: () => void; + onCompleteConcept: () => void; + onScanConcepts: () => void; + onChapterSummary: () => void; + onFeynmanTest: () => void; + onAnalyzeDoc: () => void; + onSectionAppend: () => void; +}): PanelGroup[] { + const groups: PanelGroup[] = []; + + // 通用操作 + const general: PanelAction[] = [ + { id: "ask", icon: "💬", label: "提问", description: "向 AI 提问并生成知识笔记", callback: context.onAsk }, + { id: "reading", icon: "📖", label: "新建阅读项目", description: "输入书名,生成阅读地图", callback: context.onNewReading }, + ]; + groups.push({ title: "通用", actions: general }); + + // 选中文字相关 + if (context.hasSelection) { + const selectionActions: PanelAction[] = [ + { id: "context-qa", icon: "❓", label: "基于选中内容提问", callback: context.onContextQA }, + { id: "diagram", icon: "📊", label: "生成图表 / 公式", callback: context.onDiagram }, + { id: "expand", icon: "📝", label: "扩写选中内容", callback: context.onExpand }, + ]; + groups.push({ title: "选中文字", actions: selectionActions }); + } + + // 编辑操作 + const editActions: PanelAction[] = [ + { id: "smart-complete", icon: "✨", label: "智能补全", description: "自动判断:补全/扩写/续写", callback: context.onSmartComplete }, + ]; + if (!context.hasSelection) { + editActions.push({ id: "continue", icon: "🔄", label: "续写", description: "从光标位置继续写", callback: context.onContinue }); + } + if (context.isInSection && context.sectionName) { + editActions.push({ id: "section-append", icon: "📑", label: `补充"${context.sectionName}"`, callback: context.onSectionAppend }); + } + groups.push({ title: "编辑", actions: editActions }); + + // 概念页 + if (context.isConceptPage) { + groups.push({ + title: "概念页", + actions: [ + { id: "complete-concept", icon: "🧩", label: "补全当前概念页", callback: context.onCompleteConcept }, + { id: "scan-concepts", icon: "🔍", label: "扫描空概念页", callback: context.onScanConcepts }, + ], + }); + } + + // 阅读笔记 + if (context.isReadingNote) { + groups.push({ + title: "阅读笔记", + actions: [ + { id: "chapter-summary", icon: "📋", label: "生成章节总结", callback: context.onChapterSummary }, + { id: "feynman", icon: "🎓", label: "费曼检验", callback: context.onFeynmanTest }, + ], + }); + } + + // 文档工具 + groups.push({ + title: "文档工具", + actions: [ + { id: "analyze", icon: "🔎", label: "分析文档缺失", description: "AI 分析并建议补充内容", callback: context.onAnalyzeDoc }, + ], + }); + + return groups; +} diff --git a/src/features/concept/ConceptPageManager.ts b/src/features/concept/ConceptPageManager.ts index fba1ac8..a001676 100644 --- a/src/features/concept/ConceptPageManager.ts +++ b/src/features/concept/ConceptPageManager.ts @@ -65,15 +65,15 @@ export class ConceptPageManager { ): Promise { const content = await this.app.vault.read(file); const { frontmatter, body } = this.splitFrontmatter(content); - const existingSections = this.getExistingSections(body); - // 清除空占位标题(只有标题没有内容的 section) + // 先清除空占位标题,再判断哪些 section 已有内容 const cleanedBody = this.removeEmptySections(body); + const existingSections = this.getExistingSections(cleanedBody); const newSections = this.buildSections(result, existingSections, depth); const updatedFrontmatter = this.updateFrontmatter(frontmatter, result); - // 合并:保留有内容的部分 + 追加新内容 + // 合并:cleanedBody 只保留顶级标题和有内容的 section,新内容追加 const updatedBody = this.mergeSections(cleanedBody, newSections); const newContent = updatedFrontmatter diff --git a/src/features/reading/ReadingModal.ts b/src/features/reading/ReadingModal.ts new file mode 100644 index 0000000..abff16d --- /dev/null +++ b/src/features/reading/ReadingModal.ts @@ -0,0 +1,114 @@ +import { App, Modal, Setting, Notice } from "obsidian"; + +/** + * 新建阅读项目弹窗 — 输入书名和可选目录 + */ +export class NewReadingModal extends Modal { + private bookInfo = ""; + private toc = ""; + + constructor( + app: App, + private onSubmit: (bookInfo: string, toc?: string) => void + ) { + super(app); + this.titleEl.setText("新建阅读项目"); + } + + onOpen() { + const { contentEl } = this; + + contentEl.createEl("p", { + text: "输入书名(和作者),AI 会生成阅读地图。如果是小众书籍,可以粘贴目录帮助 AI 理解。", + cls: "istart-diagram-hint", + }); + + new Setting(contentEl) + .setName("书名 / 作者") + .setDesc("如:分布式系统:概念与设计 - George Coulouris") + .addText((text) => + text + .setPlaceholder("书名 - 作者") + .onChange((v) => { this.bookInfo = v.trim(); }) + ); + + contentEl.createEl("p", { + text: "目录(可选,粘贴书籍目录可以让 AI 更准确):", + cls: "istart-diagram-hint", + }); + + const tocArea = contentEl.createEl("textarea", { + attr: { + placeholder: "第1章 概述\n第2章 系统模型\n第3章 ...\n\n(留空则由 AI 基于书名推断)", + rows: "8", + }, + cls: "istart-question-textarea", + }); + tocArea.addEventListener("input", () => { this.toc = tocArea.value; }); + + new Setting(contentEl) + .addButton((btn) => + btn.setButtonText("生成阅读地图").setCta().onClick(() => { + if (!this.bookInfo) { + new Notice("请输入书名"); + return; + } + this.close(); + this.onSubmit(this.bookInfo, this.toc.trim() || undefined); + }) + ) + .addButton((btn) => btn.setButtonText("取消").onClick(() => this.close())); + } + + onClose() { + this.contentEl.empty(); + } +} + +/** + * 费曼检验弹窗 — 显示问题列表 + */ +export class FeynmanModal extends Modal { + constructor( + app: App, + private chapter: string, + private questions: { question: string; difficulty: string; hint: string }[] + ) { + super(app); + this.titleEl.setText(`费曼检验:${chapter}`); + } + + onOpen() { + const { contentEl } = this; + + if (this.questions.length === 0) { + contentEl.createEl("p", { text: "暂无检验问题,请先在章节中记录笔记。" }); + new Setting(contentEl).addButton((btn) => btn.setButtonText("关闭").onClick(() => this.close())); + return; + } + + const difficultyLabels: Record = { + basic: "🟢 基础", + intermediate: "🟡 进阶", + advanced: "🔴 深入", + }; + + for (const q of this.questions) { + const row = contentEl.createDiv({ cls: "istart-smart-suggestion-row" }); + const header = row.createDiv({ cls: "istart-smart-suggestion-header" }); + header.createEl("span", { text: difficultyLabels[q.difficulty] || "❓" }); + header.createEl("strong", { text: q.question }); + + const details = row.createEl("details"); + details.createEl("summary", { text: "💡 提示" }); + details.createEl("p", { text: q.hint, cls: "istart-smart-suggestion-reason" }); + } + + new Setting(contentEl) + .addButton((btn) => btn.setButtonText("关闭").onClick(() => this.close())); + } + + onClose() { + this.contentEl.empty(); + } +} diff --git a/src/features/reading/ReadingProjectManager.ts b/src/features/reading/ReadingProjectManager.ts new file mode 100644 index 0000000..334f984 --- /dev/null +++ b/src/features/reading/ReadingProjectManager.ts @@ -0,0 +1,369 @@ +import { App, TFile, normalizePath, Notice } from "obsidian"; +import { ReadingPlan, ChapterSkeleton, ChapterDetail, ChapterSummaryResult, ReadingPlanner } from "../../ai/ReadingPlanner"; +import { DeepSeekSettings } from "../../types"; + +const READING_ROOT = "Knowledge/Reading"; + +export class ReadingProjectManager { + constructor(private app: App, private settings: DeepSeekSettings) {} + + /** + * 创建阅读项目:先建骨架,再逐章生成问题。 + * 支持中断后恢复。 + */ + async createProject( + plan: ReadingPlan, + onProgress?: (current: number, total: number, chapter: string) => void + ): Promise { + const projectFolder = normalizePath(`${READING_ROOT}/${this.sanitize(plan.bookTitle)}`); + await this.ensureFolder(projectFolder); + + // 1. 创建索引页(骨架) + const indexFile = await this.createIndexPage(projectFolder, plan); + + // 2. 创建所有章节笔记(空模板,不含问题) + for (const chapter of plan.chapters) { + await this.createChapterNote(projectFolder, plan.bookTitle, chapter); + } + + // 3. 逐章生成预设问题 + const planner = new ReadingPlanner(this.settings); + let generated = 0; + + for (const chapter of plan.chapters) { + generated++; + onProgress?.(generated, plan.chapters.length, chapter.title); + + try { + const detail = await planner.generateChapterQuestions(plan.bookTitle, chapter); + await this.writeChapterQuestions(projectFolder, chapter, detail); + } catch { + // 单章失败不中断整个流程,后续可以补全 + console.error(`[Reading] Failed to generate questions for chapter ${chapter.number}`); + } + } + + // 4. 更新索引页状态 + await this.updateGenerationStatus(indexFile, generated, plan.chapters.length); + + // 5. 预创建核心概念页 + await this.ensureConceptNotes(plan.keyConcepts, plan.bookTitle); + + return indexFile; + } + + /** + * 补全阅读项目:扫描缺少预设问题的章节,逐个生成。 + */ + async resumeProject( + indexFile: TFile, + onProgress?: (current: number, total: number, chapter: string) => void + ): Promise { + const content = await this.app.vault.read(indexFile); + const meta = this.app.metadataCache.getFileCache(indexFile); + const fm = meta?.frontmatter; + + if (fm?.type !== "reading-project") { + throw new Error("当前文件不是阅读项目索引页"); + } + + const bookTitle = (fm.book as string) || ""; + const projectFolder = indexFile.parent?.path ?? ""; + + // 扫描所有章节笔记,找出缺少问题的 + const chapterFiles = this.app.vault.getMarkdownFiles().filter( + (f) => f.path.startsWith(projectFolder) && f.path !== indexFile.path && f.name !== "_总结.md" + ); + + const incomplete: { file: TFile; number: number; title: string; summary: string; concepts: string[] }[] = []; + + for (const file of chapterFiles) { + const fileMeta = this.app.metadataCache.getFileCache(file); + const fileFm = fileMeta?.frontmatter; + if (fileFm?.type !== "reading-note") continue; + if (fileFm?.questions_generated === true) continue; + + // 读取文件检查是否有预设问题内容 + const fileContent = await this.app.vault.read(file); + const questionsMatch = fileContent.match(/## 读前问题\n([\s\S]*?)(?=\n## )/); + const hasQuestions = questionsMatch + ? questionsMatch[1].split("\n").filter((l) => l.trim().startsWith("- [")).length > 0 + : false; + + if (!hasQuestions) { + incomplete.push({ + file, + number: (fileFm?.chapter as number) || 0, + title: (fileFm?.title as string) || file.basename, + summary: "", + concepts: [], + }); + } + } + + if (incomplete.length === 0) return 0; + + const planner = new ReadingPlanner(this.settings); + let done = 0; + + for (const ch of incomplete) { + done++; + onProgress?.(done, incomplete.length, ch.title); + + try { + const skeleton: ChapterSkeleton = { + number: ch.number, + title: ch.title, + summary: ch.summary, + importance: "recommended", + keyConcepts: ch.concepts, + }; + const detail = await planner.generateChapterQuestions(bookTitle, skeleton); + + // 写入问题到章节文件 + let fileContent = await this.app.vault.read(ch.file); + const questionsLines = detail.questions.map((q) => `- [ ] ${q}`).join("\n"); + + if (fileContent.includes("## 读前问题")) { + fileContent = fileContent.replace( + /## 读前问题\n[\s\S]*?(?=\n## )/, + `## 读前问题\n${questionsLines}\n` + ); + } + + // 更新 frontmatter + fileContent = fileContent.replace( + /questions_generated: false/, + "questions_generated: true" + ); + if (!fileContent.includes("questions_generated")) { + fileContent = fileContent.replace("---\n\n", "questions_generated: true\n---\n\n"); + } + + await this.app.vault.modify(ch.file, fileContent); + } catch { + console.error(`[Reading] Resume failed for chapter ${ch.number}`); + } + } + + // 更新索引页状态 + const totalChapters = chapterFiles.filter((f) => { + const m = this.app.metadataCache.getFileCache(f); + return m?.frontmatter?.type === "reading-note"; + }).length; + await this.updateGenerationStatus(indexFile, totalChapters - (incomplete.length - done), totalChapters); + + return done; + } + + /** 将章节总结写入章节笔记 */ + async writeChapterSummary(file: TFile, result: ChapterSummaryResult): Promise { + let content = await this.app.vault.read(file); + + content = content.replace(/status: (unread|reading)/, "status: done"); + + const summarySection = `## 章节总结\n\n${result.summary}`; + if (content.includes("## 章节总结")) { + content = content.replace(/## 章节总结[\s\S]*?(?=\n## |$)/, summarySection + "\n"); + } else { + content = content.trimEnd() + "\n\n" + summarySection + "\n"; + } + + if (result.answeredQuestions.length > 0) { + const qaLines = result.answeredQuestions + .map((qa) => `**Q: ${qa.question}**\nA: ${qa.answer}`) + .join("\n\n"); + content = content.trimEnd() + `\n\n## 问题回答\n\n${qaLines}\n`; + } + + if (result.mermaid) { + content = content.trimEnd() + `\n\n## 关系图\n\n\`\`\`mermaid\n${result.mermaid}\n\`\`\`\n`; + } + + if (result.connections.length > 0) { + content = content.trimEnd() + `\n\n## 跨章关联\n\n${result.connections.map((c) => `- ${c}`).join("\n")}\n`; + } + + await this.app.vault.modify(file, content); + } + + // ── 私有方法 ─────────────────────────────────────────────── + + private async createIndexPage(folder: string, plan: ReadingPlan): Promise { + const indexPath = normalizePath(`${folder}/_索引.md`); + + const progressLines = plan.chapters.map((ch) => { + const icon = ch.importance === "core" ? "⭐" : ch.importance === "recommended" ? "📖" : "📄"; + return `- [ ] ${icon} 第${ch.number}章:[[${this.sanitize(ch.title)}|${ch.title}]]`; + }); + + const mermaidLines = plan.chapterRelations.length > 0 + ? `\`\`\`mermaid\ngraph LR\n ${plan.chapterRelations.join("\n ")}\n\`\`\`` + : ""; + + const conceptLinks = plan.keyConcepts.map((c) => `[[${c}]]`).join(" · "); + + const content = `--- +type: reading-project +book: "${plan.bookTitle}" +author: "${plan.author}" +started: ${new Date().toISOString().slice(0, 10)} +status: reading +generation_status: generating +chapters_generated: 0 +chapters_total: ${plan.chapters.length} +--- + +# ${plan.bookTitle} + +> ${plan.oneLiner} + +**作者:** ${plan.author} + +## 读完应能回答 + +${plan.coreQuestions.map((q) => `- ${q}`).join("\n")} + +## 前置知识 + +${plan.prerequisites.length > 0 ? plan.prerequisites.map((p) => `- ${p}`).join("\n") : "- 无特殊前置要求"} + +## 阅读进度 + +${progressLines.join("\n")} + +## 章节关系 + +${mermaidLines} + +## 核心概念 + +${conceptLinks} +`; + + const existing = this.app.vault.getAbstractFileByPath(indexPath); + if (existing instanceof TFile) { + await this.app.vault.modify(existing, content); + return existing; + } + return await this.app.vault.create(indexPath, content); + } + + private async createChapterNote(folder: string, bookTitle: string, chapter: ChapterSkeleton): Promise { + const fileName = this.sanitize(chapter.title); + const filePath = normalizePath(`${folder}/${fileName}.md`); + + if (this.app.vault.getAbstractFileByPath(filePath)) return; + + const conceptLinks = chapter.keyConcepts.map((c) => `[[${c}]]`).join(" · "); + + const content = `--- +type: reading-note +book: "${bookTitle}" +chapter: ${chapter.number} +title: "${chapter.title}" +status: unread +importance: ${chapter.importance} +questions_generated: false +--- + +# 第${chapter.number}章:${chapter.title} + +> ${chapter.summary} + +## 读前问题 + +(生成中...) + +## 要点 + +- + +## 摘抄 + +> + +## 疑问 + +- + +## 我的理解 + + + +## 关联概念 + +${conceptLinks} + +## 章节总结 + +`; + + await this.app.vault.create(filePath, content); + } + + private async writeChapterQuestions(folder: string, chapter: ChapterSkeleton, detail: ChapterDetail): Promise { + const fileName = this.sanitize(chapter.title); + const filePath = normalizePath(`${folder}/${fileName}.md`); + + const file = this.app.vault.getAbstractFileByPath(filePath); + if (!file || !(file instanceof TFile)) return; + + let content = await this.app.vault.read(file); + + const questionsLines = detail.questions.map((q) => `- [ ] ${q}`).join("\n"); + + // 替换占位文本 + content = content.replace( + /## 读前问题\n\n(生成中\.\.\.)/, + `## 读前问题\n\n${questionsLines}` + ); + + // 更新 frontmatter + content = content.replace("questions_generated: false", "questions_generated: true"); + + await this.app.vault.modify(file, content); + } + + private async updateGenerationStatus(indexFile: TFile, generated: number, total: number): Promise { + let content = await this.app.vault.read(indexFile); + + const status = generated >= total ? "complete" : "partial"; + content = content.replace(/generation_status: \w+/, `generation_status: ${status}`); + content = content.replace(/chapters_generated: \d+/, `chapters_generated: ${generated}`); + + await this.app.vault.modify(indexFile, content); + } + + private async ensureConceptNotes(concepts: string[], bookTitle: string): Promise { + const conceptsPath = normalizePath(this.settings.conceptsPath || "Knowledge/Concepts"); + const uncategorizedPath = normalizePath(`${conceptsPath}/_未分类`); + await this.ensureFolder(uncategorizedPath); + + const today = new Date().toISOString().slice(0, 10); + + for (const concept of concepts) { + const existing = this.app.vault.getMarkdownFiles().find( + (f) => f.path.startsWith(conceptsPath) && f.basename === concept + ); + if (existing) continue; + + const filePath = normalizePath(`${uncategorizedPath}/${concept}.md`); + if (!this.app.vault.getAbstractFileByPath(filePath)) { + await this.app.vault.create(filePath, + `---\ntype: concept\nname: ${concept}\nstatus: empty\ncompletion_status: pending\ncreated_from: reading\nsource_book: "${bookTitle}"\ncreated_at: ${today}\n---\n\n# ${concept}\n\n## 定义\n\n## 核心解释\n\n## 示例\n\n## 关联概念\n\n## 相关问题\n\n## 来源\n` + ); + } + } + } + + private async ensureFolder(path: string): Promise { + if (!this.app.vault.getAbstractFileByPath(path)) { + await this.app.vault.createFolder(path).catch(() => {}); + } + } + + private sanitize(name: string): string { + return name.replace(/[\\/:*?"<>|#[\]]/g, "-").replace(/\s+/g, " ").trim(); + } +} diff --git a/src/features/smart-complete/SmartCompleteModal.ts b/src/features/smart-complete/SmartCompleteModal.ts new file mode 100644 index 0000000..351331b --- /dev/null +++ b/src/features/smart-complete/SmartCompleteModal.ts @@ -0,0 +1,123 @@ +import { App, Modal, Setting, MarkdownRenderer, Component } from "obsidian"; +import { DocumentSuggestion } from "../../ai/SmartCompleter"; + +/** + * 文档分析结果弹窗 — 显示建议补充的内容 + */ +export class DocumentAnalysisModal extends Modal { + private component: Component; + private selectedIndices = new Set(); + + constructor( + app: App, + private suggestions: DocumentSuggestion[], + private onConfirm: (selected: DocumentSuggestion[]) => void + ) { + super(app); + this.component = new Component(); + this.titleEl.setText("文档分析 — 建议补充"); + } + + onOpen() { + const { contentEl } = this; + + if (this.suggestions.length === 0) { + contentEl.createEl("p", { text: "文档结构完整,暂无补充建议。" }); + new Setting(contentEl).addButton((btn) => btn.setButtonText("关闭").onClick(() => this.close())); + return; + } + + contentEl.createEl("p", { + text: `发现 ${this.suggestions.length} 处可补充内容,勾选后插入:`, + cls: "istart-diagram-hint", + }); + + const listEl = contentEl.createDiv({ cls: "istart-smart-suggestions" }); + + for (let i = 0; i < this.suggestions.length; i++) { + const s = this.suggestions[i]; + const row = listEl.createDiv({ cls: "istart-smart-suggestion-row" }); + + const header = row.createDiv({ cls: "istart-smart-suggestion-header" }); + const cb = header.createEl("input", { type: "checkbox" }); + header.createEl("strong", { text: `${s.section}` }); + header.createEl("span", { text: ` — ${s.reason}`, cls: "istart-smart-suggestion-reason" }); + + // 预览内容 + const previewEl = row.createDiv({ cls: "istart-smart-suggestion-preview" }); + void MarkdownRenderer.render(this.app, s.content, previewEl, "", this.component); + + cb.addEventListener("change", () => { + if (cb.checked) this.selectedIndices.add(i); + else this.selectedIndices.delete(i); + }); + } + + new Setting(contentEl) + .addButton((btn) => + btn.setButtonText("插入选中").setCta().onClick(() => { + const selected = [...this.selectedIndices].map((i) => this.suggestions[i]); + this.close(); + this.onConfirm(selected); + }) + ) + .addButton((btn) => + btn.setButtonText("全部插入").onClick(() => { + this.close(); + this.onConfirm(this.suggestions); + }) + ) + .addButton((btn) => btn.setButtonText("取消").onClick(() => this.close())); + } + + onClose() { + this.component.unload(); + this.contentEl.empty(); + } +} + +/** + * 扩写/续写预览弹窗 + */ +export class SmartPreviewModal extends Modal { + private component: Component; + + constructor( + app: App, + private title: string, + private content: string, + private onConfirm: () => void, + private onRegenerate: () => void + ) { + super(app); + this.component = new Component(); + } + + onOpen() { + const { contentEl } = this; + this.titleEl.setText(this.title); + + const previewEl = contentEl.createDiv({ cls: "istart-diagram-preview" }); + void MarkdownRenderer.render(this.app, this.content, previewEl, "", this.component); + + 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.component.unload(); + this.contentEl.empty(); + } +} diff --git a/src/main.ts b/src/main.ts index 6055e0b..ecbc117 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,12 @@ import { DEFAULT_BAIDU_SYNC_CONFIG } from "./types"; import { BaiduSyncView, SYNC_VIEW_TYPE } from "./features/sync/BaiduSyncView"; import { DiagramGenerator, DiagramType } from "./ai/DiagramGenerator"; import { DiagramTypeModal, DiagramPreviewModal } from "./features/diagram/DiagramModal"; +import { SmartCompleter } from "./ai/SmartCompleter"; +import { DocumentAnalysisModal, SmartPreviewModal } from "./features/smart-complete/SmartCompleteModal"; +import { ReadingPlanner } from "./ai/ReadingPlanner"; +import { NewReadingModal, FeynmanModal } from "./features/reading/ReadingModal"; +import { ReadingProjectManager } from "./features/reading/ReadingProjectManager"; +import { CommandPanelModal, buildPanelGroups } from "./features/command-panel/CommandPanelModal"; export default class DeepSeekPlugin extends Plugin { settings: DeepSeekSettings; @@ -28,8 +34,8 @@ export default class DeepSeekPlugin extends Plugin { async onload() { await this.loadSettings(); - this.addRibbonIcon("brain", "DeepSeek ask", () => { - this.openQuestionModal(); + this.addRibbonIcon("brain", "IStart-Note-AI", () => { + this.openCommandPanel(); }); this.addRibbonIcon("cloud", "Baidu cloud sync status", () => { @@ -44,6 +50,13 @@ export default class DeepSeekPlugin extends Plugin { callback: () => this.openQuestionModal(), }); + // 命令:打开命令面板 + this.addCommand({ + id: "open-panel", + name: "Open command panel", + callback: () => this.openCommandPanel(), + }); + this.addCommand({ id: "complete-current-concept", name: "Complete current concept page", @@ -142,33 +155,157 @@ export default class DeepSeekPlugin extends Plugin { }, }); + // 命令:智能补全(自动判断场景) + this.addCommand({ + id: "smart-complete", + name: "Smart complete (auto-detect context)", + editorCallback: (editor) => { + void this.runSmartComplete(editor); + }, + }); + + // 命令:扩写选中内容 + this.addCommand({ + id: "expand-selection", + name: "Expand selected text", + editorCallback: (editor) => { + const selection = editor.getSelection().trim(); + if (!selection) { + new Notice("请先选中要扩写的文字"); + return; + } + const context = editor.getValue().slice(0, 1500); + void this.runExpand(selection, context, editor); + }, + }); + + // 命令:续写 + this.addCommand({ + id: "continue-writing", + name: "Continue writing from cursor", + editorCallback: (editor) => { + const cursor = editor.getCursor(); + const beforeCursor = editor.getRange({ line: 0, ch: 0 }, cursor); + if (!beforeCursor.trim()) { + new Notice("光标前没有内容可续写"); + return; + } + void this.runContinue(beforeCursor, editor); + }, + }); + + // 命令:分析文档缺失 + this.addCommand({ + id: "analyze-document", + name: "Analyze document and suggest completions", + editorCallback: (editor) => { + const content = editor.getValue(); + if (!content.trim()) { + new Notice("文档为空"); + return; + } + void this.runDocumentAnalysis(content, editor); + }, + }); + + // 命令:新建阅读项目 + this.addCommand({ + id: "new-reading-project", + name: "New reading project (book study)", + callback: () => this.openNewReadingProject(), + }); + + // 命令:生成章节总结 + this.addCommand({ + id: "chapter-summary", + name: "Generate chapter summary", + editorCallback: (editor) => { + void this.runChapterSummary(editor); + }, + }); + + // 命令:费曼检验 + this.addCommand({ + id: "feynman-test", + name: "Feynman test (check understanding)", + editorCallback: (editor) => { + void this.runFeynmanTest(editor); + }, + }); + + // 命令:补全阅读项目(断点续传) + this.addCommand({ + id: "resume-reading-project", + name: "Resume reading project (complete missing chapters)", + callback: () => { void this.resumeReadingProject(); }, + }); + this.addSettingTab(new DeepSeekSettingsTab(this.app, this)); this.registerEvent( this.app.workspace.on("file-menu", (menu, file) => { if (!(file instanceof TFile) || file.extension !== "md") return; - menu.addItem((item) => { - item - .setTitle("IStart-Note-AI: Complete this concept page") - .setIcon("brain") - .onClick(() => { - void (async () => { - const manager = new ConceptPageManager(this.app, this.settings); - const info = await manager.analyzeFile(file); - if (!info) { - new Notice("该文件不是概念页"); - return; - } - new DepthSelectModal(this.app, info.conceptName, (depth) => { - void this.runConceptCompletion(info.file, info.conceptName, depth, { - sourceQuestion: info.sourceQuestion, - sourceAnswer: info.sourceAnswer, - }); - }).open(); - })(); - }); - }); + const fileMeta = this.app.metadataCache.getFileCache(file); + const fileType = fileMeta?.frontmatter?.type as string | undefined; + + // 概念页:补全 + if (fileType === "concept" || file.path.includes("Concepts/")) { + menu.addItem((item) => { + item + .setTitle("IStart-Note-AI: Complete this concept page") + .setIcon("brain") + .onClick(() => { + void (async () => { + const manager = new ConceptPageManager(this.app, this.settings); + const info = await manager.analyzeFile(file); + if (!info) { + new Notice("该文件不是概念页"); + return; + } + new DepthSelectModal(this.app, info.conceptName, (depth) => { + void this.runConceptCompletion(info.file, info.conceptName, depth, { + sourceQuestion: info.sourceQuestion, + sourceAnswer: info.sourceAnswer, + }); + }).open(); + })(); + }); + }); + } + + // 阅读项目索引页:补全缺失章节 + if (fileType === "reading-project") { + menu.addItem((item) => { + item + .setTitle("IStart-Note-AI: Resume reading project") + .setIcon("refresh-cw") + .onClick(() => { void this.resumeReadingProject(); }); + }); + } + + // 阅读章节笔记:生成总结 / 费曼检验 + if (fileType === "reading-note") { + menu.addItem((item) => { + item + .setTitle("IStart-Note-AI: Generate chapter summary") + .setIcon("file-text") + .onClick(() => { + const editor = this.app.workspace.activeEditor?.editor; + if (editor) void this.runChapterSummary(editor); + }); + }); + + menu.addItem((item) => { + item + .setTitle("IStart-Note-AI: Feynman test") + .setIcon("help-circle") + .onClick(() => { + const editor = this.app.workspace.activeEditor?.editor; + if (editor) void this.runFeynmanTest(editor); + }); + }); + } }) ); @@ -197,6 +334,17 @@ export default class DeepSeekPlugin extends Plugin { this.openDiagramGenerator(selection, context, editor); }); }); + + // 扩写选中内容 + menu.addItem((item) => { + item + .setTitle("IStart-Note-AI: Expand selection") + .setIcon("expand") + .onClick(() => { + const context = editor.getValue().slice(0, 1500); + void this.runExpand(selection, context, editor); + }); + }); } const cursor = editor.getCursor(); @@ -219,70 +367,203 @@ export default class DeepSeekPlugin extends Plugin { const linkMatch = selection.match(/^\[\[(.+?)(?:\|.+?)?\]\]$/) || selection.match(/^(.+)$/); const conceptName = linkMatch?.[1]; - if (!conceptName) return; - menu.addItem((item) => { - item - .setTitle(`IStart-Note-AI: Complete concept "${conceptName}"`) - .setIcon("brain") - .onClick(() => { - void (async () => { - const manager = new ConceptPageManager(this.app, this.settings); - const conceptsPath = this.settings.conceptsPath || "Knowledge/Concepts"; + if (conceptName) { + menu.addItem((item) => { + item + .setTitle(`IStart-Note-AI: Complete concept "${conceptName}"`) + .setIcon("brain") + .onClick(() => { + void (async () => { + const manager = new ConceptPageManager(this.app, this.settings); + const conceptsPath = this.settings.conceptsPath || "Knowledge/Concepts"; - // 在所有子目录中查找概念文件 - let conceptFile: TFile | null = null; - const allFiles = this.app.vault.getMarkdownFiles(); - const found = allFiles.find( - (f) => f.path.startsWith(conceptsPath) && f.basename === conceptName - ); - if (found) { - conceptFile = found; - } - - if (!conceptFile) { - const writer = new VaultWriter(this.app, this.settings); - await writer.ensureConceptNote(conceptName); - // 重新查找(现在在 _未分类/ 下) - const created = allFiles.find( + // 在所有子目录中查找概念文件 + let conceptFile: TFile | null = null; + const allFiles = this.app.vault.getMarkdownFiles(); + const found = allFiles.find( (f) => f.path.startsWith(conceptsPath) && f.basename === conceptName ); - if (created) conceptFile = created; - else { - // 直接用路径查找 - const uncatPath = `${conceptsPath}/_未分类/${conceptName}.md`; - const uncatFile = this.app.vault.getAbstractFileByPath(uncatPath); - if (uncatFile instanceof TFile) conceptFile = uncatFile; + if (found) { + conceptFile = found; } - } - if (!conceptFile) { - new Notice(`无法找到或创建概念页:${conceptName}`); - return; - } + if (!conceptFile) { + const writer = new VaultWriter(this.app, this.settings); + await writer.ensureConceptNote(conceptName); + // 重新查找(现在在 _未分类/ 下) + const created = this.app.vault.getMarkdownFiles().find( + (f) => f.path.startsWith(conceptsPath) && f.basename === conceptName + ); + if (created) conceptFile = created; + else { + // 直接用路径查找 + const uncatPath = `${conceptsPath}/_未分类/${conceptName}.md`; + const uncatFile = this.app.vault.getAbstractFileByPath(uncatPath); + if (uncatFile instanceof TFile) conceptFile = uncatFile; + } + } - const targetFile = conceptFile; - const info = await manager.analyzeFile(targetFile); - new DepthSelectModal(this.app, conceptName, (depth) => { - void this.runConceptCompletion(targetFile, conceptName, depth, { - sourceQuestion: info?.sourceQuestion, - sourceAnswer: info?.sourceAnswer, - }); - }).open(); - })(); - }); - }); + if (!conceptFile) { + new Notice(`无法找到或创建概念页:${conceptName}`); + return; + } + const targetFile = conceptFile; + const info = await manager.analyzeFile(targetFile); + new DepthSelectModal(this.app, conceptName, (depth) => { + void this.runConceptCompletion(targetFile, conceptName, depth, { + sourceQuestion: info?.sourceQuestion, + sourceAnswer: info?.sourceAnswer, + }); + }).open(); + })(); + }); + }); + } + + // 根据文件类型显示对应操作 + const activeFile = this.app.workspace.getActiveFile(); + const activeMeta = activeFile ? this.app.metadataCache.getFileCache(activeFile) : null; + const activeType = activeMeta?.frontmatter?.type as string | undefined; + const isConceptPage = activeType === "concept" || (activeFile?.path.includes("Concepts/") ?? false); + const isReadingNote = activeType === "reading-note"; + const isReadingProject = activeType === "reading-project"; + + if (isConceptPage) { + menu.addItem((item) => { + item + .setTitle("IStart-Note-AI: Complete current concept page") + .setIcon("brain") + .onClick(() => { void this.completeCurrentConcept(); }); + }); + } + + if (isReadingNote) { + menu.addItem((item) => { + item + .setTitle("IStart-Note-AI: Generate chapter summary") + .setIcon("file-text") + .onClick(() => { void this.runChapterSummary(editor); }); + }); + menu.addItem((item) => { + item + .setTitle("IStart-Note-AI: Feynman test") + .setIcon("help-circle") + .onClick(() => { void this.runFeynmanTest(editor); }); + }); + } + + if (isReadingProject) { + menu.addItem((item) => { + item + .setTitle("IStart-Note-AI: Resume reading project") + .setIcon("refresh-cw") + .onClick(() => { void this.resumeReadingProject(); }); + }); + } + + // 智能补全(始终可用) menu.addItem((item) => { item - .setTitle("IStart-Note-AI: Complete current concept page") - .setIcon("brain") - .onClick(() => { void this.completeCurrentConcept(); }); + .setTitle("IStart-Note-AI: Smart complete") + .setIcon("sparkles") + .onClick(() => { void this.runSmartComplete(editor); }); + }); + + // 分析文档(始终可用) + menu.addItem((item) => { + item + .setTitle("IStart-Note-AI: Analyze and suggest") + .setIcon("search") + .onClick(() => { + const content = editor.getValue(); + void this.runDocumentAnalysis(content, editor); + }); }); }) ); } + private openCommandPanel() { + const editor = this.app.workspace.activeEditor?.editor; + const activeFile = this.app.workspace.getActiveFile(); + const selection = editor?.getSelection().trim() ?? ""; + const hasSelection = selection.length > 0; + + // 判断当前文件类型 + const meta = activeFile ? this.app.metadataCache.getFileCache(activeFile) : null; + const fm = meta?.frontmatter; + const isConceptPage = fm?.type === "concept" || (activeFile?.path.includes("Concepts/") ?? false); + const isReadingNote = fm?.type === "reading-note"; + + // 判断光标是否在 section 内 + let isInSection = false; + let sectionName: string | null = null; + if (editor) { + const cursor = editor.getCursor(); + const content = editor.getValue(); + const appender = new SectionAppender(this.app, this.settings); + sectionName = appender.getSectionAtCursor(content, cursor.line); + isInSection = sectionName !== null; + } + + const groups = buildPanelGroups({ + hasSelection, + selection, + isConceptPage, + isReadingNote, + isInSection, + sectionName, + activeFile, + onAsk: () => this.openQuestionModal(), + onContextQA: () => { + if (editor && hasSelection) { + this.openContextQAModal(selection, activeFile?.path ?? ""); + } + }, + onNewReading: () => this.openNewReadingProject(), + onSmartComplete: () => { if (editor) void this.runSmartComplete(editor); }, + onDiagram: () => { + if (editor && hasSelection) { + const context = editor.getValue().slice(0, 800); + this.openDiagramGenerator(selection, context, editor); + } + }, + onExpand: () => { + if (editor && hasSelection) { + const context = editor.getValue().slice(0, 1500); + void this.runExpand(selection, context, editor); + } + }, + onContinue: () => { + if (editor) { + const cursor = editor.getCursor(); + const before = editor.getRange({ line: 0, ch: 0 }, cursor); + void this.runContinue(before, editor); + } + }, + onCompleteConcept: () => { void this.completeCurrentConcept(); }, + onScanConcepts: () => { void this.scanAndBatchComplete(); }, + onChapterSummary: () => { if (editor) void this.runChapterSummary(editor); }, + onFeynmanTest: () => { if (editor) void this.runFeynmanTest(editor); }, + onAnalyzeDoc: () => { + if (editor) { + const content = editor.getValue(); + void this.runDocumentAnalysis(content, editor); + } + }, + onSectionAppend: () => { + if (editor && activeFile && sectionName) { + const content = editor.getValue(); + void this.runSectionAppend(activeFile, sectionName, content); + } + }, + }); + + new CommandPanelModal(this.app, groups).open(); + } + private openContextQAModal(selectedText: string, sourceNotePath: string) { new ContextQAModal(this.app, selectedText, (question) => { void this.processContextQA(question, selectedText, sourceNotePath); @@ -606,6 +887,300 @@ export default class DeepSeekPlugin extends Plugin { }).open(); } + // ── 阅读项目 ───────────────────────────────────────────── + + private openNewReadingProject() { + new NewReadingModal(this.app, (bookInfo, toc) => { + void this.createReadingProject(bookInfo, toc); + }).open(); + } + + private async createReadingProject(bookInfo: string, toc?: string) { + const notice = new Notice("⏳ 生成全书骨架...", 0); + try { + const planner = new ReadingPlanner(this.settings); + const plan = await planner.planSkeleton(bookInfo, toc); + notice.setMessage(`✍️ 创建项目结构(${plan.chapters.length} 章)...`); + + const manager = new ReadingProjectManager(this.app, this.settings); + const indexFile = await manager.createProject(plan, (current, total, chapter) => { + notice.setMessage(`⏳ 生成预设问题 (${current}/${total}):${chapter}`); + }); + notice.hide(); + + new Notice(`✅ 阅读项目已创建:${plan.bookTitle}(${plan.chapters.length} 章)`); + const leaf = this.app.workspace.getLeaf(false); + await leaf.openFile(indexFile); + } catch (err) { + notice.hide(); + new Notice(`❌ 创建失败:${(err as Error).message}`); + console.error("[IStart-Note-AI]", err); + } + } + + private async resumeReadingProject() { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { new Notice("请先打开阅读项目的索引页"); return; } + + const meta = this.app.metadataCache.getFileCache(activeFile); + if (meta?.frontmatter?.type !== "reading-project") { + new Notice("当前文件不是阅读项目索引页"); + return; + } + + const notice = new Notice("⏳ 补全缺失章节...", 0); + try { + const manager = new ReadingProjectManager(this.app, this.settings); + const count = await manager.resumeProject(activeFile, (current, total, chapter) => { + notice.setMessage(`⏳ 补全 (${current}/${total}):${chapter}`); + }); + notice.hide(); + + if (count === 0) { + new Notice("✅ 所有章节已完整,无需补全"); + } else { + new Notice(`✅ 已补全 ${count} 个章节的预设问题`); + } + } catch (err) { + notice.hide(); + new Notice(`❌ 补全失败:${(err as Error).message}`); + } + } + + private async runChapterSummary(editor: import("obsidian").Editor) { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { new Notice("请先打开一个章节笔记"); return; } + + const content = editor.getValue(); + const meta = this.app.metadataCache.getFileCache(activeFile); + const fm = meta?.frontmatter; + + if (fm?.type !== "reading-note") { + new Notice("当前文件不是阅读章节笔记"); + return; + } + + const book = (fm.book as string) || "未知"; + const chapter = `第${fm.chapter}章:${fm.title}`; + + // 提取预设问题 + const questionsMatch = content.match(/## 读前问题\n([\s\S]*?)(?=\n## )/); + const questions = questionsMatch + ? questionsMatch[1].split("\n").filter((l) => l.trim().startsWith("- ")).map((l) => l.replace(/^- \[.\]\s*/, "").trim()) + : []; + + const notice = new Notice("⏳ 生成章节总结...", 0); + try { + const planner = new ReadingPlanner(this.settings); + const result = await planner.summarizeChapter(book, chapter, content, questions); + notice.hide(); + + const manager = new ReadingProjectManager(this.app, this.settings); + await manager.writeChapterSummary(activeFile, result); + new Notice("✅ 章节总结已生成"); + + // 刷新编辑器 + const leaf = this.app.workspace.getLeaf(false); + await leaf.openFile(activeFile); + } catch (err) { + notice.hide(); + new Notice(`❌ 生成失败:${(err as Error).message}`); + } + } + + private async runFeynmanTest(editor: import("obsidian").Editor) { + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { new Notice("请先打开一个章节笔记"); return; } + + const content = editor.getValue(); + const meta = this.app.metadataCache.getFileCache(activeFile); + const fm = meta?.frontmatter; + + if (fm?.type !== "reading-note") { + new Notice("当前文件不是阅读章节笔记"); + return; + } + + const book = (fm.book as string) || "未知"; + const chapter = `第${fm.chapter}章:${fm.title}`; + + // 提取概念 + const conceptsMatch = content.match(/## 关联概念\n([\s\S]*?)(?=\n## |$)/); + const concepts = conceptsMatch + ? conceptsMatch[1].match(/\[\[(.+?)\]\]/g)?.map((m) => m.replace(/\[\[|\]\]/g, "")) ?? [] + : []; + + const notice = new Notice("⏳ 生成检验问题...", 0); + try { + const planner = new ReadingPlanner(this.settings); + const questions = await planner.feynmanTest(book, chapter, concepts, content); + notice.hide(); + + new FeynmanModal(this.app, chapter, questions).open(); + } catch (err) { + notice.hide(); + new Notice(`❌ 生成失败:${(err as Error).message}`); + } + } + + // ── 智能补全 ───────────────────────────────────────────── + + /** + * 智能补全:自动判断场景 + * - 有选中文字 → 扩写 + * - 光标在空 section 内 → 补全该 section + * - 否则 → 续写 + */ + private async runSmartComplete(editor: import("obsidian").Editor) { + const selection = editor.getSelection().trim(); + + if (selection) { + // 场景 A:扩写选中内容 + const context = editor.getValue().slice(0, 1500); + await this.runExpand(selection, context, editor); + return; + } + + // 检查光标是否在空 section 内 + const cursor = editor.getCursor(); + const content = editor.getValue(); + const lines = content.split("\n"); + + // 向上找最近的 ## 标题 + let sectionName: string | null = null; + let sectionStartLine = -1; + for (let i = cursor.line; i >= 0; i--) { + const match = lines[i]?.match(/^##\s+(.+)/); + if (match) { + sectionName = match[1].trim(); + sectionStartLine = i; + break; + } + } + + if (sectionName && sectionStartLine >= 0) { + // 检查该 section 是否为空(标题到下一个 ## 之间没有非空行) + let sectionEmpty = true; + for (let i = sectionStartLine + 1; i < lines.length; i++) { + if (/^##\s/.test(lines[i])) break; + if (lines[i].trim().length > 0) { sectionEmpty = false; break; } + } + + if (sectionEmpty) { + // 场景 B:补全空 section + const activeFile = this.app.workspace.getActiveFile(); + const title = activeFile?.basename ?? "未知"; + await this.runSectionComplete(title, sectionName, content, sectionStartLine, editor); + return; + } + } + + // 场景 C:续写 + const beforeCursor = editor.getRange({ line: 0, ch: 0 }, cursor); + await this.runContinue(beforeCursor, editor); + } + + private async runExpand(selection: string, context: string, editor: import("obsidian").Editor) { + const notice = new Notice("⏳ 扩写中...", 0); + try { + const completer = new SmartCompleter(this.settings); + const result = await completer.expand(selection, context); + notice.hide(); + + new SmartPreviewModal( + this.app, + "扩写预览", + result.content, + () => { + // 替换选中内容 + editor.replaceSelection(result.content); + new Notice("✅ 已扩写"); + }, + () => { void this.runExpand(selection, context, editor); } + ).open(); + } catch (err) { + notice.hide(); + new Notice(`❌ 扩写失败:${(err as Error).message}`); + } + } + + private async runContinue(beforeCursor: string, editor: import("obsidian").Editor) { + const notice = new Notice("⏳ 续写中...", 0); + try { + const completer = new SmartCompleter(this.settings); + const result = await completer.continueWriting(beforeCursor); + notice.hide(); + + new SmartPreviewModal( + this.app, + "续写预览", + result.content, + () => { + const cursor = editor.getCursor(); + editor.replaceRange("\n" + result.content, cursor); + new Notice("✅ 已续写"); + }, + () => { void this.runContinue(beforeCursor, editor); } + ).open(); + } catch (err) { + notice.hide(); + new Notice(`❌ 续写失败:${(err as Error).message}`); + } + } + + private async runSectionComplete( + title: string, + sectionName: string, + fileContent: string, + sectionStartLine: number, + editor: import("obsidian").Editor + ) { + const notice = new Notice(`⏳ 补全"${sectionName}"...`, 0); + try { + const completer = new SmartCompleter(this.settings); + const result = await completer.completeSection(title, sectionName, fileContent); + notice.hide(); + + new SmartPreviewModal( + this.app, + `补全"${sectionName}"`, + result.content, + () => { + // 插入到 section 标题下方 + const insertLine = sectionStartLine + 1; + const insertPos = { line: insertLine, ch: 0 }; + editor.replaceRange(result.content + "\n\n", insertPos); + new Notice(`✅ 已补全"${sectionName}"`); + }, + () => { void this.runSectionComplete(title, sectionName, fileContent, sectionStartLine, editor); } + ).open(); + } catch (err) { + notice.hide(); + new Notice(`❌ 补全失败:${(err as Error).message}`); + } + } + + private async runDocumentAnalysis(content: string, editor: import("obsidian").Editor) { + const notice = new Notice("⏳ 分析文档中...", 0); + try { + const completer = new SmartCompleter(this.settings); + const suggestions = await completer.analyzeDocument(content); + notice.hide(); + + new DocumentAnalysisModal(this.app, suggestions, (selected) => { + // 将选中的建议追加到文档末尾 + const parts = selected.map((s) => `## ${s.section}\n${s.content}`); + const insertText = "\n\n" + parts.join("\n\n") + "\n"; + const lastLine = editor.lastLine(); + editor.replaceRange(insertText, { line: lastLine, ch: editor.getLine(lastLine).length }); + new Notice(`✅ 已插入 ${selected.length} 处补充内容`); + }).open(); + } catch (err) { + notice.hide(); + new Notice(`❌ 分析失败:${(err as Error).message}`); + } + } + // ── 图表/公式生成 ───────────────────────────────────────── private openDiagramGenerator(selection: string, context: string, editor: import("obsidian").Editor) { diff --git a/styles.css b/styles.css index 631cf18..0b2dfd9 100644 --- a/styles.css +++ b/styles.css @@ -225,3 +225,109 @@ background: var(--background-primary); color: var(--text-normal); } + + +/* ── Smart Complete ──────────────────────────────── */ + +.istart-smart-suggestions { + max-height: 50vh; + overflow-y: auto; + margin-bottom: 12px; +} + +.istart-smart-suggestion-row { + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + padding: 10px; + margin-bottom: 8px; +} + +.istart-smart-suggestion-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; +} + +.istart-smart-suggestion-reason { + color: var(--text-muted); + font-size: 12px; +} + +.istart-smart-suggestion-preview { + font-size: 13px; + padding: 8px; + background: var(--background-secondary); + border-radius: 4px; + max-height: 150px; + overflow-y: auto; +} + + +/* ── Command Panel ───────────────────────────────── */ + +.istart-command-panel { + min-width: 360px; +} + +.istart-panel-group { + margin-bottom: 12px; +} + +.istart-panel-group-title { + font-size: 11px; + font-weight: 600; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 4px 8px; + margin-bottom: 2px; +} + +.istart-panel-action { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + transition: background 0.1s; +} + +.istart-panel-action:hover { + background: var(--background-modifier-hover); +} + +.istart-panel-action-icon { + font-size: 16px; + width: 24px; + text-align: center; + flex-shrink: 0; +} + +.istart-panel-action-text { + flex: 1; + display: flex; + flex-direction: column; + gap: 1px; +} + +.istart-panel-action-label { + font-size: 14px; + color: var(--text-normal); +} + +.istart-panel-action-desc { + font-size: 11px; + color: var(--text-muted); +} + +.istart-panel-action-key { + font-size: 11px; + color: var(--text-faint); + background: var(--background-modifier-border); + border-radius: 3px; + padding: 1px 5px; + font-family: monospace; + flex-shrink: 0; +} diff --git a/versions.json b/versions.json index a141138..1302a9b 100644 --- a/versions.json +++ b/versions.json @@ -11,5 +11,6 @@ "1.6.0": "1.4.0", "1.6.1": "1.4.0", "1.7.0": "1.4.0", - "1.7.1": "1.4.0" + "1.7.1": "1.4.0", + "1.7.2": "1.4.0" }