mirror of
https://github.com/yan-istart/IStart-Note-AI-Plugin.git
synced 2026-07-22 06:51:37 +00:00
release: 1.7.2
This commit is contained in:
parent
bef0ecbc87
commit
22fda29f01
13 changed files with 1991 additions and 78 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
284
src/ai/ReadingPlanner.ts
Normal file
284
src/ai/ReadingPlanner.ts
Normal file
|
|
@ -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<ReadingPlan> {
|
||||
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<ChapterDetail> {
|
||||
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<ChapterSummaryResult> {
|
||||
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<FeynmanQuestion[]> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
165
src/ai/SmartCompleter.ts
Normal file
165
src/ai/SmartCompleter.ts
Normal file
|
|
@ -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<SmartCompletionResult> {
|
||||
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<SmartCompletionResult> {
|
||||
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<SmartCompletionResult> {
|
||||
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<DocumentSuggestion[]> {
|
||||
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<string> {
|
||||
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<string, unknown>;
|
||||
const suggestions = p.suggestions;
|
||||
if (!Array.isArray(suggestions)) return [];
|
||||
return suggestions as DocumentSuggestion[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
176
src/features/command-panel/CommandPanelModal.ts
Normal file
176
src/features/command-panel/CommandPanelModal.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -65,15 +65,15 @@ export class ConceptPageManager {
|
|||
): Promise<void> {
|
||||
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
|
||||
|
|
|
|||
114
src/features/reading/ReadingModal.ts
Normal file
114
src/features/reading/ReadingModal.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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();
|
||||
}
|
||||
}
|
||||
369
src/features/reading/ReadingProjectManager.ts
Normal file
369
src/features/reading/ReadingProjectManager.ts
Normal file
|
|
@ -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<TFile> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
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<TFile> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
123
src/features/smart-complete/SmartCompleteModal.ts
Normal file
123
src/features/smart-complete/SmartCompleteModal.ts
Normal file
|
|
@ -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<number>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
719
src/main.ts
719
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) {
|
||||
|
|
|
|||
106
styles.css
106
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue