mirror of
https://github.com/yan-istart/IStart-Note-AI-Plugin.git
synced 2026-07-22 06:51:37 +00:00
fix
This commit is contained in:
parent
f933d8e975
commit
45c86cf17e
9 changed files with 649 additions and 33 deletions
260
Readme.md
260
Readme.md
|
|
@ -1,3 +1,257 @@
|
|||
快捷键 Cmd+Shift+D 或点击侧边栏脑图标打开提问框
|
||||
在设置中填入 DeepSeek API Key
|
||||
提问后自动生成带双链的 Markdown 笔记,存入 Knowledge/Q&A/,同时自动创建 Knowledge/Concepts/ 下的概念页
|
||||
# IStart-Note-AI
|
||||
|
||||
基于 DeepSeek AI 的 Obsidian 知识图谱插件。将提问行为转化为结构化双链笔记,自动构建个人知识网络。
|
||||
|
||||
---
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 功能 | 描述 |
|
||||
|------|------|
|
||||
| 普通提问 | 输入问题 → DeepSeek 回答 → 自动生成结构化笔记 |
|
||||
| 框选提问 | 选中文本 → 基于上下文提问 → 生成带来源引用的笔记 |
|
||||
| 问题分类 | 自动判断问题类型(新问题 / 深化 / 扩展)并建立关联 |
|
||||
| 概念补全 | 按需补全空概念页,支持轻量 / 标准两种深度 |
|
||||
| 批量扫描 | 扫描 Vault 中所有空概念页,批量补全 |
|
||||
| 问题索引 | 自动维护问题图谱索引页 |
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
### 手动安装
|
||||
|
||||
1. 构建插件(见下方开发指南)
|
||||
2. 将 `dist/` 目录下的文件复制到 Vault 的 `.obsidian/plugins/istart-note-ai/`
|
||||
3. 在 Obsidian 设置 → 第三方插件中启用 **IStart-Note-AI**
|
||||
|
||||
### 目录结构要求
|
||||
|
||||
插件会自动创建以下目录(可在设置中修改路径):
|
||||
|
||||
```
|
||||
Knowledge/
|
||||
├── Q&A/ # 问答笔记
|
||||
├── Concepts/ # 概念页
|
||||
└── Questions/ # 问题索引
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 配置
|
||||
|
||||
进入 Obsidian 设置 → IStart-Note-AI:
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| API Key | DeepSeek API Key,在 [platform.deepseek.com](https://platform.deepseek.com) 获取 | 空 |
|
||||
| Base URL | API 地址 | `https://api.deepseek.com` |
|
||||
| 模型 | `deepseek-chat` 或 `deepseek-reasoner` | `deepseek-chat` |
|
||||
| Q&A 保存路径 | 问答笔记存储目录 | `Knowledge/Q&A` |
|
||||
| 概念页路径 | 概念页存储目录 | `Knowledge/Concepts` |
|
||||
| 问题索引路径 | 问题图谱索引目录 | `Knowledge/Questions` |
|
||||
| 自动打开 Graph View | 生成笔记后自动打开图谱 | 关闭 |
|
||||
|
||||
---
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 普通提问
|
||||
|
||||
- 快捷键:`Cmd/Ctrl + Shift + D`
|
||||
- 侧边栏点击脑图标
|
||||
- 命令面板:`向 DeepSeek 提问并生成知识笔记`
|
||||
|
||||
输入问题后,插件会:
|
||||
1. 调用 DeepSeek 生成回答
|
||||
2. 弹出问题分类确认弹窗(可手动调整类型)
|
||||
3. 生成结构化 Markdown 笔记,包含 Answer / Concepts / Relations / 推荐问题
|
||||
4. 自动创建相关概念页(空节点)
|
||||
5. 更新问题索引页
|
||||
|
||||
### 框选提问
|
||||
|
||||
1. 在任意笔记中选中一段文字
|
||||
2. 右键 → **IStart-Note-AI:基于选中内容提问**
|
||||
或快捷键 `Cmd/Ctrl + Shift + Q`
|
||||
3. 在弹窗中输入针对该内容的问题
|
||||
4. 生成的笔记包含来源引用,并在原笔记末尾追加反向链接
|
||||
|
||||
### 概念页补全
|
||||
|
||||
**单个补全:**
|
||||
- 打开概念页 → 命令面板:`补全当前概念页`
|
||||
- 编辑器内选中 `[[概念名]]` → 右键 → `IStart-Note-AI:补全概念 "xxx"`
|
||||
- 文件列表右键任意 `.md` 文件 → `IStart-Note-AI:补全此概念页`
|
||||
|
||||
**批量补全:**
|
||||
- 命令面板:`扫描空概念页`
|
||||
- 从列表中选择(最多 5 个)→ 选择补全深度 → 确认
|
||||
|
||||
补全深度:
|
||||
- **轻量**:定义 + 关联概念
|
||||
- **标准**:定义 + 核心解释 + 示例 + 关联概念 + 相关问题
|
||||
|
||||
所有补全内容在写入前会弹出预览窗口,支持重新生成或取消。
|
||||
|
||||
### 问题索引
|
||||
|
||||
- 命令面板:`打开问题索引`
|
||||
- 每次提问后自动更新对应索引页
|
||||
|
||||
---
|
||||
|
||||
## 笔记结构
|
||||
|
||||
### Q&A 笔记(普通提问)
|
||||
|
||||
```markdown
|
||||
---
|
||||
type: question
|
||||
question: 五行是什么?
|
||||
category: new
|
||||
parent: null
|
||||
related: []
|
||||
concepts: [五行, 木, 火, 土, 金, 水]
|
||||
status: linked
|
||||
created_at: 2026-04-25
|
||||
---
|
||||
|
||||
# 五行是什么?
|
||||
|
||||
## Question
|
||||
## Answer
|
||||
## Concepts
|
||||
## Relations
|
||||
## Tags
|
||||
## 推荐问题
|
||||
### 深化
|
||||
### 扩展
|
||||
```
|
||||
|
||||
### Q&A 笔记(框选提问)
|
||||
|
||||
```markdown
|
||||
# 为什么阴阳平衡会影响系统稳定?
|
||||
|
||||
## 来源片段
|
||||
> 阴阳平衡决定系统稳定性
|
||||
|
||||
来源:[[原始笔记路径]]
|
||||
|
||||
## Question
|
||||
## Answer
|
||||
## Concepts
|
||||
## Relations
|
||||
## 延伸问题
|
||||
## Tags
|
||||
```
|
||||
|
||||
### 概念页
|
||||
|
||||
```markdown
|
||||
---
|
||||
type: concept
|
||||
name: 五行
|
||||
status: completed
|
||||
completion_status: completed
|
||||
created_from: Q&A
|
||||
created_at: 2026-04-25
|
||||
updated_at: 2026-04-25
|
||||
---
|
||||
|
||||
# 五行
|
||||
|
||||
## 定义
|
||||
## 核心解释
|
||||
## 示例
|
||||
## 关联概念
|
||||
## 相关问题
|
||||
## 来源
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 16
|
||||
- npm >= 8
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
cd obsidian-deepseek-plugin
|
||||
npm install
|
||||
npm run dev # 监听模式,输出到 dist/main.js
|
||||
```
|
||||
|
||||
### 生产构建
|
||||
|
||||
```bash
|
||||
npm run build # 输出到 dist/main.js + dist/manifest.json
|
||||
```
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.ts # 插件入口,注册命令 / 菜单 / 设置
|
||||
├── types.ts # 全局类型定义
|
||||
├── DeepSeekClient.ts # 普通提问 API 调用
|
||||
├── ContextQAClient.ts # 框选提问 API 调用(带上下文)
|
||||
├── VaultWriter.ts # 笔记写入(Q&A / Context Q&A / 概念页)
|
||||
├── QuestionModal.ts # 普通提问弹窗
|
||||
├── ContextQAModal.ts # 框选提问弹窗
|
||||
├── QuestionClassifier.ts # 问题分类(new / refinement / expansion)
|
||||
├── QuestionClassifyModal.ts # 分类确认弹窗
|
||||
├── QuestionGraphManager.ts # 问题图谱:frontmatter / 索引页 / 推荐问题
|
||||
├── ConceptCompleter.ts # 概念补全 API 调用
|
||||
├── ConceptPageManager.ts # 概念页识别 / 增量写入 / 批量扫描
|
||||
├── ConceptCompletionModal.ts # 深度选择 / 预览确认 / 批量扫描弹窗
|
||||
└── SettingsTab.ts # 设置页面
|
||||
```
|
||||
|
||||
### 扩展开发
|
||||
|
||||
**新增 AI 功能**:参考 `ContextQAClient.ts` 的模式,实现 `ask()` 方法并返回结构化 JSON。
|
||||
|
||||
**新增命令**:在 `main.ts` 的 `onload()` 中调用 `this.addCommand()`。
|
||||
|
||||
**新增右键菜单项**:在 `editor-menu` 或 `file-menu` 事件监听中追加 `menu.addItem()`。
|
||||
|
||||
**修改笔记模板**:编辑 `VaultWriter.ts` 中的 `buildNoteContent()` 或 `buildContextNoteContent()`。
|
||||
|
||||
**修改 Prompt**:编辑对应 Client 文件中的 prompt 常量。
|
||||
|
||||
---
|
||||
|
||||
## 版本历史
|
||||
|
||||
### v1.3.0
|
||||
- 新增框选提问(Context Q&A)功能
|
||||
- 框选提问支持上下文传入、来源引用、反向链接
|
||||
- 插件更名为 IStart-Note-AI
|
||||
|
||||
### v1.2.0
|
||||
- 新增问题图谱:自动分类(new / refinement / expansion)
|
||||
- 新增问题索引页自动维护
|
||||
- 新增推荐深化 / 扩展问题
|
||||
|
||||
### v1.1.0
|
||||
- 新增概念页按需补全(轻量 / 标准)
|
||||
- 新增批量扫描空概念页
|
||||
- 新增预览确认弹窗
|
||||
- 新增右键菜单支持
|
||||
|
||||
### v1.0.0
|
||||
- 基础 Q&A 提问与笔记生成
|
||||
- 自动创建概念页与双链
|
||||
- DeepSeek API 配置
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"id": "obsidian-deepseek-knowledge",
|
||||
"name": "DeepSeek Knowledge Graph",
|
||||
"version": "1.0.0",
|
||||
"id": "istart-note-ai",
|
||||
"name": "IStart-Note-AI",
|
||||
"version": "1.3.0",
|
||||
"minAppVersion": "1.0.0",
|
||||
"description": "通过 DeepSeek AI 自动构建个人知识图谱,将问答转化为结构化双链笔记",
|
||||
"description": "基于 DeepSeek AI 的知识图谱插件:框选提问、概念补全、问题图谱、自动双链",
|
||||
"author": "Your Name",
|
||||
"authorUrl": "",
|
||||
"isDesktopOnly": false
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "obsidian-deepseek-knowledge",
|
||||
"version": "1.0.0",
|
||||
"description": "DeepSeek Knowledge Graph plugin for Obsidian",
|
||||
"name": "istart-note-ai",
|
||||
"version": "1.3.0",
|
||||
"description": "IStart-Note-AI: DeepSeek-powered knowledge graph plugin for Obsidian",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { App, TFile, parseYaml, stringifyYaml } from "obsidian";
|
||||
import { ConceptCompletionResult, CompletionDepth } from "./types";
|
||||
import { App, TFile, parseYaml, stringifyYaml, normalizePath } from "obsidian";
|
||||
import { ConceptCompletionResult, CompletionDepth, DeepSeekSettings } from "./types";
|
||||
|
||||
export interface ConceptPageInfo {
|
||||
file: TFile;
|
||||
|
|
@ -11,7 +11,7 @@ export interface ConceptPageInfo {
|
|||
}
|
||||
|
||||
export class ConceptPageManager {
|
||||
constructor(private app: App) {}
|
||||
constructor(private app: App, private settings?: DeepSeekSettings) {}
|
||||
|
||||
/** 判断当前打开的文件是否是待补全的概念页 */
|
||||
async analyzeCurrentFile(): Promise<ConceptPageInfo | null> {
|
||||
|
|
@ -77,6 +77,9 @@ export class ConceptPageManager {
|
|||
: updatedBody;
|
||||
|
||||
await this.app.vault.modify(file, newContent);
|
||||
|
||||
// 为关联概念预创建空概念页到正确路径,避免点击双链时落到根目录
|
||||
await this.ensureRelatedConceptNotes(result.related_concepts.map((c) => c.name));
|
||||
}
|
||||
|
||||
buildPreviewMarkdown(result: ConceptCompletionResult, depth: CompletionDepth): string {
|
||||
|
|
@ -174,4 +177,27 @@ export class ConceptPageManager {
|
|||
...(result.tags.length > 0 ? { tags: result.tags } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureRelatedConceptNotes(concepts: string[]): Promise<void> {
|
||||
const folderPath = normalizePath(
|
||||
this.settings?.conceptsPath ?? "Knowledge/Concepts"
|
||||
);
|
||||
|
||||
// 确保目录存在
|
||||
if (!this.app.vault.getAbstractFileByPath(folderPath)) {
|
||||
await this.app.vault.createFolder(folderPath);
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
for (const concept of concepts) {
|
||||
const filePath = normalizePath(`${folderPath}/${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: concept-completion\ncreated_at: ${today}\n---\n\n# ${concept}\n\n## 定义\n\n## 核心解释\n\n## 示例\n\n## 关联概念\n\n## 相关问题\n\n## 来源\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
79
src/ContextQAClient.ts
Normal file
79
src/ContextQAClient.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { DeepSeekSettings, ContextQAInput, ContextQAResponse, Relation } from "./types";
|
||||
|
||||
const buildPrompt = (input: ContextQAInput): string => `请基于以下上下文回答问题。
|
||||
|
||||
【上下文】
|
||||
${input.context}
|
||||
${input.surroundingContext ? `\n【周围段落】\n${input.surroundingContext}` : ""}
|
||||
|
||||
【问题】
|
||||
${input.question}
|
||||
|
||||
要求:
|
||||
1. 回答必须基于上下文,可补充必要背景知识
|
||||
2. 提取关键概念(2-5个)
|
||||
3. 识别概念间关系(影响/属于/导致/依赖/对立)
|
||||
4. 生成 2-3 个延伸问题
|
||||
5. 生成相关标签(2-4个)
|
||||
|
||||
严格按以下 JSON 格式返回,不要有任何其他内容:
|
||||
{
|
||||
"answer": "回答内容",
|
||||
"concepts": ["概念A", "概念B"],
|
||||
"relations": [
|
||||
{ "from": "概念A", "relation": "影响", "to": "概念B" }
|
||||
],
|
||||
"suggested_questions": ["延伸问题1", "延伸问题2"],
|
||||
"tags": ["标签1", "标签2"]
|
||||
}`;
|
||||
|
||||
export class ContextQAClient {
|
||||
constructor(private settings: DeepSeekSettings) {}
|
||||
|
||||
async ask(input: ContextQAInput): Promise<ContextQAResponse> {
|
||||
if (!this.settings.apiKey) {
|
||||
throw new Error("请先在插件设置中配置 API Key");
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.settings.baseUrl}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.settings.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.settings.model,
|
||||
messages: [{ role: "user", content: buildPrompt(input) }],
|
||||
temperature: 0.6,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`API 错误: ${response.status} - ${err}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices?.[0]?.message?.content ?? "";
|
||||
return this.parse(content);
|
||||
}
|
||||
|
||||
private parse(content: string): ContextQAResponse {
|
||||
const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/) ||
|
||||
content.match(/(\{[\s\S]*\})/);
|
||||
const jsonStr = jsonMatch ? jsonMatch[1] : content;
|
||||
|
||||
try {
|
||||
const p = JSON.parse(jsonStr.trim());
|
||||
return {
|
||||
answer: p.answer || "",
|
||||
concepts: Array.isArray(p.concepts) ? p.concepts : [],
|
||||
relations: Array.isArray(p.relations) ? p.relations as Relation[] : [],
|
||||
suggested_questions: Array.isArray(p.suggested_questions) ? p.suggested_questions : [],
|
||||
tags: Array.isArray(p.tags) ? p.tags : [],
|
||||
};
|
||||
} catch {
|
||||
return { answer: content, concepts: [], relations: [], suggested_questions: [], tags: [] };
|
||||
}
|
||||
}
|
||||
}
|
||||
74
src/ContextQAModal.ts
Normal file
74
src/ContextQAModal.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { App, Modal, Setting } from "obsidian";
|
||||
|
||||
export class ContextQAModal extends Modal {
|
||||
private question = "";
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private selectedText: string,
|
||||
private onSubmit: (question: string) => void
|
||||
) {
|
||||
super(app);
|
||||
this.titleEl.setText("基于选中内容提问");
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
|
||||
// 显示选中内容预览
|
||||
const preview = contentEl.createDiv({
|
||||
attr: {
|
||||
style: [
|
||||
"background: var(--background-secondary)",
|
||||
"border-left: 3px solid var(--interactive-accent)",
|
||||
"padding: 8px 12px",
|
||||
"margin-bottom: 14px",
|
||||
"border-radius: 4px",
|
||||
"font-size: 13px",
|
||||
"color: var(--text-muted)",
|
||||
"max-height: 80px",
|
||||
"overflow-y: auto",
|
||||
"white-space: pre-wrap",
|
||||
"word-break: break-word",
|
||||
].join(";"),
|
||||
},
|
||||
});
|
||||
preview.setText(
|
||||
this.selectedText.length > 200
|
||||
? this.selectedText.slice(0, 200) + "…"
|
||||
: this.selectedText
|
||||
);
|
||||
|
||||
const textArea = contentEl.createEl("textarea", {
|
||||
attr: {
|
||||
placeholder: "针对上方内容,输入你的问题...",
|
||||
rows: "3",
|
||||
style: "width:100%; resize:vertical; padding:8px; font-size:14px;",
|
||||
},
|
||||
});
|
||||
|
||||
textArea.addEventListener("input", () => (this.question = textArea.value));
|
||||
textArea.addEventListener("keydown", (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") this.submit();
|
||||
});
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton((btn) =>
|
||||
btn.setButtonText("提问 (Ctrl+Enter)").setCta().onClick(() => this.submit())
|
||||
)
|
||||
.addButton((btn) => btn.setButtonText("取消").onClick(() => this.close()));
|
||||
|
||||
setTimeout(() => textArea.focus(), 50);
|
||||
}
|
||||
|
||||
private submit() {
|
||||
const q = this.question.trim();
|
||||
if (!q) return;
|
||||
this.close();
|
||||
this.onSubmit(q);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { App, TFile, normalizePath } from "obsidian";
|
||||
import { DeepSeekResponse, DeepSeekSettings } from "./types";
|
||||
import { DeepSeekResponse, DeepSeekSettings, ContextQAInput, ContextQAResponse } from "./types";
|
||||
|
||||
export class VaultWriter {
|
||||
constructor(private app: App, private settings: DeepSeekSettings) {}
|
||||
|
|
@ -11,13 +11,11 @@ export class VaultWriter {
|
|||
const folderPath = normalizePath(this.settings.savePath);
|
||||
const filePath = normalizePath(`${folderPath}/${filename}`);
|
||||
|
||||
// 确保目录存在
|
||||
await this.ensureFolder(folderPath);
|
||||
|
||||
const content = this.buildNoteContent(question, response);
|
||||
const file = await this.app.vault.create(filePath, content);
|
||||
|
||||
// 自动创建概念页(V2 功能)
|
||||
for (const concept of response.concepts) {
|
||||
await this.ensureConceptNote(concept);
|
||||
}
|
||||
|
|
@ -25,15 +23,32 @@ export class VaultWriter {
|
|||
return file;
|
||||
}
|
||||
|
||||
private buildNoteContent(question: string, response: DeepSeekResponse): string {
|
||||
const conceptLinks = response.concepts
|
||||
.map((c) => `- [[${c}]]`)
|
||||
.join("\n");
|
||||
async writeContextQANote(input: ContextQAInput, response: ContextQAResponse): Promise<TFile> {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const safeTitle = this.sanitizeFilename(input.question).slice(0, 50);
|
||||
const filename = `${date}-ctx-${safeTitle}.md`;
|
||||
const folderPath = normalizePath(this.settings.savePath);
|
||||
const filePath = normalizePath(`${folderPath}/${filename}`);
|
||||
|
||||
await this.ensureFolder(folderPath);
|
||||
|
||||
const content = this.buildContextNoteContent(input, response);
|
||||
const file = await this.app.vault.create(filePath, content);
|
||||
|
||||
for (const concept of response.concepts) {
|
||||
await this.ensureConceptNote(concept);
|
||||
}
|
||||
|
||||
await this.appendBacklink(input.sourceNote, file.path, input.question);
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
private buildNoteContent(question: string, response: DeepSeekResponse): string {
|
||||
const conceptLinks = response.concepts.map((c) => `- [[${c}]]`).join("\n");
|
||||
const relationLines = response.relations
|
||||
.map((r) => `- [[${r.from}]] -${r.relation}-> [[${r.to}]]`)
|
||||
.join("\n");
|
||||
|
||||
const tagLine = response.tags.map((t) => `#${t.replace(/\s+/g, "_")}`).join(" ");
|
||||
|
||||
return `# ${question}
|
||||
|
|
@ -55,6 +70,63 @@ ${tagLine || "暂无标签"}
|
|||
`;
|
||||
}
|
||||
|
||||
private buildContextNoteContent(input: ContextQAInput, response: ContextQAResponse): string {
|
||||
const conceptLinks = response.concepts.map((c) => `- [[${c}]]`).join("\n");
|
||||
const relationLines = response.relations
|
||||
.map((r) => `- [[${r.from}]] -${r.relation}-> [[${r.to}]]`)
|
||||
.join("\n");
|
||||
const tagLine = response.tags.map((t) => `#${t.replace(/\s+/g, "_")}`).join(" ");
|
||||
const suggestedLines = response.suggested_questions.map((q) => `- ${q}`).join("\n");
|
||||
const sourceLink = input.sourceNote ? `[[${input.sourceNote}]]` : "未知来源";
|
||||
|
||||
return `# ${input.question}
|
||||
|
||||
## 来源片段
|
||||
> ${input.context.split("\n").join("\n> ")}
|
||||
|
||||
来源:${sourceLink}
|
||||
|
||||
## Question
|
||||
${input.question}
|
||||
|
||||
## Answer
|
||||
${response.answer}
|
||||
|
||||
## Concepts
|
||||
${conceptLinks || "- 暂无"}
|
||||
|
||||
## Relations
|
||||
${relationLines || "- 暂无"}
|
||||
|
||||
## 延伸问题
|
||||
${suggestedLines || "- 暂无"}
|
||||
|
||||
## Tags
|
||||
${tagLine || "暂无标签"}
|
||||
`;
|
||||
}
|
||||
|
||||
private async appendBacklink(sourceNotePath: string, qaFilePath: string, question: string): Promise<void> {
|
||||
if (!sourceNotePath) return;
|
||||
const sourceFile = this.app.vault.getAbstractFileByPath(sourceNotePath) as TFile | null;
|
||||
if (!sourceFile) return;
|
||||
|
||||
const content = await this.app.vault.read(sourceFile);
|
||||
const backlinkSection = "## 相关问答";
|
||||
const link = `- [[${qaFilePath}|${question}]]`;
|
||||
|
||||
if (content.includes(link)) return;
|
||||
|
||||
if (content.includes(backlinkSection)) {
|
||||
await this.app.vault.modify(
|
||||
sourceFile,
|
||||
content.replace(backlinkSection, `${backlinkSection}\n${link}`)
|
||||
);
|
||||
} else {
|
||||
await this.app.vault.modify(sourceFile, content.trimEnd() + `\n\n${backlinkSection}\n${link}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async ensureConceptNote(concept: string): Promise<void> {
|
||||
const folderPath = normalizePath(this.settings.conceptsPath || "Knowledge/Concepts");
|
||||
const filePath = normalizePath(`${folderPath}/${concept}.md`);
|
||||
|
|
|
|||
120
src/main.ts
120
src/main.ts
|
|
@ -10,6 +10,8 @@ import { DepthSelectModal, PreviewModal, BatchScanModal } from "./ConceptComplet
|
|||
import { QuestionClassifier } from "./QuestionClassifier";
|
||||
import { QuestionGraphManager } from "./QuestionGraphManager";
|
||||
import { QuestionClassifyModal } from "./QuestionClassifyModal";
|
||||
import { ContextQAClient } from "./ContextQAClient";
|
||||
import { ContextQAModal } from "./ContextQAModal";
|
||||
|
||||
export default class DeepSeekPlugin extends Plugin {
|
||||
settings: DeepSeekSettings;
|
||||
|
|
@ -44,6 +46,22 @@ export default class DeepSeekPlugin extends Plugin {
|
|||
callback: () => this.scanAndBatchComplete(),
|
||||
});
|
||||
|
||||
// 命令:框选提问
|
||||
this.addCommand({
|
||||
id: "context-qa",
|
||||
name: "基于选中内容提问",
|
||||
hotkeys: [{ modifiers: ["Mod", "Shift"], key: "q" }],
|
||||
editorCallback: (editor) => {
|
||||
const selection = editor.getSelection().trim();
|
||||
if (!selection) {
|
||||
new Notice("请先选中一段文字");
|
||||
return;
|
||||
}
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
this.openContextQAModal(selection, activeFile?.path ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
// 命令:问题图谱索引
|
||||
this.addCommand({
|
||||
id: "open-question-index",
|
||||
|
|
@ -59,10 +77,10 @@ export default class DeepSeekPlugin extends Plugin {
|
|||
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle("DeepSeek:补全此概念页")
|
||||
.setTitle("IStart-Note-AI:补全此概念页")
|
||||
.setIcon("brain")
|
||||
.onClick(async () => {
|
||||
const manager = new ConceptPageManager(this.app);
|
||||
const manager = new ConceptPageManager(this.app, this.settings);
|
||||
const info = await manager.analyzeFile(file);
|
||||
if (!info) {
|
||||
new Notice("该文件不是概念页");
|
||||
|
|
@ -82,8 +100,22 @@ export default class DeepSeekPlugin extends Plugin {
|
|||
// 编辑器内右键菜单
|
||||
this.registerEvent(
|
||||
this.app.workspace.on("editor-menu", (menu, editor) => {
|
||||
// 检查选中文字是否是 [[概念]] 格式
|
||||
const selection = editor.getSelection().trim();
|
||||
|
||||
// 框选提问入口(有选中内容时显示)
|
||||
if (selection) {
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle("IStart-Note-AI:基于选中内容提问")
|
||||
.setIcon("message-circle")
|
||||
.onClick(() => {
|
||||
const activeFile = this.app.workspace.getActiveFile();
|
||||
this.openContextQAModal(selection, activeFile?.path ?? "");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 概念补全入口
|
||||
const linkMatch = selection.match(/^\[\[(.+?)(?:\|.+?)?\]\]$/) ||
|
||||
selection.match(/^(.+)$/);
|
||||
const conceptName = linkMatch?.[1];
|
||||
|
|
@ -91,16 +123,14 @@ export default class DeepSeekPlugin extends Plugin {
|
|||
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle(`DeepSeek:补全概念 "${conceptName}"`)
|
||||
.setTitle(`IStart-Note-AI:补全概念 "${conceptName}"`)
|
||||
.setIcon("brain")
|
||||
.onClick(async () => {
|
||||
const manager = new ConceptPageManager(this.app);
|
||||
// 尝试找到对应概念页文件
|
||||
const manager = new ConceptPageManager(this.app, this.settings);
|
||||
const conceptsPath = this.settings.conceptsPath || "Knowledge/Concepts";
|
||||
const filePath = `${conceptsPath}/${conceptName}.md`;
|
||||
let file = this.app.vault.getAbstractFileByPath(filePath) as TFile | null;
|
||||
|
||||
// 不存在则先创建
|
||||
if (!file) {
|
||||
const writer = new VaultWriter(this.app, this.settings);
|
||||
await writer.ensureConceptNote(conceptName);
|
||||
|
|
@ -122,10 +152,9 @@ export default class DeepSeekPlugin extends Plugin {
|
|||
});
|
||||
});
|
||||
|
||||
// 当前文件是概念页时,也提供补全入口
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle("DeepSeek:补全当前概念页")
|
||||
.setTitle("IStart-Note-AI:补全当前概念页")
|
||||
.setIcon("brain")
|
||||
.onClick(() => this.completeCurrentConcept());
|
||||
});
|
||||
|
|
@ -133,6 +162,73 @@ export default class DeepSeekPlugin extends Plugin {
|
|||
);
|
||||
}
|
||||
|
||||
private openContextQAModal(selectedText: string, sourceNotePath: string) {
|
||||
new ContextQAModal(this.app, selectedText, (question) => {
|
||||
this.processContextQA(question, selectedText, sourceNotePath);
|
||||
}).open();
|
||||
}
|
||||
|
||||
private async processContextQA(question: string, context: string, sourceNotePath: string) {
|
||||
const notice = new Notice("⏳ 基于上下文思考中...", 0);
|
||||
|
||||
try {
|
||||
const client = new ContextQAClient(this.settings);
|
||||
const graphManager = new QuestionGraphManager(this.app, this.settings);
|
||||
|
||||
// 获取周围段落作为补充上下文(取文件前 500 字)
|
||||
let surroundingContext: string | undefined;
|
||||
if (sourceNotePath) {
|
||||
const sourceFile = this.app.vault.getAbstractFileByPath(sourceNotePath) as TFile | null;
|
||||
if (sourceFile) {
|
||||
const fullContent = await this.app.vault.read(sourceFile);
|
||||
surroundingContext = fullContent.slice(0, 500);
|
||||
}
|
||||
}
|
||||
|
||||
const [response, history] = await Promise.all([
|
||||
client.ask({ question, context, sourceNote: sourceNotePath, surroundingContext }),
|
||||
graphManager.getQuestionHistory(),
|
||||
]);
|
||||
|
||||
notice.hide();
|
||||
|
||||
// 分类
|
||||
const classifier = new QuestionClassifier(this.settings);
|
||||
const classifyNotice = new Notice("🔍 分析问题关系...", 0);
|
||||
const classification = await classifier.classify(question, history);
|
||||
classifyNotice.hide();
|
||||
|
||||
new QuestionClassifyModal(this.app, question, classification, async (confirmed) => {
|
||||
const writeNotice = new Notice("✍️ 写入笔记...", 0);
|
||||
try {
|
||||
const writer = new VaultWriter(this.app, this.settings);
|
||||
const file = await writer.writeContextQANote(
|
||||
{ question, context, sourceNote: sourceNotePath, surroundingContext },
|
||||
response
|
||||
);
|
||||
|
||||
await graphManager.attachClassification(file, question, confirmed, response.concepts);
|
||||
await graphManager.appendRecommendations(file, confirmed);
|
||||
await graphManager.updateQuestionIndex(question, confirmed, file.path);
|
||||
|
||||
writeNotice.hide();
|
||||
new Notice(`✅ 上下文笔记已生成:${file.name}`);
|
||||
|
||||
const leaf = this.app.workspace.getLeaf(false);
|
||||
await leaf.openFile(file);
|
||||
} catch (err) {
|
||||
writeNotice.hide();
|
||||
new Notice(`❌ 写入失败:${err.message}`);
|
||||
console.error("[IStart-Note-AI]", err);
|
||||
}
|
||||
}).open();
|
||||
} catch (err) {
|
||||
notice.hide();
|
||||
new Notice(`❌ 错误:${err.message}`);
|
||||
console.error("[IStart-Note-AI]", err);
|
||||
}
|
||||
}
|
||||
|
||||
private openQuestionModal() {
|
||||
new QuestionModal(this.app, (question) => {
|
||||
this.processQuestion(question);
|
||||
|
|
@ -212,7 +308,7 @@ export default class DeepSeekPlugin extends Plugin {
|
|||
}
|
||||
|
||||
private async completeCurrentConcept() {
|
||||
const manager = new ConceptPageManager(this.app);
|
||||
const manager = new ConceptPageManager(this.app, this.settings);
|
||||
const info = await manager.analyzeCurrentFile();
|
||||
|
||||
if (!info) {
|
||||
|
|
@ -241,7 +337,7 @@ export default class DeepSeekPlugin extends Plugin {
|
|||
const result = await completer.complete(conceptName, depth, context);
|
||||
notice.hide();
|
||||
|
||||
const manager = new ConceptPageManager(this.app);
|
||||
const manager = new ConceptPageManager(this.app, this.settings);
|
||||
const previewMd = manager.buildPreviewMarkdown(result, depth);
|
||||
|
||||
new PreviewModal(
|
||||
|
|
@ -267,7 +363,7 @@ export default class DeepSeekPlugin extends Plugin {
|
|||
|
||||
private async scanAndBatchComplete() {
|
||||
const notice = new Notice("🔍 扫描空概念页中...", 0);
|
||||
const manager = new ConceptPageManager(this.app);
|
||||
const manager = new ConceptPageManager(this.app, this.settings);
|
||||
const empties = await manager.scanEmptyConcepts();
|
||||
notice.hide();
|
||||
|
||||
|
|
|
|||
15
src/types.ts
15
src/types.ts
|
|
@ -52,3 +52,18 @@ export interface Relation {
|
|||
relation: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export interface ContextQAInput {
|
||||
question: string;
|
||||
context: string; // 框选内容
|
||||
sourceNote: string; // 来源文件路径
|
||||
surroundingContext?: string; // 上下文段落(可选)
|
||||
}
|
||||
|
||||
export interface ContextQAResponse {
|
||||
answer: string;
|
||||
concepts: string[];
|
||||
relations: Relation[];
|
||||
suggested_questions: string[];
|
||||
tags: string[];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue